yuto-oweseek 4 лет назад
Родитель
Сommit
bb53f1c1b4

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

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

@@ -15,6 +15,7 @@ ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
 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

+ 3 - 0
packages/app/src/server/routes/index.js

@@ -46,6 +46,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');
 
@@ -205,6 +206,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('/*/$'                   , loginRequired, injectUserUISettings, page.showPageWithEndOfSlash, page.notFound);
   app.get('/*'                     , loginRequired, autoReconnectToSearch, injectUserUISettings, page.showPage, page.notFound);
 

+ 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

@@ -571,6 +571,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,
+  },
 };
 
 

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

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

@@ -1,5 +1,13 @@
 {% extends 'base/layout.html' %}
 
+{% block html_additional_headers %}
+  <!-- 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 %}