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

Merge pull request #8199 from weseek/master

Release v6.2.3
Yuki Takei 2 лет назад
Родитель
Сommit
abdc70d310
51 измененных файлов с 725 добавлено и 213 удалено
  1. 1 1
      apps/app/package.json
  2. 1 1
      apps/app/src/components/Layout/MainPane.tsx
  3. 1 4
      apps/app/src/components/Layout/ShareLinkLayout.tsx
  4. 1 1
      apps/app/src/components/Navbar/GrowiNavbarBottom.tsx
  5. 2 2
      apps/app/src/components/PageSideContents.tsx
  6. 10 5
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx
  7. 12 6
      apps/app/src/pages/share/[[...path]].page.tsx
  8. 2 0
      apps/app/src/server/crowi/index.js
  9. 0 54
      apps/app/src/server/middlewares/certify-shared-file.js
  10. 145 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.spec.ts
  11. 49 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts
  12. 1 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/index.ts
  13. 4 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/interfaces.ts
  14. 28 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts
  15. 25 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-attachment.ts
  16. 1 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/index.ts
  17. 59 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.spec.ts
  18. 22 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts
  19. 131 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.spec.ts
  20. 69 0
      apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts
  21. 1 1
      apps/app/src/server/middlewares/certify-shared-page.js
  22. 0 1
      apps/app/src/server/models/index.js
  23. 0 39
      apps/app/src/server/models/share-link.js
  24. 46 0
      apps/app/src/server/models/share-link.ts
  25. 46 44
      apps/app/src/server/routes/apiv3/attachment.js
  26. 1 2
      apps/app/src/server/routes/apiv3/security-settings/index.js
  27. 2 5
      apps/app/src/server/routes/apiv3/share-links.js
  28. 3 3
      apps/app/src/server/routes/index.js
  29. 1 1
      apps/app/src/server/service/page.ts
  30. 2 2
      apps/app/src/server/util/mongoose-utils.ts
  31. 1 0
      apps/app/src/services/renderer/remark-plugins/attachment.ts
  32. 4 5
      apps/app/src/stores/attachment.tsx
  33. 2 0
      apps/app/src/styles/_mixins.scss
  34. 7 0
      apps/app/src/styles/_share-link.scss
  35. 0 12
      apps/app/src/styles/_sharelink.scss
  36. 20 0
      apps/app/src/styles/mixins/_share-link.scss
  37. 1 0
      apps/app/src/styles/style-app.scss
  38. 1 1
      apps/slackbot-proxy/package.json
  39. 1 1
      package.json
  40. 1 1
      packages/core/package.json
  41. 1 1
      packages/hackmd/package.json
  42. 1 1
      packages/presentation/package.json
  43. 1 1
      packages/preset-templates/package.json
  44. 1 1
      packages/preset-themes/package.json
  45. 1 1
      packages/remark-attachment-refs/package.json
  46. 1 1
      packages/remark-drawio/package.json
  47. 1 1
      packages/remark-growi-directive/package.json
  48. 1 1
      packages/remark-lsx/package.json
  49. 1 1
      packages/slack/package.json
  50. 1 1
      packages/ui/package.json
  51. 11 11
      yarn.lock

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",

+ 1 - 1
apps/app/src/components/Layout/MainPane.tsx

@@ -23,7 +23,7 @@ export const MainPane = (props: Props): JSX.Element => {
                   <div className="flex-grow-1 flex-basis-0 mw-0">
                     {children}
                   </div>
-                  <div className="grw-side-contents-container d-edit-none" data-vrt-blackout-side-contents>
+                  <div className="grw-side-contents-container d-edit-none d-print-none" data-vrt-blackout-side-contents>
                     <div className="grw-side-contents-sticky-container">
                       {sideContents}
                     </div>

+ 1 - 4
apps/app/src/components/Layout/ShareLinkLayout.tsx

@@ -2,7 +2,6 @@ import React, { ReactNode } from 'react';
 
 import dynamic from 'next/dynamic';
 
-import { useEditorModeClassName } from '../../client/services/layout';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 
 import { RawLayout } from './RawLayout';
@@ -21,10 +20,8 @@ type Props = {
 }
 
 export const ShareLinkLayout = ({ children }: Props): JSX.Element => {
-  const className = useEditorModeClassName();
-
   return (
-    <RawLayout className={className}>
+    <RawLayout className="share-link">
       <GrowiNavbar isGlobalSearchHidden />
 
       <div className="page-wrapper d-flex d-print-block">

+ 1 - 1
apps/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -24,7 +24,7 @@ export const GrowiNavbarBottom = (): JSX.Element => {
   }
 
   return (
-    <div className="d-md-none d-edit-none fixed-bottom">
+    <div className="d-md-none d-edit-none d-print-none fixed-bottom">
 
       { isDeviceSmallerThanMd && !isSearchPage && (
         <div id="grw-global-search-collapse" className="grw-global-search collapse bg-dark">

+ 2 - 2
apps/app/src/components/PageSideContents.tsx

@@ -41,7 +41,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   return (
     <>
       {/* Page list */}
-      <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']} d-print-none`}>
+      <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
         {!isSharedUser && (
           <button
             type="button"
@@ -64,7 +64,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
       {/* Comments */}
       {!isTopPagePath && (
-        <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']} d-print-none`}>
+        <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
           <Link to="page-comments" offset={-120}>
             <button
               type="button"

+ 10 - 5
apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -9,11 +9,14 @@ import { useDeleteAttachmentModal } from '~/stores/modal';
 
 import styles from './RichAttachment.module.scss';
 
-export const RichAttachment: React.FC<{
+type RichAttachmentProps = {
   attachmentId: string,
   url: string,
-  attachmentName: string
-}> = React.memo(({ attachmentId, url, attachmentName }) => {
+  attachmentName: string,
+}
+
+export const RichAttachment = React.memo((props: RichAttachmentProps) => {
+  const { attachmentId, attachmentName } = props;
   const { t } = useTranslation();
   const { data: attachment, remove } = useSWRxAttachment(attachmentId);
   const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
@@ -58,13 +61,15 @@ export const RichAttachment: React.FC<{
           </div>
           <div className="pl-0">
             <div className="d-inline-block">
-              <a target="_blank" rel="noopener noreferrer" href={filePathProxied}>
+              {/* Since we need to include the "referer" to view the attachment on the shared page */}
+              {/* eslint-disable-next-line react/jsx-no-target-blank */}
+              <a target="_blank" rel="noopener" href={filePathProxied}>
                 {attachmentName || originalName}
               </a>
               <a className="ml-2 attachment-download" href={downloadPathProxied}>
                 <i className="icon-cloud-download" />
               </a>
-              <a className="ml-2 text-danger attachment-delete" onClick={onClickTrashButtonHandler}>
+              <a className="ml-2 text-danger attachment-delete d-share-link-none" type="button" onClick={onClickTrashButtonHandler}>
                 <i className="icon-trash" />
               </a>
             </div>

+ 12 - 6
apps/app/src/pages/share/[[...path]].page.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect } from 'react';
 
-import type { IUserHasId, IPagePopulatedToShowRevision } from '@growi/core';
+import { type IUserHasId, type IPagePopulatedToShowRevision, getIdForRef } from '@growi/core';
 import type {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -18,6 +18,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
+import ShareLink from '~/server/models/share-link';
 import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid, useIsEnabledMarp,
@@ -233,18 +234,23 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   const props: Props = result.props as Props;
 
   try {
-    const ShareLinkModel = crowi.model('ShareLink');
-    const shareLink = await ShareLinkModel.findOne({ _id: params.linkId }).populate('relatedPage');
+    const shareLink = await ShareLink.findOne({ _id: params.linkId }).populate('relatedPage');
     if (shareLink == null) {
       props.isNotFound = true;
     }
     else {
       props.isNotFound = false;
-      const ssrMaxRevisionBodyLength = crowi.configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
-      props.skipSSR = await skipSSR(shareLink.relatedPage, ssrMaxRevisionBodyLength);
-      props.shareLinkRelatedPage = await shareLink.relatedPage.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();
+
+      // retrieve Page
+      const Page = crowi.model('Page');
+      const relatedPage = await Page.findOne({ _id: getIdForRef(shareLink.relatedPage) });
+      // determine whether skip SSR
+      const ssrMaxRevisionBodyLength = crowi.configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
+      props.skipSSR = await skipSSR(relatedPage, ssrMaxRevisionBodyLength);
+      // populate
+      props.shareLinkRelatedPage = await relatedPage.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
     }
   }
   catch (err) {

+ 2 - 0
apps/app/src/server/crowi/index.js

@@ -20,6 +20,7 @@ import { projectRoot } from '~/utils/project-dir-utils';
 import UserEvent from '../events/user';
 import Activity from '../models/activity';
 import PageRedirect from '../models/page-redirect';
+import ShareLink from '../models/share-link';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
@@ -303,6 +304,7 @@ Crowi.prototype.setupModels = async function() {
   allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
+  allModels.ShareLink = ShareLink;
 
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));

+ 0 - 54
apps/app/src/server/middlewares/certify-shared-file.js

@@ -1,54 +0,0 @@
-import loggerFactory from '~/utils/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;
-
-    // Attachments cannot be viewed by clients who do not send referer.
-    // https://github.com/weseek/growi/issues/2819
-    if (referer == null) {
-      return next();
-    }
-
-    const { path } = url.parse(referer);
-
-    if (!path.startsWith('/share/')) {
-      return 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) {
-      return next();
-    }
-
-    const shareLinks = await ShareLink.find({ relatedPage: attachment.page });
-
-    // If sharelinks don't exist, skip it
-    if (shareLinks.length === 0) {
-      return 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();
-  };
-
-};

+ 145 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.spec.ts

@@ -0,0 +1,145 @@
+import type { Response } from 'express';
+import { mock } from 'vitest-mock-extended';
+
+import { ShareLinkDocument } from '~/server/models/share-link';
+
+import { certifySharedPageAttachmentMiddleware, type RequestToAllowShareLink } from './certify-shared-page-attachment';
+import { ValidReferer } from './interfaces';
+
+const mocks = vi.hoisted(() => {
+  return {
+    validateRefererMock: vi.fn(),
+    retrieveValidShareLinkByRefererMock: vi.fn(),
+    validateAttachmentMock: vi.fn(),
+  };
+});
+
+vi.mock('./validate-referer', () => ({ validateReferer: mocks.validateRefererMock }));
+vi.mock('./retrieve-valid-share-link', () => ({ retrieveValidShareLinkByReferer: mocks.retrieveValidShareLinkByRefererMock }));
+vi.mock('./validate-attachment', () => ({ validateAttachment: mocks.validateAttachmentMock }));
+
+
+describe('certifySharedPageAttachmentMiddleware', () => {
+
+  const res = mock<Response>();
+  const next = vi.fn();
+
+  describe('should called next() without req.isSharedPage set', () => {
+
+    it('when the fileId param is null', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = {}; // id: undefined
+      req.headers = {};
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).not.toHaveBeenCalled();
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+    it('when validateReferer returns null', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = { id: 'file id string' };
+      req.headers = { referer: 'referer string' };
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+    it('when retrieveValidShareLinkByReferer returns null', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = { id: 'file id string' };
+      req.headers = { referer: 'referer string' };
+
+      const validReferer: ValidReferer = {
+        referer: 'referer string',
+        shareLinkId: 'ffffffffffffffffffffffff',
+      };
+      mocks.validateRefererMock.mockImplementation(() => validReferer);
+
+      mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(null);
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+    it('when validateAttachment returns false', async() => {
+      // setup
+      const req = mock<RequestToAllowShareLink>();
+      req.params = { id: 'file id string' };
+      req.headers = { referer: 'referer string' };
+
+      const validReferer = vi.fn();
+      mocks.validateRefererMock.mockImplementation(() => validReferer);
+
+      const shareLinkMock = mock<ShareLinkDocument>();
+      mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(shareLinkMock);
+
+      mocks.validateAttachmentMock.mockResolvedValue(false);
+
+      // when
+      await certifySharedPageAttachmentMiddleware(req, res, next);
+
+      // then
+      expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
+      expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+      expect(mocks.validateAttachmentMock).toHaveBeenCalledOnce();
+      expect(mocks.validateAttachmentMock).toHaveBeenCalledWith('file id string', shareLinkMock);
+      expect(req.isSharedPage === true).toBeFalsy();
+      expect(next).toHaveBeenCalledOnce();
+    });
+
+  });
+
+  it('should set req.isSharedPage true', async() => {
+    // setup
+    const req = mock<RequestToAllowShareLink>();
+    req.params = { id: 'file id string' };
+    req.headers = { referer: 'referer string' };
+
+    const validReferer = vi.fn();
+    mocks.validateRefererMock.mockImplementation(() => validReferer);
+
+    const shareLinkMock = mock<ShareLinkDocument>();
+    mocks.retrieveValidShareLinkByRefererMock.mockResolvedValue(shareLinkMock);
+
+    mocks.validateAttachmentMock.mockResolvedValue(true);
+
+    // when
+    await certifySharedPageAttachmentMiddleware(req, res, next);
+
+    // then
+    expect(mocks.validateRefererMock).toHaveBeenCalledOnce();
+    expect(mocks.validateRefererMock).toHaveBeenCalledWith('referer string');
+    expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledOnce();
+    expect(mocks.retrieveValidShareLinkByRefererMock).toHaveBeenCalledWith(validReferer);
+    expect(mocks.validateAttachmentMock).toHaveBeenCalledOnce();
+    expect(mocks.validateAttachmentMock).toHaveBeenCalledWith('file id string', shareLinkMock);
+
+    expect(req.isSharedPage === true).toBeTruthy();
+
+    expect(next).toHaveBeenCalledOnce();
+  });
+});

+ 49 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/certify-shared-page-attachment.ts

@@ -0,0 +1,49 @@
+import type { NextFunction, Request, Response } from 'express';
+
+import loggerFactory from '~/utils/logger';
+
+import { retrieveValidShareLinkByReferer } from './retrieve-valid-share-link';
+import { validateAttachment } from './validate-attachment';
+import { validateReferer } from './validate-referer';
+
+
+const logger = loggerFactory('growi:middleware:certify-shared-page-attachment');
+
+
+export interface RequestToAllowShareLink extends Request {
+  isSharedPage?: boolean,
+}
+
+export const certifySharedPageAttachmentMiddleware = async(req: RequestToAllowShareLink, res: Response, next: NextFunction): Promise<void> => {
+
+  const fileId: string | undefined = req.params.id;
+  const { referer } = req.headers;
+
+  if (fileId == null) {
+    logger.error('The param fileId is required. Please confirm to usage of this middleware.');
+    return next();
+  }
+
+  const validReferer = validateReferer(referer);
+  if (!validReferer) {
+    logger.info('invalid referer.');
+    return next();
+  }
+
+  logger.info('referer is valid.');
+
+  const shareLink = await retrieveValidShareLinkByReferer(validReferer);
+  if (shareLink == null) {
+    logger.info(`No valid ShareLink document found by the referer (${validReferer.referer}})`);
+    return next();
+  }
+
+  if (!(await validateAttachment(fileId, shareLink))) {
+    logger.info(`No valid ShareLink document found by the fileId (${fileId}) and referer (${validReferer.referer}})`);
+    return next();
+  }
+
+  req.isSharedPage = true;
+  next();
+
+};

+ 1 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/index.ts

@@ -0,0 +1 @@
+export * from './certify-shared-page-attachment';

+ 4 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/interfaces.ts

@@ -0,0 +1,4 @@
+export type ValidReferer = {
+  referer: string,
+  shareLinkId: string,
+};

+ 28 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/retrieve-valid-share-link.ts

@@ -0,0 +1,28 @@
+import type { ShareLinkDocument, ShareLinkModel } from '~/server/models/share-link';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+import type { ValidReferer } from './interfaces';
+
+
+const logger = loggerFactory('growi:middleware:certify-shared-page-attachment:retrieve-valid-share-link');
+
+
+export const retrieveValidShareLinkByReferer = async(referer: ValidReferer): Promise<ShareLinkDocument | null> => {
+  const ShareLink = getModelSafely<ShareLinkDocument, ShareLinkModel>('ShareLink');
+  if (ShareLink == null) {
+    logger.warn('Could not get ShareLink model. next() will be called without processing anything.');
+    return null;
+  }
+
+  const shareLinkId = referer;
+  const shareLink = await ShareLink.findOne({
+    id: shareLinkId,
+  });
+  if (shareLink == null || shareLink.isExpired()) {
+    logger.info(`ShareLink ('${shareLinkId}') is not found or has already expired.`);
+    return null;
+  }
+
+  return shareLink;
+};

+ 25 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-attachment.ts

@@ -0,0 +1,25 @@
+import { getIdForRef, type IAttachment } from '@growi/core';
+
+import { ShareLinkDocument } from '~/server/models/share-link';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:middleware:certify-shared-page-attachment:validate-attachment');
+
+
+export const validateAttachment = async(fileId: string, shareLink: ShareLinkDocument): Promise<boolean> => {
+  const Attachment = getModelSafely<IAttachment>('Attachment');
+  if (Attachment == null) {
+    logger.warn('Could not get Attachment model. next() will be called without processing anything.');
+    return false;
+  }
+
+  const relatedPageId = getIdForRef(shareLink.relatedPage);
+  const result = await Attachment.exists({
+    _id: fileId,
+    page: relatedPageId,
+  });
+
+  return result != null;
+};

+ 1 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/index.ts

@@ -0,0 +1 @@
+export * from './validate-referer';

+ 59 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.spec.ts

@@ -0,0 +1,59 @@
+import { retrieveSiteUrl } from './retrieve-site-url';
+
+const mocks = vi.hoisted(() => {
+  return {
+    configManagerMock: {
+      getConfig: vi.fn(),
+    },
+  };
+});
+
+vi.mock('~/server/service/config-manager', () => {
+  return { configManager: mocks.configManagerMock };
+});
+
+
+describe('retrieveSiteUrl', () => {
+
+  describe('returns null', () => {
+
+    it('when the siteUrl is not set', () => {
+      // setup
+      mocks.configManagerMock.getConfig.mockImplementation(() => {
+        return null;
+      });
+
+      // when
+      const result = retrieveSiteUrl();
+
+      // then
+      expect(result).toBeNull();
+    });
+
+    it('when the siteUrl is invalid', () => {
+      // setup
+      mocks.configManagerMock.getConfig.mockImplementation(() => {
+        return 'invalid siteUrl string';
+      });
+
+      // when
+      const result = retrieveSiteUrl();
+
+      // then
+      expect(result).toBeNull();
+    });
+  });
+
+  it('returns a URL instance', () => {
+    // setup
+    const siteUrl = 'https://example.com';
+    mocks.configManagerMock.getConfig.mockImplementation(() => siteUrl);
+
+    // when
+    const result = retrieveSiteUrl();
+
+    // then
+    expect(result).toEqual(new URL(siteUrl));
+  });
+
+});

+ 22 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/retrieve-site-url.ts

@@ -0,0 +1,22 @@
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer:retrieve-site-url');
+
+
+export const retrieveSiteUrl = (): URL | null => {
+  const siteUrlString = configManager.getConfig('crowi', 'app:siteUrl');
+  if (siteUrlString == null) {
+    logger.warn("Verification referer does not work because 'Site URL' is NOT set. All of attachments in share link page is invisible.");
+    return null;
+  }
+
+  try {
+    return new URL(siteUrlString);
+  }
+  catch (err) {
+    logger.error(`Parsing 'app:siteUrl' ('${siteUrlString}') has failed.`);
+    return null;
+  }
+};

+ 131 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.spec.ts

@@ -0,0 +1,131 @@
+import { objectIdUtils } from '@growi/core/dist/utils';
+
+import { validateReferer } from './validate-referer';
+
+const mocks = vi.hoisted(() => {
+  return {
+    retrieveSiteUrlMock: vi.fn(),
+  };
+});
+
+vi.mock('./retrieve-site-url', () => ({ retrieveSiteUrl: mocks.retrieveSiteUrlMock }));
+
+
+describe('validateReferer', () => {
+
+  const isValidObjectIdSpy = vi.spyOn(objectIdUtils, 'isValidObjectId');
+
+  beforeEach(() => {
+    isValidObjectIdSpy.mockClear();
+  });
+
+  describe('refurns false', () => {
+
+    it('when the referer argument is undefined', () => {
+      // setup
+
+      // when
+      const result = validateReferer(undefined);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).not.toHaveBeenCalled();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the referer is invalid', () => {
+      // when
+      const result = validateReferer('invalid URL');
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).not.toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the siteUrl returns null', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return null;
+      });
+
+      // when
+      const refererString = 'https://example.org/share/xxxxx';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the hostname of the referer does not match with siteUrl', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return new URL('https://example.com');
+      });
+
+      // when
+      const refererString = 'https://example.org/share/xxxxx';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the port of the referer does not match with siteUrl', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return new URL('https://example.com');
+      });
+
+      // when
+      const refererString = 'https://example.com:8080/share/xxxxx';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).not.toHaveBeenCalled();
+    });
+
+    it('when the shareLinkId is invalid', () => {
+      // setup
+      mocks.retrieveSiteUrlMock.mockImplementation(() => {
+        return new URL('https://example.com');
+      });
+
+      // when
+      const refererString = 'https://example.com/share/FFFFFFFFFFFFFFFFFFFFFFFF';
+      const result = validateReferer(refererString);
+
+      // then
+      expect(result).toBeFalsy();
+      expect(mocks.retrieveSiteUrlMock).toHaveBeenCalledOnce();
+      expect(isValidObjectIdSpy).toHaveBeenCalledOnce();
+    });
+
+  });
+
+  it('returns ValidReferer instance', () => {
+    // setup
+    mocks.retrieveSiteUrlMock.mockImplementation(() => {
+      return new URL('https://example.com');
+    });
+
+
+    // when
+    const shareLinkId = '65436ba09ae6983bd608b89c';
+    const refererString = `https://example.com/share/${shareLinkId}`;
+    const result = validateReferer(refererString);
+
+    // then
+    expect(result).toStrictEqual({
+      referer: refererString,
+      shareLinkId,
+    });
+  });
+
+});

+ 69 - 0
apps/app/src/server/middlewares/certify-shared-page-attachment/validate-referer/validate-referer.ts

@@ -0,0 +1,69 @@
+import { objectIdUtils } from '@growi/core/dist/utils';
+
+import loggerFactory from '~/utils/logger';
+
+import { ValidReferer } from '../interfaces';
+
+import { retrieveSiteUrl } from './retrieve-site-url';
+
+
+const logger = loggerFactory('growi:middlewares:certify-shared-file:validate-referer');
+
+
+export const validateReferer = (referer: string | undefined): ValidReferer | false => {
+  // not null
+  if (referer == null) {
+    logger.info('The referer string is undefined');
+    return false;
+  }
+
+  let refererUrl: URL;
+  try {
+    refererUrl = new URL(referer);
+  }
+  catch (err) {
+    logger.info(`Parsing referer ('${referer}') has failed`);
+    return false;
+  }
+
+  // siteUrl
+  const siteUrl = retrieveSiteUrl();
+  if (siteUrl == null) {
+    logger.info('The siteUrl is null.');
+    return false;
+  }
+
+  // validate hostname and port
+  if (refererUrl.hostname !== siteUrl.hostname || refererUrl.port !== siteUrl.port) {
+    logger.warn('The hostname or port mismatched.', {
+      refererUrl: {
+        hostname: refererUrl.hostname,
+        port: refererUrl.port,
+      },
+      siteUrl: {
+        hostname: siteUrl.hostname,
+        port: siteUrl.port,
+      },
+    });
+    return false;
+  }
+
+  // validate pathname
+  // https://regex101.com/r/M5Bp6E/1
+  const match = refererUrl.pathname.match(/^\/share\/(?<shareLinkId>[a-f0-9]{24})$/i);
+  if (match == null || match.groups?.shareLinkId == null) {
+    logger.warn(`The pathname ('${refererUrl.pathname}') is invalid.`, match);
+    return false;
+  }
+
+  // validate shareLinkId is an correct ObjectId
+  if (!objectIdUtils.isValidObjectId(match.groups.shareLinkId)) {
+    logger.warn(`The shareLinkId ('${match.groups.shareLinkId}') is invalid as an ObjectId.`);
+    return false;
+  }
+
+  return {
+    referer,
+    shareLinkId: match.groups.shareLinkId,
+  };
+};

+ 1 - 1
apps/app/src/server/middlewares/certify-shared-page.js

@@ -1,3 +1,4 @@
+import ShareLink from '~/server/models/share-link';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middleware:certify-shared-page');
@@ -11,7 +12,6 @@ module.exports = (crowi) => {
       return next();
     }
 
-    const ShareLink = crowi.model('ShareLink');
     const sharelink = await ShareLink.findOne({ _id: shareLinkId, relatedPage: pageId });
 
     // check sharelink enabled

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

@@ -14,6 +14,5 @@ module.exports = {
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
-  ShareLink: require('./share-link'),
   SlackAppIntegration: require('./slack-app-integration'),
 };

+ 0 - 39
apps/app/src/server/models/share-link.js

@@ -1,39 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-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 },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
-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;
-};

+ 46 - 0
apps/app/src/server/models/share-link.ts

@@ -0,0 +1,46 @@
+import mongoose, { Schema } from 'mongoose';
+import type {
+  Document, Model,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import type { IShareLink } from '~/interfaces/share-link';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+
+export interface ShareLinkDocument extends IShareLink, Document {
+  isExpired: () => boolean,
+}
+
+export type ShareLinkModel = Model<ShareLinkDocument>;
+
+
+/*
+ * define schema
+ */
+const ObjectId = mongoose.Schema.Types.ObjectId;
+const schema = new Schema<ShareLinkDocument, ShareLinkModel>({
+  relatedPage: {
+    type: ObjectId,
+    ref: 'Page',
+    required: true,
+    index: true,
+  },
+  expiredAt: { type: Date },
+  description: { type: String },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+schema.methods.isExpired = function() {
+  if (this.expiredAt == null) {
+    return false;
+  }
+  return this.expiredAt.getTime() < new Date().getTime();
+};
+
+export default getOrCreateModel<ShareLinkDocument, ShareLinkModel>('ShareLink', schema);

+ 46 - 44
apps/app/src/server/routes/apiv3/attachment.js

@@ -3,12 +3,13 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
 
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 const express = require('express');
 
 const router = express.Router();
-const { query } = require('express-validator');
+const { query, param } = require('express-validator');
 
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
@@ -26,8 +27,8 @@ module.exports = (crowi) => {
   const Attachment = crowi.model('Attachment');
 
   const validator = {
-    attachment: [
-      query('attachmentId').isMongoId().withMessage('attachmentId is required'),
+    retrieveAttachment: [
+      param('id').isMongoId().withMessage('attachment id is required'),
     ],
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
@@ -36,47 +37,6 @@ module.exports = (crowi) => {
     ],
   };
 
-  /**
-   * @swagger
-   *
-   *    /attachment:
-   *      get:
-   *        tags: [Attachment]
-   *        description: Get attachment
-   *        responses:
-   *          200:
-   *            description: Return attachment
-   *        parameters:
-   *          - name: attachemnt_id
-   *            in: query
-   *            required: true
-   *            description: attachment id
-   *            schema:
-   *              type: string
-   */
-  router.get('/', accessTokenParser, loginRequired, validator.attachment, apiV3FormValidator, async(req, res) => {
-    try {
-      const attachmentId = req.query.attachmentId;
-
-      const attachment = await Attachment.findById(attachmentId).populate('creator').exec();
-
-      if (attachment == null) {
-        const message = 'Attachment not found';
-        return res.apiv3Err(message, 404);
-      }
-
-      if (attachment.creator != null && attachment.creator instanceof User) {
-        attachment.creator = serializeUserSecurely(attachment.creator);
-      }
-
-      return res.apiv3({ attachment });
-    }
-    catch (err) {
-      logger.error('Attachment retrieval failed', err);
-      return res.apiv3Err(err, 500);
-    }
-  });
-
   /**
    * @swagger
    *
@@ -135,5 +95,47 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *    /attachment/{id}:
+   *      get:
+   *        tags: [Attachment]
+   *        description: Get attachment
+   *        responses:
+   *          200:
+   *            description: Return attachment
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: attachment id
+   *            schema:
+   *              type: string
+   */
+  router.get('/:id', accessTokenParser, certifySharedPageAttachmentMiddleware, loginRequired, validator.retrieveAttachment, apiV3FormValidator,
+    async(req, res) => {
+      try {
+        const attachmentId = req.params.id;
+
+        const attachment = await Attachment.findById(attachmentId).populate('creator').exec();
+
+        if (attachment == null) {
+          const message = 'Attachment not found';
+          return res.apiv3Err(message, 404);
+        }
+
+        if (attachment.creator != null && attachment.creator instanceof User) {
+          attachment.creator = serializeUserSecurely(attachment.creator);
+        }
+
+        return res.apiv3({ attachment });
+      }
+      catch (err) {
+        logger.error('Attachment retrieval failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
   return router;
 };

+ 1 - 2
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -5,6 +5,7 @@ import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import ShareLink from '~/server/models/share-link';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
@@ -731,7 +732,6 @@ module.exports = (crowi) => {
    *                      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 = {};
@@ -769,7 +769,6 @@ module.exports = (crowi) => {
    */
 
   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;

+ 2 - 5
apps/app/src/server/routes/apiv3/share-links.js

@@ -1,17 +1,17 @@
 // TODO remove this setting after implemented all
 /* eslint-disable no-unused-vars */
 import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import ShareLink from '~/server/models/share-link';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 
-const express = require('express');
-
 const router = express.Router();
 
 const { body, query, param } = require('express-validator');
@@ -31,7 +31,6 @@ module.exports = (crowi) => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
-  const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
 
   const activityEvent = crowi.event('activity');
@@ -145,8 +144,6 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
     }
 
-    const ShareLink = crowi.model('ShareLink');
-
     try {
       const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
 

+ 3 - 3
apps/app/src/server/routes/index.js

@@ -6,6 +6,7 @@ import { middlewareFactory as rateLimiterFactory } from '~/features/rate-limiter
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import { generateCertifyBrandLogoMiddleware } from '../middlewares/certify-brand-logo';
+import { certifySharedPageAttachmentMiddleware } from '../middlewares/certify-shared-page-attachment';
 import { excludeReadOnlyUser } from '../middlewares/exclude-read-only-user';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
@@ -33,7 +34,6 @@ 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 certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const certifyBrandLogo = generateCertifyBrandLogoMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
@@ -156,10 +156,10 @@ module.exports = function(crowi, app) {
 
   app.get('/me'                                   , loginRequiredStrictly, next.delegateToNext);
   app.get('/me/*'                                 , loginRequiredStrictly, next.delegateToNext);
-  app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
+  app.get('/attachment/:id([0-9a-z]{24})'         , certifySharedPageAttachmentMiddleware , 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);
+  app.get('/download/:id([0-9a-z]{24})'         , certifySharedPageAttachmentMiddleware, loginRequired, attachment.api.download);
 
   app.get('/_search'                            , loginRequired, next.delegateToNext);
 

+ 1 - 1
apps/app/src/server/service/page.ts

@@ -36,6 +36,7 @@ import { IOptionsForCreate, IOptionsForUpdate } from '../models/interfaces/page-
 import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
+import ShareLink from '../models/share-link';
 import Subscription from '../models/subscription';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
 
@@ -1690,7 +1691,6 @@ class PageService {
     const Comment = this.crowi.model('Comment');
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
-    const ShareLink = this.crowi.model('ShareLink');
     const Revision = this.crowi.model('Revision');
     const Attachment = this.crowi.model('Attachment');
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;

+ 2 - 2
apps/app/src/server/util/mongoose-utils.ts

@@ -18,9 +18,9 @@ export const getMongoUri = (): string => {
     || ((env.NODE_ENV === 'test') ? 'mongodb://mongo/growi_test' : 'mongodb://mongo/growi');
 };
 
-export const getModelSafely = <T>(modelName: string): Model<T & Document> | null => {
+export const getModelSafely = <Interface, Method = Interface>(modelName: string): Method & Model<Interface & Document> | null => {
   if (mongoose.modelNames().includes(modelName)) {
-    return mongoose.model<T & Document>(modelName);
+    return mongoose.model<Interface & Document, Method & Model<Interface & Document>>(modelName);
   }
   return null;
 };

+ 1 - 0
apps/app/src/services/renderer/remark-plugins/attachment.ts

@@ -24,6 +24,7 @@ const rewriteNode = (node: Node) => {
   };
 };
 
+
 export const remarkPlugin: Plugin = () => {
   return (tree) => {
     visit(tree, (node) => {

+ 4 - 5
apps/app/src/stores/attachment.tsx

@@ -25,10 +25,9 @@ type IDataAttachmentList = {
 
 export const useSWRxAttachment = (attachmentId: string): SWRResponseWithUtils<Util, IAttachmentHasId, Error> => {
   const swrResponse = useSWR(
-    ['/attachment', attachmentId],
-    useCallback(async([endpoint, attachmentId]) => {
-      const params = { attachmentId };
-      const res = await apiv3Get(endpoint, params);
+    [`/attachment/${attachmentId}`],
+    useCallback(async([endpoint]) => {
+      const res = await apiv3Get(endpoint);
       return res.data.attachment;
     }, []),
   );
@@ -75,7 +74,7 @@ export const useSWRxAttachments = (pageId?: Nullable<string>, pageNumber?: numbe
       await apiPost('/attachments.remove', body);
       mutate();
       // Mutation for rich attachment rendering
-      mutateUseSWRxAttachment(['/attachment', body.attachment_id], body.attachment_id);
+      mutateUseSWRxAttachment([`/attachment/${body.attachment_id}`], body.attachment_id);
     }
     catch (err) {
       throw err;

+ 2 - 0
apps/app/src/styles/_mixins.scss

@@ -1,5 +1,7 @@
 @use './bootstrap/init' as bs;
 
+@import './mixins/share-link';
+
 @mixin variable-font-size($basesize) {
   font-size: $basesize * 0.6;
 

+ 7 - 0
apps/app/src/styles/_share-link.scss

@@ -0,0 +1,7 @@
+@use './mixins';
+
+@include mixins.share-link() {
+  .d-share-link-none {
+    display: none !important;
+  }
+}

+ 0 - 12
apps/app/src/styles/_sharelink.scss

@@ -1,12 +0,0 @@
-.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;
-  }
-}

+ 20 - 0
apps/app/src/styles/mixins/_share-link.scss

@@ -0,0 +1,20 @@
+@mixin share-link() {
+  .layout-root.share-link {
+    @content;
+  }
+}
+
+@mixin share-link-for-module($isContentGlobal: false) {
+  :global {
+    .layout-root.share-link {
+      @if ($isContentGlobal) {
+        @content;
+      }
+      @else {
+        :local {
+          @content;
+        }
+      }
+    }
+  }
+}

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

@@ -21,6 +21,7 @@
 @import 'mirror_mode';
 @import 'modal';
 @import 'page-path';
+@import 'share-link';
 @import 'tag';
 @import 'installer';
 

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.2.2-slackbot-proxy.0",
+  "version": "6.2.3-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/hackmd",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "type": "module",

+ 1 - 1
packages/presentation/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/presentation",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "description": "GROWI plugin for presentation",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/preset-templates/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/preset-templates",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "scripts": {
     "test": "vitest run",
     "version": "yarn version --no-git-tag-version --preid=RC"

+ 1 - 1
packages/preset-themes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@growi/preset-themes",
   "description": "GROWI preset themes",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "license": "MIT",
   "main": "dist/libs/preset-themes.umd.js",
   "module": "dist/libs/preset-themes.mjs",

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-attachment-refs",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-drawio/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-drawio",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-growi-directive/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-directive",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-lsx",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "license": "MIT",
   "type": "module",
   "main": "dist/index.cjs",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "6.2.2",
+  "version": "6.2.3-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [

+ 11 - 11
yarn.lock

@@ -2295,13 +2295,13 @@
     xdg-basedir "^4.0.0"
 
 "@growi/core@link:packages/core":
-  version "6.2.2"
+  version "6.2.3-RC.0"
   dependencies:
     bson-objectid "^2.0.4"
     escape-string-regexp "^4.0.0"
 
 "@growi/hackmd@link:packages/hackmd":
-  version "6.2.2"
+  version "6.2.3-RC.0"
 
 "@growi/pluginkit@link:packages/pluginkit":
   version "0.1.0"
@@ -2310,18 +2310,18 @@
     extensible-custom-error "^0.0.7"
 
 "@growi/presentation@link:packages/presentation":
-  version "6.2.2"
+  version "6.2.3-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
 
 "@growi/preset-templates@link:packages/preset-templates":
-  version "6.2.2"
+  version "6.2.3-RC.0"
 
 "@growi/preset-themes@link:packages/preset-themes":
-  version "6.2.2"
+  version "6.2.3-RC.0"
 
 "@growi/remark-attachment-refs@link:packages/remark-attachment-refs":
-  version "6.2.2"
+  version "6.2.3-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
@@ -2333,10 +2333,10 @@
     universal-bunyan "^0.9.2"
 
 "@growi/remark-drawio@link:packages/remark-drawio":
-  version "6.2.2"
+  version "6.2.3-RC.0"
 
 "@growi/remark-growi-directive@link:packages/remark-growi-directive":
-  version "6.2.2"
+  version "6.2.3-RC.0"
   dependencies:
     "@types/mdast" "^3.0.0"
     "@types/unist" "^2.0.0"
@@ -2353,7 +2353,7 @@
     uvu "^0.5.0"
 
 "@growi/remark-lsx@link:packages/remark-lsx":
-  version "6.2.2"
+  version "6.2.3-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
@@ -2365,7 +2365,7 @@
     swr "^2.0.3"
 
 "@growi/slack@link:packages/slack":
-  version "6.2.2"
+  version "6.2.3-RC.0"
   dependencies:
     "@slack/oauth" "^2.0.1"
     "@slack/web-api" "^6.2.4"
@@ -2381,7 +2381,7 @@
     url-join "^4.0.0"
 
 "@growi/ui@link:packages/ui":
-  version "6.2.2"
+  version "6.2.3-RC.0"
   dependencies:
     "@growi/core" "link:packages/core"