Răsfoiți Sursa

Merge pull request #8243 from weseek/support/migrate-attachment-codes-to-ts

support: TypeScriptize attachment codes
Yuki Takei 2 ani în urmă
părinte
comite
c8b93f1282
55 a modificat fișierele cu 1917 adăugiri și 2397 ștergeri
  1. 4 3
      apps/app/package.json
  2. 8 4
      apps/app/src/interfaces/crowi-request.ts
  3. 2 3
      apps/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js
  4. 1 1
      apps/app/src/pages/[[...path]].page.tsx
  5. 1 1
      apps/app/src/pages/_document.page.tsx
  6. 1 1
      apps/app/src/pages/_private-legacy-pages.page.tsx
  7. 1 1
      apps/app/src/pages/_search.page.tsx
  8. 1 1
      apps/app/src/pages/invited.page.tsx
  9. 1 1
      apps/app/src/pages/maintenance.page.tsx
  10. 2 3
      apps/app/src/pages/me/[[...path]].page.tsx
  11. 1 1
      apps/app/src/pages/share/[[...path]].page.tsx
  12. 1 1
      apps/app/src/pages/tags.page.tsx
  13. 1 1
      apps/app/src/pages/trash.page.tsx
  14. 1 1
      apps/app/src/pages/utils/commons.ts
  15. 84 84
      apps/app/src/server/crowi/index.js
  16. 24 0
      apps/app/src/server/interfaces/attachment.ts
  17. 0 103
      apps/app/src/server/models/attachment.js
  18. 115 0
      apps/app/src/server/models/attachment.ts
  19. 12 3
      apps/app/src/server/models/index.js
  20. 1 5
      apps/app/src/server/models/serializers/bookmark-serializer.js
  21. 5 0
      apps/app/src/server/models/serializers/index.ts
  22. 1 5
      apps/app/src/server/models/serializers/page-serializer.js
  23. 1 5
      apps/app/src/server/models/serializers/revision-serializer.js
  24. 1 5
      apps/app/src/server/models/serializers/user-group-relation-serializer.js
  25. 2 7
      apps/app/src/server/models/serializers/user-serializer.js
  26. 2 1
      apps/app/src/server/models/user.js
  27. 1 1
      apps/app/src/server/routes/apiv3/attachment.js
  28. 1 1
      apps/app/src/server/routes/apiv3/customize-setting.js
  29. 9 143
      apps/app/src/server/routes/attachment/api.js
  30. 57 0
      apps/app/src/server/routes/attachment/download.ts
  31. 40 0
      apps/app/src/server/routes/attachment/get-brand-logo.ts
  32. 187 0
      apps/app/src/server/routes/attachment/get.ts
  33. 3 0
      apps/app/src/server/routes/attachment/index.ts
  34. 13 13
      apps/app/src/server/routes/index.js
  35. 1 1
      apps/app/src/server/routes/ogp.ts
  36. 1 5
      apps/app/src/server/service/attachment.js
  37. 136 106
      apps/app/src/server/service/file-uploader/aws.ts
  38. 126 95
      apps/app/src/server/service/file-uploader/azure.ts
  39. 30 8
      apps/app/src/server/service/file-uploader/file-uploader.ts
  40. 0 215
      apps/app/src/server/service/file-uploader/gcs.js
  41. 260 0
      apps/app/src/server/service/file-uploader/gcs.ts
  42. 29 14
      apps/app/src/server/service/file-uploader/gridfs.ts
  43. 0 39
      apps/app/src/server/service/file-uploader/index.js
  44. 35 0
      apps/app/src/server/service/file-uploader/index.ts
  45. 102 29
      apps/app/src/server/service/file-uploader/local.ts
  46. 0 35
      apps/app/src/server/service/file-uploader/none.js
  47. 70 0
      apps/app/src/server/service/file-uploader/utils/headers.ts
  48. 1 0
      apps/app/src/server/service/file-uploader/utils/index.ts
  49. 7 4
      apps/app/src/server/service/g2g-transfer.ts
  50. 1 1
      apps/app/src/server/service/page.ts
  51. 1 1
      apps/app/src/utils/admin-page-util.ts
  52. 8 3
      packages/core/src/interfaces/attachment.ts
  53. 1 0
      packages/remark-attachment-refs/package.json
  54. 7 4
      packages/remark-attachment-refs/src/server/routes/refs.ts
  55. 516 1438
      yarn.lock

+ 4 - 3
apps/app/package.json

@@ -51,15 +51,16 @@
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "// comments for dependencies": {
+    "@aws-skd/*": "fix version above 3.186.0 that is required by mongodb@4.16.0",
+    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "string-width": "5.0.0 or above exports only ESM.",
-    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
     "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
   },
   "dependencies": {
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
-    "@aws-sdk/client-s3": "^3.58.0",
-    "@aws-sdk/s3-request-presigner": "^3.58.0",
+    "@aws-sdk/client-s3": "3.454.0",
+    "@aws-sdk/s3-request-presigner": "3.454.0",
     "@azure/identity": "^3.3.2",
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",

+ 8 - 4
apps/app/src/interfaces/crowi-request.ts

@@ -1,9 +1,11 @@
-import type { IUser, IUserHasId } from '@growi/core';
-import { Request } from 'express';
+import type { IUser } from '@growi/core';
+import type { Request } from 'express';
+import type { Document } from 'mongoose';
 
-export interface CrowiRequest<U extends IUser = IUserHasId> extends Request {
 
-  user?: U,
+export interface CrowiProperties {
+
+  user?: IUser & Document,
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   crowi: any,
@@ -14,3 +16,5 @@ export interface CrowiRequest<U extends IUser = IUserHasId> extends Request {
   csrfToken: () => string,
 
 }
+
+export interface CrowiRequest extends CrowiProperties, Request {}

+ 2 - 3
apps/app/src/migrations/20220613064207-add-attachment-type-to-existing-attachments.js

@@ -1,8 +1,8 @@
 import mongoose from 'mongoose';
 
 import { AttachmentType } from '~/server/interfaces/attachment';
-import attachmentModel from '~/server/models/attachment';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { Attachment } from '~/server/models';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:add-attachment-type-to-existing-attachments');
@@ -11,7 +11,6 @@ module.exports = {
   async up(db) {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Attachment = getModelSafely('Attachment') || attachmentModel();
 
     // Add attachmentType for wiki page
     // Filter pages where "attachmentType" doesn't exist and "page" is not null

+ 1 - 1
apps/app/src/pages/[[...path]].page.tsx

@@ -626,7 +626,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
 
   const result = await getServerSideCommonProps(context);

+ 1 - 1
apps/app/src/pages/_document.page.tsx

@@ -48,7 +48,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
-    const { crowi } = ctx.req as CrowiRequest<any>;
+    const { crowi } = ctx.req as CrowiRequest;
     const { customizeService } = crowi;
 
     const { themeHref } = customizeService;

+ 1 - 1
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -124,7 +124,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
 
   const result = await getServerSideCommonProps(context);

+ 1 - 1
apps/app/src/pages/_search.page.tsx

@@ -152,7 +152,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
 
   const result = await getServerSideCommonProps(context);

+ 1 - 1
apps/app/src/pages/invited.page.tsx

@@ -71,7 +71,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   const result = await getServerSideCommonProps(context);
 

+ 1 - 1
apps/app/src/pages/maintenance.page.tsx

@@ -79,7 +79,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
 
   const result = await getServerSideCommonProps(context);
 

+ 2 - 3
apps/app/src/pages/me/[[...path]].page.tsx

@@ -1,6 +1,5 @@
 import React, { useMemo } from 'react';
 
-import type { IUserHasId } from '@growi/core';
 import {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -194,7 +193,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user, crowi } = req;
 
   const result = await getServerSideCommonProps(context);
@@ -209,7 +208,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   if (user != null) {
     const User = crowi.model('User');
-    const userData = await User.findById(req.user.id).populate({ path: 'imageAttachment', select: 'filePathProxied' });
+    const userData = await User.findById(user.id).populate({ path: 'imageAttachment', select: 'filePathProxied' });
     props.currentUser = userData.toObject();
   }
 

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

@@ -224,7 +224,7 @@ async function addActivity(context: GetServerSidePropsContext, action: Supported
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { crowi, params } = req;
   const result = await getServerSideCommonProps(context);
 

+ 1 - 1
apps/app/src/pages/tags.page.tsx

@@ -152,7 +152,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   const result = await getServerSideCommonProps(context);
 

+ 1 - 1
apps/app/src/pages/trash.page.tsx

@@ -141,7 +141,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 }
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
   const result = await getServerSideCommonProps(context);
 

+ 1 - 1
apps/app/src/pages/utils/commons.ts

@@ -42,7 +42,7 @@ export type CommonProps = {
 export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(context: GetServerSidePropsContext) => {
   const getModelSafely = await import('~/server/util/mongoose-utils').then(mod => mod.getModelSafely);
 
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { crowi, user } = req;
   const {
     appService, configManager, customizeService, attachmentService,

+ 84 - 84
apps/app/src/server/crowi/index.js

@@ -20,17 +20,13 @@ import loggerFactory from '~/utils/logger';
 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 UserGroupRelation from '../models/user-group-relation';
+import { modelsDependsOnCrowi } from '../models';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { instanciate as instanciateExternalAccountService } from '../service/external-account';
+import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
@@ -45,72 +41,81 @@ import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
-const models = require('../models');
 
 const sep = path.sep;
 
-function Crowi() {
-  this.version = pkg.version;
-  this.runtimeVersions = undefined; // initialized by scanRuntimeVersions()
-
-  this.publicDir = path.join(projectRoot, 'public') + sep;
-  this.resourceDir = path.join(projectRoot, 'resource') + sep;
-  this.localeDir = path.join(this.resourceDir, 'locales') + sep;
-  this.viewsDir = path.resolve(__dirname, '../views') + sep;
-  this.tmpDir = path.join(projectRoot, 'tmp') + sep;
-  this.cacheDir = path.join(this.tmpDir, 'cache');
-
-  this.express = null;
-
-  this.config = {};
-  this.configManager = null;
-  this.s2sMessagingService = null;
-  this.g2gTransferPusherService = null;
-  this.g2gTransferReceiverService = null;
-  this.mailService = null;
-  this.passportService = null;
-  this.globalNotificationService = null;
-  this.userNotificationService = null;
-  this.xssService = null;
-  this.aclService = null;
-  this.appService = null;
-  this.fileUploadService = null;
-  this.restQiitaAPIService = null;
-  this.growiBridgeService = null;
-  this.exportService = null;
-  this.importService = null;
-  this.pluginService = null;
-  this.searchService = null;
-  this.socketIoService = null;
-  this.pageService = null;
-  this.syncPageStatusService = null;
-  this.cdnResourcesService = new CdnResourcesService();
-  this.slackIntegrationService = null;
-  this.inAppNotificationService = null;
-  this.activityService = null;
-  this.commentService = null;
-  this.xss = new Xss();
-  this.questionnaireService = null;
-  this.questionnaireCronService = null;
-
-  this.tokens = null;
-
-  this.models = {};
-
-  this.env = process.env;
-  this.node_env = this.env.NODE_ENV || 'development';
-
-  this.port = this.env.PORT || 3000;
-
-  this.events = {
-    user: new UserEvent(this),
-    page: new (require('../events/page'))(this),
-    activity: new (require('../events/activity'))(this),
-    bookmark: new (require('../events/bookmark'))(this),
-    comment: new (require('../events/comment'))(this),
-    tag: new (require('../events/tag'))(this),
-    admin: new (require('../events/admin'))(this),
-  };
+class Crowi {
+
+  /** @type {AppService} */
+  appService;
+
+  /** @type {FileUploader} */
+  fileUploadService;
+
+  constructor() {
+    this.version = pkg.version;
+    this.runtimeVersions = undefined; // initialized by scanRuntimeVersions()
+
+    this.publicDir = path.join(projectRoot, 'public') + sep;
+    this.resourceDir = path.join(projectRoot, 'resource') + sep;
+    this.localeDir = path.join(this.resourceDir, 'locales') + sep;
+    this.viewsDir = path.resolve(__dirname, '../views') + sep;
+    this.tmpDir = path.join(projectRoot, 'tmp') + sep;
+    this.cacheDir = path.join(this.tmpDir, 'cache');
+
+    this.express = null;
+
+    this.config = {};
+    this.configManager = null;
+    this.s2sMessagingService = null;
+    this.g2gTransferPusherService = null;
+    this.g2gTransferReceiverService = null;
+    this.mailService = null;
+    this.passportService = null;
+    this.globalNotificationService = null;
+    this.userNotificationService = null;
+    this.xssService = null;
+    this.aclService = null;
+    this.appService = null;
+    this.fileUploadService = null;
+    this.restQiitaAPIService = null;
+    this.growiBridgeService = null;
+    this.exportService = null;
+    this.importService = null;
+    this.pluginService = null;
+    this.searchService = null;
+    this.socketIoService = null;
+    this.pageService = null;
+    this.syncPageStatusService = null;
+    this.cdnResourcesService = new CdnResourcesService();
+    this.slackIntegrationService = null;
+    this.inAppNotificationService = null;
+    this.activityService = null;
+    this.commentService = null;
+    this.xss = new Xss();
+    this.questionnaireService = null;
+    this.questionnaireCronService = null;
+
+    this.tokens = null;
+
+    this.models = {};
+
+    this.env = process.env;
+    this.node_env = this.env.NODE_ENV || 'development';
+
+    this.port = this.env.PORT || 3000;
+
+    this.events = {
+      user: new UserEvent(this),
+      page: new (require('../events/page'))(this),
+      activity: new (require('../events/activity'))(this),
+      bookmark: new (require('../events/bookmark'))(this),
+      comment: new (require('../events/comment'))(this),
+      tag: new (require('../events/tag'))(this),
+      admin: new (require('../events/admin'))(this),
+    };
+  }
+
 }
 
 Crowi.prototype.init = async function() {
@@ -303,22 +308,17 @@ Crowi.prototype.setupSocketIoService = async function() {
 };
 
 Crowi.prototype.setupModels = async function() {
-  Object.keys(models).forEach((key) => {
-    return this.model(key, models[key](this));
-  });
+  Object.keys(modelsDependsOnCrowi).forEach((key) => {
+    const factory = modelsDependsOnCrowi[key];
 
-  // include models that are independent from crowi
-  const crowiIndependent = {};
-  crowiIndependent.Activity = Activity;
-  crowiIndependent.Tag = Tag;
-  crowiIndependent.UserGroup = UserGroup;
-  crowiIndependent.UserGroupRelation = UserGroupRelation;
-  crowiIndependent.PageRedirect = PageRedirect;
-  crowiIndependent.ShareLink = ShareLink;
-
-  Object.keys(crowiIndependent).forEach((key) => {
-    return this.model(key, crowiIndependent[key]);
+    if (!(factory instanceof Function)) {
+      logger.warn(`modelsDependsOnCrowi['${key}'] is not a function. skipped.`);
+      return;
+    }
+
+    return this.model(key, modelsDependsOnCrowi[key](this));
   });
+
 };
 
 Crowi.prototype.setupCron = function() {
@@ -641,7 +641,7 @@ Crowi.prototype.setUpApp = async function() {
  */
 Crowi.prototype.setUpFileUpload = async function(isForceUpdate = false) {
   if (this.fileUploadService == null || isForceUpdate) {
-    this.fileUploadService = require('../service/file-uploader')(this);
+    this.fileUploadService = getUploader(this);
   }
 };
 

+ 24 - 0
apps/app/src/server/interfaces/attachment.ts

@@ -5,3 +5,27 @@ export const AttachmentType = {
 } as const;
 
 export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType];
+
+
+export type ExpressHttpHeader<Field = string> = {
+  field: Field,
+  value: string | string[]
+};
+
+export type IContentHeaders = {
+  contentType?: ExpressHttpHeader<'Content-Type'>;
+  contentLength?: ExpressHttpHeader<'Content-Length'>;
+  contentSecurityPolicy?: ExpressHttpHeader<'Content-Security-Policy'>;
+  contentDisposition?: ExpressHttpHeader<'Content-Disposition'>;
+}
+
+export type RespondOptions = {
+  download?: boolean,
+}
+
+export const ResponseMode = {
+  RELAY: 'relay',
+  REDIRECT: 'redirect',
+  DELEGATE: 'delegate',
+} as const;
+export type ResponseMode = typeof ResponseMode[keyof typeof ResponseMode];

+ 0 - 103
apps/app/src/server/models/attachment.js

@@ -1,103 +0,0 @@
-import path from 'path';
-
-import loggerFactory from '~/utils/logger';
-
-import { AttachmentType } from '../interfaces/attachment';
-
-
-const { addSeconds } = require('date-fns');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:models:attachment');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-module.exports = function(crowi) {
-  function generateFileHash(fileName) {
-    const hash = require('crypto').createHash('md5');
-    hash.update(`${fileName}_${Date.now()}`);
-
-    return hash.digest('hex');
-  }
-
-  const attachmentSchema = new mongoose.Schema({
-    page: { type: ObjectId, ref: 'Page', index: true },
-    creator: { type: ObjectId, ref: 'User', index: true },
-    filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
-    fileName: { type: String, required: true, unique: true },
-    originalName: { type: String },
-    fileFormat: { type: String, required: true },
-    fileSize: { type: Number, default: 0 },
-    temporaryUrlCached: { type: String },
-    temporaryUrlExpiredAt: { type: Date },
-    attachmentType: {
-      type: String,
-      enum: AttachmentType,
-      required: true,
-    },
-  }, {
-    timestamps: { createdAt: true, updatedAt: false },
-  });
-  attachmentSchema.plugin(uniqueValidator);
-  attachmentSchema.plugin(mongoosePaginate);
-
-  attachmentSchema.virtual('filePathProxied').get(function() {
-    return `/attachment/${this._id}`;
-  });
-
-  attachmentSchema.virtual('downloadPathProxied').get(function() {
-    return `/download/${this._id}`;
-  });
-
-  attachmentSchema.set('toObject', { virtuals: true });
-  attachmentSchema.set('toJSON', { virtuals: true });
-
-
-  attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize, attachmentType) {
-    // eslint-disable-next-line @typescript-eslint/no-this-alias
-    const Attachment = this;
-
-    const extname = path.extname(originalName);
-    let fileName = generateFileHash(originalName);
-    if (extname.length > 1) { // ignore if empty or '.' only
-      fileName = `${fileName}${extname}`;
-    }
-
-    const attachment = new Attachment();
-    attachment.page = pageId;
-    attachment.creator = user._id;
-    attachment.originalName = originalName;
-    attachment.fileName = fileName;
-    attachment.fileFormat = fileFormat;
-    attachment.fileSize = fileSize;
-    attachment.attachmentType = attachmentType;
-    return attachment;
-  };
-
-
-  attachmentSchema.methods.getValidTemporaryUrl = function() {
-    if (this.temporaryUrlExpiredAt == null) {
-      return null;
-    }
-    // return null when expired url
-    if (this.temporaryUrlExpiredAt.getTime() < new Date().getTime()) {
-      return null;
-    }
-    return this.temporaryUrlCached;
-  };
-
-  attachmentSchema.methods.cashTemporaryUrlByProvideSec = function(temporaryUrl, provideSec) {
-    if (temporaryUrl == null) {
-      throw new Error('url is required.');
-    }
-    this.temporaryUrlCached = temporaryUrl;
-    this.temporaryUrlExpiredAt = addSeconds(new Date(), provideSec);
-
-    return this.save();
-  };
-
-  return mongoose.model('Attachment', attachmentSchema);
-};

+ 115 - 0
apps/app/src/server/models/attachment.ts

@@ -0,0 +1,115 @@
+import path from 'path';
+
+import type { IAttachment } from '@growi/core';
+import { addSeconds } from 'date-fns';
+import {
+  Schema, type Model, type Document, Types,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import loggerFactory from '~/utils/logger';
+
+import { AttachmentType } from '../interfaces/attachment';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:models:attachment');
+
+
+function generateFileHash(fileName) {
+  const hash = require('crypto').createHash('md5');
+  hash.update(`${fileName}_${Date.now()}`);
+
+  return hash.digest('hex');
+}
+
+type GetValidTemporaryUrl = () => string | null | undefined;
+type CashTemporaryUrlByProvideSec = (temporaryUrl: string, lifetimeSec: number) => Promise<IAttachmentDocument>;
+
+export interface IAttachmentDocument extends IAttachment, Document {
+  getValidTemporaryUrl: GetValidTemporaryUrl
+  cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec,
+}
+export interface IAttachmentModel extends Model<IAttachmentDocument> {
+  createWithoutSave
+}
+
+const attachmentSchema = new Schema({
+  page: { type: Types.ObjectId, ref: 'Page', index: true },
+  creator: { type: Types.ObjectId, ref: 'User', index: true },
+  fileName: { type: String, required: true, unique: true },
+  fileFormat: { type: String, required: true },
+  fileSize: { type: Number, default: 0 },
+  originalName: { type: String },
+  temporaryUrlCached: { type: String },
+  temporaryUrlExpiredAt: { type: Date },
+  attachmentType: {
+    type: String,
+    enum: AttachmentType,
+    required: true,
+  },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+attachmentSchema.plugin(uniqueValidator);
+attachmentSchema.plugin(mongoosePaginate);
+
+// virtual
+attachmentSchema.virtual('filePathProxied').get(function() {
+  return `/attachment/${this._id}`;
+});
+
+attachmentSchema.virtual('downloadPathProxied').get(function() {
+  return `/download/${this._id}`;
+});
+
+attachmentSchema.set('toObject', { virtuals: true });
+attachmentSchema.set('toJSON', { virtuals: true });
+
+
+attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize, attachmentType) {
+  // eslint-disable-next-line @typescript-eslint/no-this-alias
+  const Attachment = this;
+
+  const extname = path.extname(originalName);
+  let fileName = generateFileHash(originalName);
+  if (extname.length > 1) { // ignore if empty or '.' only
+    fileName = `${fileName}${extname}`;
+  }
+
+  const attachment = new Attachment();
+  attachment.page = pageId;
+  attachment.creator = user._id;
+  attachment.originalName = originalName;
+  attachment.fileName = fileName;
+  attachment.fileFormat = fileFormat;
+  attachment.fileSize = fileSize;
+  attachment.attachmentType = attachmentType;
+  return attachment;
+};
+
+const getValidTemporaryUrl: GetValidTemporaryUrl = function(this: IAttachmentDocument) {
+  if (this.temporaryUrlExpiredAt == null) {
+    return null;
+  }
+  // return null when expired url
+  if (this.temporaryUrlExpiredAt.getTime() < new Date().getTime()) {
+    return null;
+  }
+  return this.temporaryUrlCached;
+};
+attachmentSchema.methods.getValidTemporaryUrl = getValidTemporaryUrl;
+
+const cashTemporaryUrlByProvideSec: CashTemporaryUrlByProvideSec = function(this: IAttachmentDocument, temporaryUrl, lifetimeSec) {
+  if (temporaryUrl == null) {
+    throw new Error('url is required.');
+  }
+  this.temporaryUrlCached = temporaryUrl;
+  this.temporaryUrlExpiredAt = addSeconds(new Date(), lifetimeSec);
+
+  return this.save();
+};
+attachmentSchema.methods.cashTemporaryUrlByProvideSec = cashTemporaryUrlByProvideSec;
+
+export const Attachment = getOrCreateModel<IAttachmentDocument, IAttachmentModel>('Attachment', attachmentSchema);

+ 12 - 3
apps/app/src/server/models/index.js

@@ -1,15 +1,24 @@
-import Page from '~/server/models/page';
+import Page from './page';
 
-module.exports = {
+export const modelsDependsOnCrowi = {
   Page,
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
   Comment: require('./comment'),
-  Attachment: require('./attachment'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   SlackAppIntegration: require('./slack-app-integration'),
 };
+
+// setup models that independent from crowi
+export * from './attachment';
+export * as Activity from './activity';
+export * as PageRedirect from './page-redirect';
+export * as ShareLink from './share-link';
+export * as Tag from './tag';
+export * as UserGroup from './user-group';
+
+export * from './serializers';

+ 1 - 5
apps/app/src/server/models/serializers/bookmark-serializer.js

@@ -7,7 +7,7 @@ function serializeInsecurePageAttributes(bookmark) {
   return bookmark;
 }
 
-function serializeBookmarkSecurely(bookmark) {
+export function serializeBookmarkSecurely(bookmark) {
   let serialized = bookmark;
 
   // invoke toObject if bookmark is a model instance
@@ -19,7 +19,3 @@ function serializeBookmarkSecurely(bookmark) {
 
   return serialized;
 }
-
-module.exports = {
-  serializeBookmarkSecurely,
-};

+ 5 - 0
apps/app/src/server/models/serializers/index.ts

@@ -0,0 +1,5 @@
+export * from './bookmark-serializer';
+export * from './page-serializer';
+export * from './revision-serializer';
+export * from './user-group-relation-serializer';
+export * from './user-serializer';

+ 1 - 5
apps/app/src/server/models/serializers/page-serializer.js

@@ -20,7 +20,7 @@ function serializeInsecureUserAttributes(page) {
   return page;
 }
 
-function serializePageSecurely(page) {
+export function serializePageSecurely(page) {
   let serialized = page;
 
   // invoke toObject if page is a model instance
@@ -36,7 +36,3 @@ function serializePageSecurely(page) {
 
   return serialized;
 }
-
-module.exports = {
-  serializePageSecurely,
-};

+ 1 - 5
apps/app/src/server/models/serializers/revision-serializer.js

@@ -7,14 +7,10 @@ function serializeInsecureUserAttributes(revision) {
   return revision;
 }
 
-function serializeRevisionSecurely(revision) {
+export function serializeRevisionSecurely(revision) {
   const serialized = revision;
 
   serializeInsecureUserAttributes(serialized);
 
   return serialized;
 }
-
-module.exports = {
-  serializeRevisionSecurely,
-};

+ 1 - 5
apps/app/src/server/models/serializers/user-group-relation-serializer.js

@@ -7,7 +7,7 @@ function serializeInsecureUserAttributes(userGroupRelation) {
   return userGroupRelation;
 }
 
-function serializeUserGroupRelationSecurely(userGroupRelation) {
+export function serializeUserGroupRelationSecurely(userGroupRelation) {
   let serialized = userGroupRelation;
 
   // invoke toObject if page is a model instance
@@ -19,7 +19,3 @@ function serializeUserGroupRelationSecurely(userGroupRelation) {
 
   return serialized;
 }
-
-module.exports = {
-  serializeUserGroupRelationSecurely,
-};

+ 2 - 7
apps/app/src/server/models/serializers/user-serializer.js

@@ -1,7 +1,7 @@
 const mongoose = require('mongoose');
 
 
-function omitInsecureAttributes(user) {
+export function omitInsecureAttributes(user) {
   // omit password
   delete user.password;
   // omit apiToken
@@ -14,7 +14,7 @@ function omitInsecureAttributes(user) {
   return user;
 }
 
-function serializeUserSecurely(user) {
+export function serializeUserSecurely(user) {
   const User = mongoose.model('User');
 
   // return when it is not a user object
@@ -33,8 +33,3 @@ function serializeUserSecurely(user) {
 
   return serialized;
 }
-
-module.exports = {
-  omitInsecureAttributes,
-  serializeUserSecurely,
-};

+ 2 - 1
apps/app/src/server/models/user.js

@@ -6,6 +6,8 @@ import { i18n } from '^/config/next-i18next.config';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 
+import { Attachment } from './attachment';
+
 
 const crypto = require('crypto');
 
@@ -249,7 +251,6 @@ module.exports = function(crowi) {
       return this.image;
     }
     if (this.imageAttachment != null && this.imageAttachment._id != null) {
-      const Attachment = crowi.model('Attachment');
       const imageAttachment = await Attachment.findById(this.imageAttachment);
       return imageAttachment.filePathProxied;
     }

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

@@ -1,5 +1,6 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
+import { Attachment } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -24,7 +25,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const Attachment = crowi.model('Attachment');
 
   const validator = {
     retrieveAttachment: [

+ 1 - 1
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -9,6 +9,7 @@ import multer from 'multer';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
+import { Attachment } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -102,7 +103,6 @@ module.exports = (crowi) => {
   const activityEvent = crowi.event('activity');
 
   const { customizeService, attachmentService } = crowi;
-  const Attachment = crowi.model('Attachment');
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const validator = {
     layout: [

+ 9 - 143
apps/app/src/server/routes/attachment.js → apps/app/src/server/routes/attachment/api.js

@@ -4,14 +4,14 @@ import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
+import { Attachment, serializePageSecurely, serializeRevisionSecurely } from '../../models';
+
 /* eslint-disable no-use-before-define */
 
 
 const logger = loggerFactory('growi:routes:attachment');
 
-const { serializePageSecurely } = require('../models/serializers/page-serializer');
-const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
-const ApiResponse = require('../util/apiResponse');
+const ApiResponse = require('../../util/apiResponse');
 
 /**
  * @swagger
@@ -133,8 +133,7 @@ const ApiResponse = require('../util/apiResponse');
  *            example: "/download/5e0734e072560e001761fa67"
  */
 
-module.exports = function(crowi, app) {
-  const Attachment = crowi.model('Attachment');
+export const routesFactory = (crowi) => {
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
@@ -142,20 +141,6 @@ module.exports = function(crowi, app) {
 
   const activityEvent = crowi.event('activity');
 
-  /**
-   * Check the user is accessible to the related page
-   *
-   * @param {User} user
-   * @param {Attachment} attachment
-   */
-  async function isAccessibleByViewer(user, attachment) {
-    if (attachment.page != null) {
-      // eslint-disable-next-line no-return-await
-      return await Page.isAccessiblePageByViewer(attachment.page, user);
-    }
-    return true;
-  }
-
   /**
    * Check the user is accessible to the related page
    *
@@ -177,138 +162,19 @@ module.exports = function(crowi, app) {
     return await Page.isAccessiblePageByViewer(attachment.page, user);
   }
 
-  /**
-   * Common method to response
-   *
-   * @param {Request} req
-   * @param {Response} res
-   * @param {User} user
-   * @param {Attachment} attachment
-   * @param {boolean} forceDownload
-   */
-  async function responseForAttachment(req, res, attachment, forceDownload) {
-    const { fileUploadService } = crowi;
-
-    if (attachment == null) {
-      return res.json(ApiResponse.error('attachment not found'));
-    }
-
-    const user = req.user;
-    const isAccessible = await isAccessibleByViewer(user, attachment);
-    if (!isAccessible) {
-      return res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`));
-    }
-
-    // add headers before evaluating 'req.fresh'
-    setHeaderToRes(res, attachment, forceDownload);
-
-    // return 304 if request is "fresh"
-    // see: http://expressjs.com/en/5x/api.html#req.fresh
-    if (req.fresh) {
-      return res.sendStatus(304);
-    }
-
-    if (fileUploadService.canRespond()) {
-      return fileUploadService.respond(res, attachment);
-    }
-
-    let fileStream;
-    try {
-      fileStream = await fileUploadService.findDeliveryFile(attachment);
-    }
-    catch (e) {
-      logger.error(e);
-      return res.json(ApiResponse.error(e.message));
-    }
-
-    const parameters = {
-      ip:  req.ip,
-      endpoint: req.originalUrl,
-      action: SupportedAction.ACTION_ATTACHMENT_DOWNLOAD,
-      user: req.user?._id,
-      snapshot: {
-        username: req.user?.username,
-      },
-    };
-    await crowi.activityService.createActivity(parameters);
-
-    return fileStream.pipe(res);
-  }
-
-  /**
-   * set http response header
-   *
-   * @param {Response} res
-   * @param {Attachment} attachment
-   * @param {boolean} forceDownload
-   */
-  function setHeaderToRes(res, attachment, forceDownload) {
-    res.set({
-      ETag: `Attachment-${attachment._id}`,
-      'Last-Modified': attachment.createdAt.toUTCString(),
-    });
-
-    if (attachment.fileSize) {
-      res.set({
-        'Content-Length': attachment.fileSize,
-      });
-    }
-
-    // download
-    if (forceDownload) {
-      res.set({
-        'Content-Disposition': `attachment;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
-      });
-    }
-    // reference
-    else {
-      res.set({
-        'Content-Type': attachment.fileFormat,
-        // eslint-disable-next-line max-len
-        'Content-Security-Policy': "script-src 'unsafe-hashes'; style-src 'self' 'unsafe-inline'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
-        'Content-Disposition': `inline;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
-      });
-    }
-  }
-
 
   const actions = {};
   const api = {};
 
   actions.api = api;
 
-  api.download = async function(req, res) {
-    const id = req.params.id;
+  // api.download = async function(req, res) {
+  //   const id = req.params.id;
 
-    const attachment = await Attachment.findById(id);
-
-    return responseForAttachment(req, res, attachment, true);
-  };
-
-  /**
-   * @api {get} /attachments.get get attachments
-   * @apiName get
-   * @apiGroup Attachment
-   *
-   * @apiParam {String} id
-   */
-  api.get = async function(req, res) {
-    const id = req.params.id;
+  //   const attachment = await Attachment.findById(id);
 
-    const attachment = await Attachment.findById(id);
-
-    return responseForAttachment(req, res, attachment);
-  };
-
-  api.getBrandLogo = async function(req, res) {
-    const brandLogoAttachment = await Attachment.findOne({ attachmentType: AttachmentType.BRAND_LOGO });
-
-    if (brandLogoAttachment == null) {
-      return res.status(404).json(ApiResponse.error('Brand logo does not exist'));
-    }
-
-    return responseForAttachment(req, res, brandLogoAttachment);
-  };
+  //   return responseForAttachment(req, res, attachment, true);
+  // };
 
   /**
    * @swagger

+ 57 - 0
apps/app/src/server/routes/attachment/download.ts

@@ -0,0 +1,57 @@
+import express from 'express';
+import type { Router } from 'express';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '../../crowi';
+import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
+
+import {
+  GetRequest, GetResponse, getActionFactory, retrieveAttachmentFromIdParam,
+} from './get';
+
+
+const logger = loggerFactory('growi:routes:attachment:download');
+
+
+const generateActivityParameters = (req: CrowiRequest) => {
+  return {
+    ip:  req.ip,
+    endpoint: req.originalUrl,
+    action: SupportedAction.ACTION_ATTACHMENT_DOWNLOAD,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+};
+
+export const downloadRouterFactory = (crowi: Crowi): Router => {
+
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+
+  const router = express.Router();
+
+  // note: retrieveAttachmentFromIdParam requires `req.params.id`
+  router.get<{ id: string }>('/:id([0-9a-z]{24})',
+    certifySharedPageAttachmentMiddleware, loginRequired,
+    retrieveAttachmentFromIdParam,
+
+    async(req: GetRequest, res: GetResponse) => {
+      const { attachment } = res.locals;
+
+      const activityParameters = generateActivityParameters(req);
+      const createActivity = async() => {
+        await crowi.activityService.createActivity(activityParameters);
+      };
+
+      const getAction = getActionFactory(crowi, attachment);
+      await getAction(req, res, { download: true });
+
+      createActivity();
+    });
+
+  return router;
+};

+ 40 - 0
apps/app/src/server/routes/attachment/get-brand-logo.ts

@@ -0,0 +1,40 @@
+import express from 'express';
+import type {
+  Response, Router,
+} from 'express';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '../../crowi';
+import { AttachmentType } from '../../interfaces/attachment';
+import { generateCertifyBrandLogoMiddleware } from '../../middlewares/certify-brand-logo';
+import { Attachment } from '../../models';
+import ApiResponse from '../../util/apiResponse';
+
+import { getActionFactory } from './get';
+
+
+const logger = loggerFactory('growi:routes:attachment:get-brand-logo');
+
+
+export const getBrandLogoRouterFactory = (crowi: Crowi): Router => {
+
+  const certifyBrandLogo = generateCertifyBrandLogoMiddleware(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+
+  const router = express.Router();
+
+  router.get('/brand-logo', certifyBrandLogo, loginRequired, async(req: CrowiRequest, res: Response) => {
+    const brandLogoAttachment = await Attachment.findOne({ attachmentType: AttachmentType.BRAND_LOGO });
+
+    if (brandLogoAttachment == null) {
+      return res.status(404).json(ApiResponse.error('Brand logo does not exist'));
+    }
+
+    const getAction = getActionFactory(crowi, brandLogoAttachment);
+    getAction(req, res);
+  });
+
+  return router;
+};

+ 187 - 0
apps/app/src/server/routes/attachment/get.ts

@@ -0,0 +1,187 @@
+import {
+  getIdForRef, type IPage, type IUser,
+} from '@growi/core';
+import express from 'express';
+import type {
+  NextFunction, Request, Response, Router,
+} from 'express';
+import mongoose from 'mongoose';
+
+import type { CrowiProperties, CrowiRequest } from '~/interfaces/crowi-request';
+import { ResponseMode, type ExpressHttpHeader, type RespondOptions } from '~/server/interfaces/attachment';
+import {
+  type FileUploader,
+  toExpressHttpHeaders, ContentHeaders, applyHeaders,
+} from '~/server/service/file-uploader';
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '../../crowi';
+import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
+import { Attachment, type IAttachmentDocument } from '../../models';
+import ApiResponse from '../../util/apiResponse';
+
+
+const logger = loggerFactory('growi:routes:attachment:get');
+
+
+// TODO: remove this local interface when models/page has typescriptized
+interface PageModel {
+  isAccessiblePageByViewer: (pageId: string, user: IUser | undefined) => Promise<boolean>
+}
+
+type LocalsAfterDataInjection = { attachment: IAttachmentDocument };
+
+type RetrieveAttachmentFromIdParamRequest = CrowiProperties & Request<
+  { id: string },
+  any, any, any,
+  LocalsAfterDataInjection
+>;
+
+type RetrieveAttachmentFromIdParamResponse = Response<
+  any,
+  LocalsAfterDataInjection
+>;
+
+export const retrieveAttachmentFromIdParam = async(
+    req: RetrieveAttachmentFromIdParamRequest, res: RetrieveAttachmentFromIdParamResponse, next: NextFunction,
+): Promise<void> => {
+
+  const id = req.params.id;
+  const attachment = await Attachment.findById(id);
+
+  if (attachment == null) {
+    res.json(ApiResponse.error('attachment not found'));
+    return;
+  }
+
+  const user = req.user;
+
+  // check viewer has permission
+  if (user != null && attachment.page != null) {
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const isAccessible = await Page.isAccessiblePageByViewer(getIdForRef(attachment.page), user);
+    if (!isAccessible) {
+      res.json(ApiResponse.error(`Forbidden to access to the attachment '${attachment.id}'. This attachment might belong to other pages.`));
+      return;
+    }
+  }
+
+  res.locals.attachment = attachment;
+
+  return next();
+};
+
+
+export const generateHeadersForFresh = (attachment: IAttachmentDocument): ExpressHttpHeader[] => {
+  return toExpressHttpHeaders({
+    ETag: `Attachment-${attachment._id}`,
+    'Last-Modified': attachment.createdAt.toUTCString(),
+  });
+};
+
+
+const respondForRedirectMode = async(res: Response, fileUploadService: FileUploader, attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
+  const isDownload = opts?.download ?? false;
+
+  if (!isDownload) {
+    const temporaryUrl = attachment.getValidTemporaryUrl();
+    if (temporaryUrl != null) {
+      res.redirect(temporaryUrl);
+      return;
+    }
+  }
+
+  const temporaryUrl = await fileUploadService.generateTemporaryUrl(attachment, opts);
+
+  res.redirect(temporaryUrl.url);
+
+  // persist temporaryUrl
+  if (!isDownload) {
+    try {
+      attachment.cashTemporaryUrlByProvideSec(temporaryUrl.url, temporaryUrl.lifetimeSec);
+      return;
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }
+};
+
+const respondForRelayMode = async(res: Response, fileUploadService: FileUploader, attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
+  // apply content-* headers before response
+  const isDownload = opts?.download ?? false;
+  const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+  applyHeaders(res, contentHeaders.toExpressHttpHeaders());
+
+  try {
+    const readable = await fileUploadService.findDeliveryFile(attachment);
+    readable.pipe(res);
+  }
+  catch (e) {
+    logger.error(e);
+    res.json(ApiResponse.error(e.message));
+    return;
+  }
+};
+
+export const getActionFactory = (crowi: Crowi, attachment: IAttachmentDocument) => {
+  return async(req: CrowiRequest, res: Response, opts?: RespondOptions): Promise<void> => {
+
+    // add headers before evaluating 'req.fresh'
+    applyHeaders(res, generateHeadersForFresh(attachment));
+
+    // return 304 if request is "fresh"
+    // see: http://expressjs.com/en/5x/api.html#req.fresh
+    if (req.fresh) {
+      res.sendStatus(304);
+      return;
+    }
+
+    const { fileUploadService } = crowi;
+
+    const responseMode = fileUploadService.determineResponseMode();
+    switch (responseMode) {
+      case ResponseMode.DELEGATE:
+        fileUploadService.respond(res, attachment, opts);
+        return;
+      case ResponseMode.REDIRECT:
+        respondForRedirectMode(res, fileUploadService, attachment, opts);
+        return;
+      case ResponseMode.RELAY:
+        respondForRelayMode(res, fileUploadService, attachment, opts);
+        return;
+    }
+  };
+};
+
+
+export type GetRequest = CrowiProperties & Request<
+  { id: string },
+  any, any, any,
+  LocalsAfterDataInjection
+>;
+
+export type GetResponse = Response<
+  any,
+  LocalsAfterDataInjection
+>
+
+export const getRouterFactory = (crowi: Crowi): Router => {
+
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+
+  const router = express.Router();
+
+  // note: retrieveAttachmentFromIdParam requires `req.params.id`
+  router.get<{ id: string }>('/:id([0-9a-z]{24})',
+    certifySharedPageAttachmentMiddleware, loginRequired,
+    retrieveAttachmentFromIdParam,
+
+    (req: GetRequest, res: GetResponse) => {
+      const { attachment } = res.locals;
+      const getAction = getActionFactory(crowi, attachment);
+      getAction(req, res);
+    });
+
+  return router;
+};

+ 3 - 0
apps/app/src/server/routes/attachment/index.ts

@@ -0,0 +1,3 @@
+export * from './get';
+export * from './get-brand-logo';
+export * from './download';

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

@@ -5,8 +5,6 @@ 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';
@@ -15,6 +13,8 @@ import {
   generateUnavailableWhenMaintenanceModeMiddleware, generateUnavailableWhenMaintenanceModeMiddlewareForApi,
 } from '../middlewares/unavailable-when-maintenance-mode';
 
+import * as attachment from './attachment';
+import { routesFactory as attachmentApiRoutesFactory } from './attachment/api';
 import * as forgotPassword from './forgot-password';
 import nextFactory from './next';
 import * as userActivation from './user-activation';
@@ -34,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 certifyBrandLogo = generateCertifyBrandLogoMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
@@ -42,7 +41,7 @@ module.exports = function(crowi, app) {
   const login = require('./login')(crowi, app);
   const loginPassport = require('./login-passport')(crowi, app);
   const admin = require('./admin')(crowi, app);
-  const attachment = require('./attachment')(crowi, app);
+  const attachmentApi = attachmentApiRoutesFactory(crowi).api;
   const comment = require('./comment')(crowi, app);
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
@@ -108,7 +107,8 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/import/qiita'           , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromQiita);
   app.post('/_api/admin/import/testQiitaAPI'    , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.testQiitaAPI);
 
-  app.get('/attachment/brand-logo' , certifyBrandLogo, loginRequired, attachment.api.getBrandLogo);
+  // brand logo
+  app.use('/attachment', attachment.getBrandLogoRouterFactory(crowi));
 
   /*
    * Routes below are unavailable when maintenance mode
@@ -140,11 +140,11 @@ module.exports = function(crowi, app) {
   apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
   apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
 
-  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachment.api.add);
-  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachment.api.uploadProfileImage);
-  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachment.api.remove);
-  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachment.api.removeProfileImage);
-  apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
+  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, addActivity , attachmentApi.add);
+  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
+  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
+  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
+  apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachmentApi.limit);
 
   // API v1
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);
@@ -153,9 +153,9 @@ 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})'         , certifySharedPageAttachmentMiddleware , loginRequired, attachment.api.get);
-  app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
-  app.get('/download/:id([0-9a-z]{24})'         , certifySharedPageAttachmentMiddleware, loginRequired, attachment.api.download);
+
+  app.use('/attachment', attachment.getRouterFactory(crowi));
+  app.use('/download', attachment.downloadRouterFactory(crowi));
 
   app.get('/_search'                            , loginRequired, next.delegateToNext);
 

+ 1 - 1
apps/app/src/server/routes/ogp.ts

@@ -12,6 +12,7 @@ import { param, validationResult, ValidationError } from 'express-validator';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
+import { Attachment } from '../models';
 import { convertStreamToBuffer } from '../util/stream';
 
 const logger = loggerFactory('growi:routes:ogp');
@@ -38,7 +39,6 @@ module.exports = function(crowi) {
 
     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);

+ 1 - 5
apps/app/src/server/service/attachment.js

@@ -1,6 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
 import { AttachmentType } from '../interfaces/attachment';
+import { Attachment } from '../models';
 
 const fs = require('fs');
 
@@ -27,8 +28,6 @@ class AttachmentService {
       throw new Error(res.errorMessage);
     }
 
-    const Attachment = this.crowi.model('Attachment');
-
     const fileStream = fs.createReadStream(file.path, {
       flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
     });
@@ -69,7 +68,6 @@ class AttachmentService {
   }
 
   async removeAttachment(attachmentId) {
-    const Attachment = this.crowi.model('Attachment');
     const { fileUploadService } = this.crowi;
     const attachment = await Attachment.findById(attachmentId);
 
@@ -80,8 +78,6 @@ class AttachmentService {
   }
 
   async isBrandLogoExist() {
-    const Attachment = this.crowi.model('Attachment');
-
     const query = { attachmentType: AttachmentType.BRAND_LOGO };
     const count = await Attachment.countDocuments(query);
 

+ 136 - 106
apps/app/src/server/service/file-uploader/aws.ts

@@ -5,17 +5,23 @@ import {
   DeleteObjectsCommand,
   PutObjectCommand,
   DeleteObjectCommand,
-  GetObjectCommandOutput,
   ListObjectsCommand,
+  type GetObjectCommandInput,
+  ObjectCannedACL,
 } from '@aws-sdk/client-s3';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import urljoin from 'url-join';
 
+import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
 
-import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+import {
+  AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
+} from './file-uploader';
+import { ContentHeaders } from './utils';
 
 
 const logger = loggerFactory('growi:service:fileUploaderAws');
@@ -24,7 +30,7 @@ const logger = loggerFactory('growi:service:fileUploaderAws');
  * File metadata in storage
  * TODO: mv this to "./uploader"
  */
-  interface FileMeta {
+interface FileMeta {
   name: string;
   size: number;
 }
@@ -41,6 +47,51 @@ type AwsConfig = {
   forcePathStyle?: boolean
 }
 
+const isFileExists = async(s3: S3Client, params) => {
+  try {
+    await s3.send(new HeadObjectCommand(params));
+  }
+  catch (err) {
+    if (err != null && err.code === 'NotFound') {
+      return false;
+    }
+    throw err;
+  }
+  return true;
+};
+
+const getAwsConfig = (): AwsConfig => {
+  return {
+    credentials: {
+      accessKeyId: configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
+      secretAccessKey: configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
+    },
+    region: configManager.getConfig('crowi', 'aws:s3Region'),
+    endpoint: configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
+    bucket: configManager.getConfig('crowi', 'aws:s3Bucket'),
+    forcePathStyle: configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null, // s3ForcePathStyle renamed to forcePathStyle in v3
+  };
+};
+
+const S3Factory = (): S3Client => {
+  const config = getAwsConfig();
+  return new S3Client(config);
+};
+
+const getFilePathOnStorage = (attachment) => {
+  if (attachment.filePath != null) {
+    return attachment.filePath;
+  }
+
+  const dirName = (attachment.page != null)
+    ? 'attachment'
+    : 'user';
+  const filePath = urljoin(dirName, attachment.fileName);
+
+  return filePath;
+};
+
+
 // TODO: rewrite this module to be a type-safe implementation
 class AwsFileUploader extends AbstractFileUploader {
 
@@ -51,6 +102,13 @@ class AwsFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritdoc
+   */
+  override listFiles() {
+    throw new Error('Method not implemented.');
+  }
+
   /**
    * @inheritdoc
    */
@@ -68,81 +126,70 @@ class AwsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: Response): void {
-    throw new Error('Method not implemented.');
+  override determineResponseMode() {
+    return configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode')
+      ? ResponseMode.RELAY
+      : ResponseMode.REDIRECT;
   }
 
-}
+  /**
+   * @inheritdoc
+   */
+  override respond(): void {
+    throw new Error('AwsFileUploader does not support ResponseMode.DELEGATE.');
+  }
 
-module.exports = (crowi) => {
-  const lib = new AwsFileUploader(crowi);
+  /**
+   * @inheritdoc
+   */
+  override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
+    if (!this.getIsReadable()) {
+      throw new Error('AWS is not configured.');
+    }
 
-  const getAwsConfig = (): AwsConfig => {
-    return {
-      credentials: {
-        accessKeyId: configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
-        secretAccessKey: configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
-      },
-      region: configManager.getConfig('crowi', 'aws:s3Region'),
-      endpoint: configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
-      bucket: configManager.getConfig('crowi', 'aws:s3Bucket'),
-      forcePathStyle: configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null, // s3ForcePathStyle renamed to forcePathStyle in v3
-    };
-  };
+    const s3 = S3Factory();
+    const awsConfig = getAwsConfig();
+    const filePath = getFilePathOnStorage(attachment);
 
-  const S3Factory = (): S3Client => {
-    const config = getAwsConfig();
-    return new S3Client(config);
-  };
+    const params = {
+      Bucket: awsConfig.bucket,
+      Key: filePath,
+    };
 
-  const getFilePathOnStorage = (attachment) => {
-    if (attachment.filePath != null) {
-      return attachment.filePath;
+    // check file exists
+    const isExists = await isFileExists(s3, params);
+    if (!isExists) {
+      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
     }
 
-    const dirName = (attachment.page != null)
-      ? 'attachment'
-      : 'user';
-    const filePath = urljoin(dirName, attachment.fileName);
+    try {
+      const body = (await s3.send(new GetObjectCommand(params))).Body;
 
-    return filePath;
-  };
+      if (body == null) {
+        throw new Error(`S3 returned null for the Attachment (${filePath})`);
+      }
 
-  const isFileExists = async(s3: S3Client, params) => {
-    try {
-      await s3.send(new HeadObjectCommand(params));
+      // eslint-disable-next-line no-nested-ternary
+      return 'stream' in body
+        ? body.stream() // get stream from Blob
+        : !('read' in body)
+          ? body as unknown as NodeJS.ReadableStream // cast force
+          : body;
     }
     catch (err) {
-      if (err != null && err.code === 'NotFound') {
-        return false;
-      }
-      throw err;
+      logger.error(err);
+      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
     }
-    return true;
-  };
-
-  lib.isValidUploadSettings = function() {
-    return configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
-      && configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
-      && (
-        configManager.getConfig('crowi', 'aws:s3Region') != null
-          || configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null
-      )
-      && configManager.getConfig('crowi', 'aws:s3Bucket') != null;
-  };
-
-  lib.canRespond = function() {
-    return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
-  };
+  }
 
-  (lib as any).respond = async function(res, attachment) {
-    if (!lib.getIsUploadable()) {
+  /**
+   * @inheritDoc
+   */
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    if (!this.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
-    const temporaryUrl = attachment.getValidTemporaryUrl();
-    if (temporaryUrl != null) {
-      return res.redirect(temporaryUrl);
-    }
+
 
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
@@ -151,22 +198,38 @@ module.exports = (crowi) => {
 
     // issue signed url (default: expires 120 seconds)
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
-    const params = {
+    // const isDownload = opts?.download ?? false;
+    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const params: GetObjectCommandInput = {
       Bucket: awsConfig.bucket,
       Key: filePath,
+      // ResponseContentType: contentHeaders.contentType?.value.toString(),
+      // ResponseContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+    };
+    const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
+      expiresIn: lifetimeSecForTemporaryUrl,
+    });
+
+    return {
+      url: signedUrl,
+      lifetimeSec: lifetimeSecForTemporaryUrl,
     };
-    const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), { expiresIn: lifetimeSecForTemporaryUrl });
 
+  }
 
-    res.redirect(signedUrl);
+}
 
-    try {
-      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
-    }
-    catch (err) {
-      logger.error(err);
-    }
+module.exports = (crowi) => {
+  const lib = new AwsFileUploader(crowi);
 
+  lib.isValidUploadSettings = function() {
+    return configManager.getConfig('crowi', 'aws:s3AccessKeyId') != null
+      && configManager.getConfig('crowi', 'aws:s3SecretAccessKey') != null
+      && (
+        configManager.getConfig('crowi', 'aws:s3Region') != null
+          || configManager.getConfig('crowi', 'aws:s3CustomEndpoint') != null
+      )
+      && configManager.getConfig('crowi', 'aws:s3Bucket') != null;
   };
 
   (lib as any).deleteFile = async function(attachment) {
@@ -230,7 +293,7 @@ module.exports = (crowi) => {
       ContentType: attachment.fileFormat,
       Key: filePath,
       Body: fileStream,
-      ACL: 'public-read',
+      ACL: ObjectCannedACL.public_read,
     };
 
     return s3.send(new PutObjectCommand(params));
@@ -245,45 +308,12 @@ module.exports = (crowi) => {
       ContentType: contentType,
       Key: filePath,
       Body: data,
-      ACL: 'public-read',
+      ACL: ObjectCannedACL.public_read,
     };
 
     return s3.send(new PutObjectCommand(params));
   };
 
-  (lib as any).findDeliveryFile = async function(attachment) {
-    if (!lib.getIsReadable()) {
-      throw new Error('AWS is not configured.');
-    }
-
-    const s3 = S3Factory();
-    const awsConfig = getAwsConfig();
-    const filePath = getFilePathOnStorage(attachment);
-
-    const params = {
-      Bucket: awsConfig.bucket,
-      Key: filePath,
-    };
-
-    // check file exists
-    const isExists = await isFileExists(s3, params);
-    if (!isExists) {
-      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
-    }
-
-    let stream : GetObjectCommandOutput['Body'];
-    try {
-      stream = (await s3.send(new GetObjectCommand(params))).Body;
-    }
-    catch (err) {
-      logger.error(err);
-      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
-    }
-
-    // return stream.Readable
-    return stream;
-  };
-
   (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');

+ 126 - 95
apps/app/src/server/service/file-uploader/azure.ts

@@ -6,21 +6,26 @@ import {
   BlobClient,
   BlockBlobClient,
   BlobDeleteOptions,
-  BlobDeleteIfExistsResponse,
-  BlockBlobUploadResponse,
   ContainerClient,
   generateBlobSASQueryParameters,
   ContainerSASPermissions,
   SASProtocol,
-  BlockBlobParallelUploadOptions,
-  BlockBlobUploadStreamOptions,
+  type BlobDeleteIfExistsResponse,
+  type BlockBlobUploadResponse,
+  type BlockBlobParallelUploadOptions,
+  type BlockBlobUploadStreamOptions,
 } from '@azure/storage-blob';
 
+import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
 
-import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+import {
+  AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
+} from './file-uploader';
+import { ContentHeaders } from './utils';
 
 const urljoin = require('url-join');
 
@@ -36,6 +41,59 @@ type AzureConfig = {
   containerName: string,
 }
 
+
+function getAzureConfig(): AzureConfig {
+  return {
+    accountName: configManager.getConfig('crowi', 'azure:storageAccountName'),
+    containerName: configManager.getConfig('crowi', 'azure:storageContainerName'),
+  };
+}
+
+function getCredential(): TokenCredential {
+  const tenantId = configManager.getConfig('crowi', 'azure:tenantId');
+  const clientId = configManager.getConfig('crowi', 'azure:clientId');
+  const clientSecret = configManager.getConfig('crowi', 'azure:clientSecret');
+  return new ClientSecretCredential(tenantId, clientId, clientSecret);
+}
+
+async function getContainerClient(): Promise<ContainerClient> {
+  const { accountName, containerName } = getAzureConfig();
+  const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
+  return blobServiceClient.getContainerClient(containerName);
+}
+
+// Server creates User Delegation SAS Token for container
+// https://learn.microsoft.com/ja-jp/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
+async function getSasToken(lifetimeSec) {
+  const { accountName, containerName } = getAzureConfig();
+  const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
+
+  const now = Date.now();
+  const startsOn = new Date(now - 30 * 1000);
+  const expiresOn = new Date(now + lifetimeSec * 1000);
+  const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
+
+  // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
+  // r:read, a:add, c:create, w:write, d:delete, l:list
+  const containerPermissionsForAnonymousUser = 'rl';
+  const sasOptions = {
+    containerName,
+    permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
+    protocol: SASProtocol.HttpsAndHttp,
+    startsOn,
+    expiresOn,
+  };
+
+  const sasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
+
+  return sasToken;
+}
+
+function getFilePathOnStorage(attachment) {
+  const dirName = (attachment.page != null) ? 'attachment' : 'user';
+  return urljoin(dirName, attachment.fileName);
+}
+
 class AzureFileUploader extends AbstractFileUploader {
 
   /**
@@ -48,108 +106,102 @@ class AzureFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override saveFile(param: SaveFileParam) {
+  override listFiles() {
     throw new Error('Method not implemented.');
   }
 
   /**
    * @inheritdoc
    */
-  override deleteFiles() {
+  override saveFile(param: SaveFileParam) {
     throw new Error('Method not implemented.');
   }
 
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: Response): void {
+  override deleteFiles() {
     throw new Error('Method not implemented.');
   }
 
-}
-
-module.exports = (crowi) => {
-  const lib = new AzureFileUploader(crowi);
-
-  function getAzureConfig(): AzureConfig {
-    return {
-      accountName: configManager.getConfig('crowi', 'azure:storageAccountName'),
-      containerName: configManager.getConfig('crowi', 'azure:storageContainerName'),
-    };
-  }
-
-  function getCredential(): TokenCredential {
-    const tenantId = configManager.getConfig('crowi', 'azure:tenantId');
-    const clientId = configManager.getConfig('crowi', 'azure:clientId');
-    const clientSecret = configManager.getConfig('crowi', 'azure:clientSecret');
-    return new ClientSecretCredential(tenantId, clientId, clientSecret);
+  /**
+   * @inheritdoc
+   */
+  override determineResponseMode() {
+    return configManager.getConfig('crowi', 'azure:referenceFileWithRelayMode')
+      ? ResponseMode.RELAY
+      : ResponseMode.REDIRECT;
   }
 
-  async function getContainerClient(): Promise<ContainerClient> {
-    const { accountName, containerName } = getAzureConfig();
-    const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
-    return blobServiceClient.getContainerClient(containerName);
+  /**
+   * @inheritdoc
+   */
+  override respond(): void {
+    throw new Error('AzureFileUploader does not support ResponseMode.DELEGATE.');
   }
 
-  // Server creates User Delegation SAS Token for container
-  // https://learn.microsoft.com/ja-jp/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
-  async function getSasToken(lifetimeSec) {
-    const { accountName, containerName } = getAzureConfig();
-    const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
-
-    const now = Date.now();
-    const startsOn = new Date(now - 30 * 1000);
-    const expiresOn = new Date(now + lifetimeSec * 1000);
-    const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
-
-    // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
-    // r:read, a:add, c:create, w:write, d:delete, l:list
-    const containerPermissionsForAnonymousUser = 'rl';
-    const sasOptions = {
-      containerName,
-      permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
-      protocol: SASProtocol.HttpsAndHttp,
-      startsOn,
-      expiresOn,
-    };
-
-    const sasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
+  /**
+   * @inheritdoc
+   */
+  override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
+    if (!this.getIsReadable()) {
+      throw new Error('Azure is not configured.');
+    }
 
-    return sasToken;
-  }
+    const filePath = getFilePathOnStorage(attachment);
+    const containerClient = await getContainerClient();
+    const blobClient: BlobClient = containerClient.getBlobClient(filePath);
+    const downloadResponse = await blobClient.download();
+    if (downloadResponse.errorCode) {
+      logger.error(downloadResponse.errorCode);
+      throw new Error(downloadResponse.errorCode);
+    }
+    if (!downloadResponse?.readableStreamBody) {
+      throw new Error(`Coudn't get file from Azure for the Attachment (${filePath})`);
+    }
 
-  function getFilePathOnStorage(attachment) {
-    const dirName = (attachment.page != null) ? 'attachment' : 'user';
-    return urljoin(dirName, attachment.fileName);
+    return downloadResponse.readableStreamBody;
   }
 
-  lib.isValidUploadSettings = function() {
-    return configManager.getConfig('crowi', 'azure:storageAccountName') != null
-      && configManager.getConfig('crowi', 'azure:storageContainerName') != null;
-  };
-
-  lib.canRespond = function() {
-    return !configManager.getConfig('crowi', 'azure:referenceFileWithRelayMode');
-  };
+  /**
+   * @inheritDoc
+   */
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    if (!this.getIsUploadable()) {
+      throw new Error('Azure Blob is not configured.');
+    }
 
-  (lib as any).respond = async function(res, attachment) {
     const containerClient = await getContainerClient();
     const filePath = getFilePathOnStorage(attachment);
-    const blockBlobClient: BlockBlobClient = await containerClient.getBlockBlobClient(filePath);
+    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
     const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'azure:lifetimeSecForTemporaryUrl');
 
     const sasToken = await getSasToken(lifetimeSecForTemporaryUrl);
     const signedUrl = `${blockBlobClient.url}?${sasToken}`;
 
-    res.redirect(signedUrl);
+    // TODO: re-impl using generateSasUrl
+    // const isDownload = opts?.download ?? false;
+    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    // const signedUrl = blockBlobClient.generateSasUrl({
+    //   contentType: contentHeaders.contentType?.value.toString(),
+    //   contentDisposition: contentHeaders.contentDisposition?.value.toString(),
+    // });
 
-    try {
-      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
-    }
-    catch (err) {
-      logger.error(err);
-    }
+    return {
+      url: signedUrl,
+      lifetimeSec: lifetimeSecForTemporaryUrl,
+    };
 
+  }
+
+}
+
+module.exports = (crowi) => {
+  const lib = new AzureFileUploader(crowi);
+
+  lib.isValidUploadSettings = function() {
+    return configManager.getConfig('crowi', 'azure:storageAccountName') != null
+      && configManager.getConfig('crowi', 'azure:storageContainerName') != null;
   };
 
   (lib as any).deleteFile = async function(attachment) {
@@ -206,27 +258,6 @@ module.exports = (crowi) => {
     return;
   };
 
-  (lib as any).findDeliveryFile = async function(attachment) {
-    if (!lib.getIsReadable()) {
-      throw new Error('Azure is not configured.');
-    }
-
-    const filePath = getFilePathOnStorage(attachment);
-    const containerClient = await getContainerClient();
-    const blobClient: BlobClient = containerClient.getBlobClient(filePath);
-    const downloadResponse = await blobClient.download();
-    if (downloadResponse.errorCode) {
-      logger.error(downloadResponse.errorCode);
-      throw new Error(downloadResponse.errorCode);
-    }
-    if (!downloadResponse?.readableStreamBody) {
-      throw new Error(`Coudn't get file from Azure for the Attachment (${filePath})`);
-    }
-
-    return downloadResponse.readableStreamBody;
-  };
-
-
   (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const gcsTotalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');

+ 30 - 8
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -1,5 +1,9 @@
 import { randomUUID } from 'crypto';
 
+import type { Response } from 'express';
+
+import { type RespondOptions, ResponseMode } from '~/server/interfaces/attachment';
+import { Attachment, type IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
@@ -18,19 +22,27 @@ export type CheckLimitResult = {
   errorMessage?: string,
 }
 
+export type TemporaryUrl = {
+  url: string,
+  lifetimeSec: number,
+}
+
 export interface FileUploader {
   getIsUploadable(): boolean,
   isWritable(): Promise<boolean>,
   getIsReadable(): boolean,
   isValidUploadSettings(): boolean,
   getFileUploadEnabled(): boolean,
+  listFiles(): any,
   saveFile(param: SaveFileParam): Promise<any>,
   deleteFiles(): void,
   getFileUploadTotalLimit(): number,
   getTotalFileSize(): Promise<number>,
   doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
-  canRespond(): boolean
-  respond(res: Response, attachment: Response): void,
+  determineResponseMode(): ResponseMode,
+  respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
+  findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>,
+  generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl>,
 }
 
 export abstract class AbstractFileUploader implements FileUploader {
@@ -84,6 +96,8 @@ export abstract class AbstractFileUploader implements FileUploader {
     return !!configManager.getConfig('crowi', 'app:fileUpload');
   }
 
+  abstract listFiles();
+
   abstract saveFile(param: SaveFileParam);
 
   abstract deleteFiles();
@@ -107,8 +121,6 @@ export abstract class AbstractFileUploader implements FileUploader {
    * @returns Total file size
    */
   async getTotalFileSize() {
-    const Attachment = this.crowi.model('Attachment');
-
     // Get attachment total file size
     const res = await Attachment.aggregate().group({
       _id: null,
@@ -137,15 +149,25 @@ export abstract class AbstractFileUploader implements FileUploader {
   }
 
   /**
-   * Checks if Uploader can respond to the HTTP request.
+   * Determine ResponseMode
    */
-  canRespond(): boolean {
-    return false;
+  determineResponseMode(): ResponseMode {
+    return ResponseMode.RELAY;
   }
 
   /**
    * Respond to the HTTP request.
    */
-  abstract respond(res: Response, attachment: Response): void;
+  abstract respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void;
+
+  /**
+   * Find the file and Return ReadStream
+   */
+  abstract findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>;
+
+  /**
+   * Generate temporaryUrl that is valid for a very short time
+   */
+  abstract generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl>;
 
 }

+ 0 - 215
apps/app/src/server/service/file-uploader/gcs.js

@@ -1,215 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-import { AbstractFileUploader } from './file-uploader';
-
-const logger = loggerFactory('growi:service:fileUploaderAws');
-
-const { Storage } = require('@google-cloud/storage');
-const urljoin = require('url-join');
-
-let _instance;
-
-
-module.exports = function(crowi) {
-  const { configManager } = crowi;
-  const lib = new AbstractFileUploader(crowi);
-
-  function getGcsBucket() {
-    return configManager.getConfig('crowi', 'gcs:bucket');
-  }
-
-  function getGcsInstance() {
-    if (_instance == null) {
-      const keyFilename = configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
-      // see https://googleapis.dev/nodejs/storage/latest/Storage.html
-      _instance = keyFilename != null
-        ? new Storage({ keyFilename }) // Create a client with explicit credentials
-        : new Storage(); // Create a client that uses Application Default Credentials
-    }
-    return _instance;
-  }
-
-  function getFilePathOnStorage(attachment) {
-    const namespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
-    // const namespace = null;
-    const dirName = (attachment.page != null)
-      ? 'attachment'
-      : 'user';
-    const filePath = urljoin(namespace || '', dirName, attachment.fileName);
-
-    return filePath;
-  }
-
-  /**
-   * check file existence
-   * @param {File} file https://googleapis.dev/nodejs/storage/latest/File.html
-   */
-  async function isFileExists(file) {
-    // check file exists
-    const res = await file.exists();
-    return res[0];
-  }
-
-  lib.isValidUploadSettings = function() {
-    return configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null
-      && configManager.getConfig('crowi', 'gcs:bucket') != null;
-  };
-
-  lib.canRespond = function() {
-    return !configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode');
-  };
-
-  lib.respond = async function(res, attachment) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('GCS is not configured.');
-    }
-    const temporaryUrl = attachment.getValidTemporaryUrl();
-    if (temporaryUrl != null) {
-      return res.redirect(temporaryUrl);
-    }
-
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-    const filePath = getFilePathOnStorage(attachment);
-    const file = myBucket.file(filePath);
-    const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'gcs:lifetimeSecForTemporaryUrl');
-
-    // issue signed url (default: expires 120 seconds)
-    // https://cloud.google.com/storage/docs/access-control/signed-urls
-    const [signedUrl] = await file.getSignedUrl({
-      action: 'read',
-      expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
-    });
-
-    res.redirect(signedUrl);
-
-    try {
-      return attachment.cashTemporaryUrlByProvideSec(signedUrl, lifetimeSecForTemporaryUrl);
-    }
-    catch (err) {
-      logger.error(err);
-    }
-
-  };
-
-  lib.deleteFile = function(attachment) {
-    const filePath = getFilePathOnStorage(attachment);
-    return lib.deleteFilesByFilePaths([filePath]);
-  };
-
-  lib.deleteFiles = function(attachments) {
-    const filePaths = attachments.map((attachment) => {
-      return getFilePathOnStorage(attachment);
-    });
-    return lib.deleteFilesByFilePaths(filePaths);
-  };
-
-  lib.deleteFilesByFilePaths = function(filePaths) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('GCS is not configured.');
-    }
-
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-
-    const files = filePaths.map((filePath) => {
-      return myBucket.file(filePath);
-    });
-
-    files.forEach((file) => {
-      file.delete({ ignoreNotFound: true });
-    });
-  };
-
-  lib.uploadAttachment = function(fileStream, attachment) {
-    if (!lib.getIsUploadable()) {
-      throw new Error('GCS is not configured.');
-    }
-
-    logger.debug(`File uploading: fileName=${attachment.fileName}`);
-
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-    const filePath = getFilePathOnStorage(attachment);
-    const options = {
-      destination: filePath,
-    };
-
-    return myBucket.upload(fileStream.path, options);
-  };
-
-  lib.saveFile = async function({ filePath, contentType, data }) {
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-
-    return myBucket.file(filePath).save(data, { resumable: false });
-  };
-
-  /**
-   * Find data substance
-   *
-   * @param {Attachment} attachment
-   * @return {stream.Readable} readable stream
-   */
-  lib.findDeliveryFile = async function(attachment) {
-    if (!lib.getIsReadable()) {
-      throw new Error('GCS is not configured.');
-    }
-
-    const gcs = getGcsInstance();
-    const myBucket = gcs.bucket(getGcsBucket());
-    const filePath = getFilePathOnStorage(attachment);
-    const file = myBucket.file(filePath);
-
-    // check file exists
-    const isExists = await isFileExists(file);
-    if (!isExists) {
-      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in GCS`);
-    }
-
-    let stream;
-    try {
-      stream = file.createReadStream();
-    }
-    catch (err) {
-      logger.error(err);
-      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
-    }
-
-    // return stream.Readable
-    return stream;
-  };
-
-  /**
-   * check the file size limit
-   *
-   * In detail, the followings are checked.
-   * - per-file size limit (specified by MAX_FILE_SIZE)
-   */
-  lib.checkLimit = async function(uploadFileSize) {
-    const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
-    const gcsTotalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
-    return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
-  };
-
-  /**
-   * List files in storage
-   */
-  lib.listFiles = async function() {
-    if (!lib.getIsReadable()) {
-      throw new Error('GCS is not configured.');
-    }
-
-    const gcs = getGcsInstance();
-    const bucket = gcs.bucket(getGcsBucket());
-    const [files] = await bucket.getFiles({
-      prefix: configManager.getConfig('crowi', 'gcs:uploadNamespace'),
-    });
-
-    return files.map(({ name, metadata: { size } }) => {
-      return { name, size };
-    });
-  };
-
-  return lib;
-};

+ 260 - 0
apps/app/src/server/service/file-uploader/gcs.ts

@@ -0,0 +1,260 @@
+import { Storage } from '@google-cloud/storage';
+import urljoin from 'url-join';
+
+import type Crowi from '~/server/crowi';
+import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models';
+import loggerFactory from '~/utils/logger';
+
+import { configManager } from '../config-manager';
+
+import {
+  AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
+} from './file-uploader';
+import { ContentHeaders } from './utils';
+
+const logger = loggerFactory('growi:service:fileUploaderGcs');
+
+
+function getGcsBucket() {
+  return configManager.getConfig('crowi', 'gcs:bucket');
+}
+
+let storage: Storage;
+function getGcsInstance() {
+  if (storage == null) {
+    const keyFilename = configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
+    // see https://googleapis.dev/nodejs/storage/latest/Storage.html
+    storage = keyFilename != null
+      ? new Storage({ keyFilename }) // Create a client with explicit credentials
+      : new Storage(); // Create a client that uses Application Default Credentials
+  }
+  return storage;
+}
+
+function getFilePathOnStorage(attachment) {
+  const namespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
+  // const namespace = null;
+  const dirName = (attachment.page != null)
+    ? 'attachment'
+    : 'user';
+  const filePath = urljoin(namespace || '', dirName, attachment.fileName);
+
+  return filePath;
+}
+
+/**
+ * check file existence
+ * @param {File} file https://googleapis.dev/nodejs/storage/latest/File.html
+ */
+async function isFileExists(file) {
+  // check file exists
+  const res = await file.exists();
+  return res[0];
+}
+
+
+// TODO: rewrite this module to be a type-safe implementation
+class GcsFileUploader extends AbstractFileUploader {
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override listFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(param: SaveFileParam) {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override determineResponseMode() {
+    return configManager.getConfig('crowi', 'gcs:referenceFileWithRelayMode')
+      ? ResponseMode.RELAY
+      : ResponseMode.REDIRECT;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(): void {
+    throw new Error('GcsFileUploader does not support ResponseMode.DELEGATE.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
+    if (!this.getIsReadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+    const file = myBucket.file(filePath);
+
+    // check file exists
+    const isExists = await isFileExists(file);
+    if (!isExists) {
+      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in GCS`);
+    }
+
+    try {
+      return file.createReadStream();
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
+    }
+  }
+
+  /**
+   * @inheritDoc
+   */
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    if (!this.getIsUploadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+    const file = myBucket.file(filePath);
+    const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'gcs:lifetimeSecForTemporaryUrl');
+
+    // issue signed url (default: expires 120 seconds)
+    // https://cloud.google.com/storage/docs/access-control/signed-urls
+    // const isDownload = opts?.download ?? false;
+    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const [signedUrl] = await file.getSignedUrl({
+      action: 'read',
+      expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
+      // responseType: contentHeaders.contentType?.value.toString(),
+      // responseDisposition: contentHeaders.contentDisposition?.value.toString(),
+    });
+
+    return {
+      url: signedUrl,
+      lifetimeSec: lifetimeSecForTemporaryUrl,
+    };
+
+  }
+
+}
+
+
+module.exports = function(crowi: Crowi) {
+  const lib = new GcsFileUploader(crowi);
+
+  lib.isValidUploadSettings = function() {
+    return configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null
+      && configManager.getConfig('crowi', 'gcs:bucket') != null;
+  };
+
+  (lib as any).deleteFile = function(attachment) {
+    const filePath = getFilePathOnStorage(attachment);
+    return (lib as any).deleteFilesByFilePaths([filePath]);
+  };
+
+  (lib as any).deleteFiles = function(attachments) {
+    const filePaths = attachments.map((attachment) => {
+      return getFilePathOnStorage(attachment);
+    });
+    return (lib as any).deleteFilesByFilePaths(filePaths);
+  };
+
+  (lib as any).deleteFilesByFilePaths = function(filePaths) {
+    if (!lib.getIsUploadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+
+    const files = filePaths.map((filePath) => {
+      return myBucket.file(filePath);
+    });
+
+    files.forEach((file) => {
+      file.delete({ ignoreNotFound: true });
+    });
+  };
+
+  (lib as any).uploadAttachment = function(fileStream, attachment) {
+    if (!lib.getIsUploadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+    const options = {
+      destination: filePath,
+    };
+
+    return myBucket.upload(fileStream.path, options);
+  };
+
+  lib.saveFile = async function({ filePath, contentType, data }) {
+    const gcs = getGcsInstance();
+    const myBucket = gcs.bucket(getGcsBucket());
+
+    return myBucket.file(filePath).save(data, { resumable: false });
+  };
+
+  /**
+   * check the file size limit
+   *
+   * In detail, the followings are checked.
+   * - per-file size limit (specified by MAX_FILE_SIZE)
+   */
+  (lib as any).checkLimit = async function(uploadFileSize) {
+    const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
+    const gcsTotalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
+  };
+
+  /**
+   * List files in storage
+   */
+  (lib as any).listFiles = async function() {
+    if (!lib.getIsReadable()) {
+      throw new Error('GCS is not configured.');
+    }
+
+    const gcs = getGcsInstance();
+    const bucket = gcs.bucket(getGcsBucket());
+    const [files] = await bucket.getFiles({
+      prefix: configManager.getConfig('crowi', 'gcs:uploadNamespace'),
+    });
+
+    return files.map(({ name, metadata: { size } }) => {
+      return { name, size };
+    });
+  };
+
+  return lib;
+};

+ 29 - 14
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -2,12 +2,15 @@ import { Readable } from 'stream';
 import util from 'util';
 
 import mongoose from 'mongoose';
+import { createModel } from 'mongoose-gridfs';
 
+import type { RespondOptions } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
 import { configManager } from '../config-manager';
 
-import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+import { AbstractFileUploader, type TemporaryUrl, type SaveFileParam } from './file-uploader';
 
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 
@@ -22,6 +25,13 @@ class GridfsFileUploader extends AbstractFileUploader {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritdoc
+   */
+  override listFiles() {
+    throw new Error('Method not implemented.');
+  }
+
   /**
    * @inheritdoc
    */
@@ -39,10 +49,24 @@ class GridfsFileUploader extends AbstractFileUploader {
   /**
    * @inheritdoc
    */
-  override respond(res: Response, attachment: Response): void {
+  override respond(): void {
+    throw new Error('GridfsFileUploader does not support ResponseMode.DELEGATE.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
     throw new Error('Method not implemented.');
   }
 
+  /**
+   * @inheritDoc
+   */
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    throw new Error('GridfsFileUploader does not support ResponseMode.REDIRECT.');
+  }
+
 }
 
 
@@ -52,7 +76,6 @@ module.exports = function(crowi) {
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
   // instantiate mongoose-gridfs
-  const { createModel } = require('mongoose-gridfs');
   const AttachmentFile = createModel({
     modelName: COLLECTION_NAME,
     bucketName: COLLECTION_NAME,
@@ -71,11 +94,7 @@ module.exports = function(crowi) {
   };
 
   (lib as any).deleteFile = async function(attachment) {
-    let filenameValue = attachment.fileName;
-
-    if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
-      filenameValue = attachment.filePath;
-    }
+    const filenameValue = attachment.fileName;
 
     const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
 
@@ -162,12 +181,8 @@ module.exports = function(crowi) {
    * @param {Attachment} attachment
    * @return {stream.Readable} readable stream
    */
-  (lib as any).findDeliveryFile = async function(attachment) {
-    let filenameValue = attachment.fileName;
-
-    if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
-      filenameValue = attachment.filePath;
-    }
+  lib.findDeliveryFile = async function(attachment) {
+    const filenameValue = attachment.fileName;
 
     const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
 

+ 0 - 39
apps/app/src/server/service/file-uploader/index.js

@@ -1,39 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:FileUploaderServise');
-
-const envToModuleMappings = {
-  aws:     'aws',
-  local:   'local',
-  none:    'none',
-  mongo:   'gridfs',
-  mongodb: 'gridfs',
-  gridfs:  'gridfs',
-  gcp:     'gcs',
-  gcs:     'gcs',
-  azure:   'azure',
-};
-
-class FileUploadServiceFactory {
-
-  initializeUploader(crowi) {
-    const method = envToModuleMappings[crowi.configManager.getConfig('crowi', 'app:fileUploadType')];
-    const modulePath = `./${method}`;
-    this.uploader = require(modulePath)(crowi);
-
-    if (this.uploader == null) {
-      logger.warn('Failed to initialize uploader.');
-    }
-  }
-
-  getUploader(crowi) {
-    this.initializeUploader(crowi);
-    return this.uploader;
-  }
-
-}
-
-module.exports = (crowi) => {
-  const factory = new FileUploadServiceFactory(crowi);
-  return factory.getUploader(crowi);
-};

+ 35 - 0
apps/app/src/server/service/file-uploader/index.ts

@@ -0,0 +1,35 @@
+import type Crowi from '~/server/crowi';
+import loggerFactory from '~/utils/logger';
+
+import { configManager } from '../config-manager';
+
+import type { FileUploader } from './file-uploader';
+
+export type { FileUploader } from './file-uploader';
+
+const logger = loggerFactory('growi:service:FileUploaderServise');
+
+const envToModuleMappings = {
+  aws:     'aws',
+  local:   'local',
+  mongo:   'gridfs',
+  mongodb: 'gridfs',
+  gridfs:  'gridfs',
+  gcp:     'gcs',
+  gcs:     'gcs',
+  azure:   'azure',
+};
+
+export const getUploader = (crowi: Crowi): FileUploader => {
+  const method = envToModuleMappings[configManager.getConfig('crowi', 'app:fileUploadType')];
+  const modulePath = `./${method}`;
+  const uploader = require(modulePath)(crowi);
+
+  if (uploader == null) {
+    logger.warn('Failed to initialize uploader.');
+  }
+
+  return uploader;
+};
+
+export * from './utils';

+ 102 - 29
apps/app/src/server/service/file-uploader/local.js → apps/app/src/server/service/file-uploader/local.ts

@@ -1,8 +1,20 @@
 import { Readable } from 'stream';
 
+import type { Response } from 'express';
+
+import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
-import { AbstractFileUploader } from './file-uploader';
+import { configManager } from '../config-manager';
+
+import {
+  AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
+} from './file-uploader';
+import {
+  ContentHeaders, applyHeaders,
+} from './utils';
+
 
 const logger = loggerFactory('growi:service:fileUploaderLocal');
 
@@ -14,22 +26,84 @@ const mkdir = require('mkdirp');
 const streamToPromise = require('stream-to-promise');
 const urljoin = require('url-join');
 
+
+// TODO: rewrite this module to be a type-safe implementation
+class LocalFileUploader extends AbstractFileUploader {
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override listFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(param: SaveFileParam) {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  deleteFileByFilePath(filePath: string): void {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override determineResponseMode() {
+    return configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect')
+      ? ResponseMode.DELEGATE
+      : ResponseMode.RELAY;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritDoc
+   */
+  override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
+    throw new Error('LocalFileUploader does not support ResponseMode.REDIRECT.');
+  }
+
+}
+
 module.exports = function(crowi) {
-  const { configManager } = crowi;
-  const lib = new AbstractFileUploader(crowi);
+  const lib = new LocalFileUploader(crowi);
+
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
   function getFilePathOnStorage(attachment) {
-    let filePath;
-    if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
-      filePath = path.posix.join(basePath, attachment.filePath);
-    }
-    else {
-      const dirName = (attachment.page != null)
-        ? 'attachment'
-        : 'user';
-      filePath = path.posix.join(basePath, dirName, attachment.fileName);
-    }
+    const dirName = (attachment.page != null)
+      ? 'attachment'
+      : 'user';
+    const filePath = path.posix.join(basePath, dirName, attachment.fileName);
 
     return filePath;
   }
@@ -48,14 +122,14 @@ module.exports = function(crowi) {
     return true;
   };
 
-  lib.deleteFile = async function(attachment) {
+  (lib as any).deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
     return lib.deleteFileByFilePath(filePath);
   };
 
-  lib.deleteFiles = async function(attachments) {
+  (lib as any).deleteFiles = async function(attachments) {
     attachments.map((attachment) => {
-      return lib.deleteFile(attachment);
+      return (lib as any).deleteFile(attachment);
     });
   };
 
@@ -72,7 +146,7 @@ module.exports = function(crowi) {
     return fs.unlinkSync(filePath);
   };
 
-  lib.uploadAttachment = async function(fileStream, attachment) {
+  (lib as any).uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
     const filePath = getFilePathOnStorage(attachment);
@@ -126,33 +200,32 @@ module.exports = function(crowi) {
    * In detail, the followings are checked.
    * - per-file size limit (specified by MAX_FILE_SIZE)
    */
-  lib.checkLimit = async function(uploadFileSize) {
+  (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
-  /**
-   * Checks if Uploader can respond to the HTTP request.
-   */
-  lib.canRespond = function() {
-    // Check whether to use internal redirect of nginx or Apache.
-    return configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect');
-  };
-
   /**
    * Respond to the HTTP request.
    * @param {Response} res
    * @param {Response} attachment
    */
-  lib.respond = function(res, attachment) {
+  lib.respond = function(res, attachment, opts) {
     // Responce using internal redirect of nginx or Apache.
     const storagePath = getFilePathOnStorage(attachment);
     const relativePath = path.relative(crowi.publicDir, storagePath);
     const internalPathRoot = configManager.getConfig('crowi', 'fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);
-    res.set('X-Accel-Redirect', internalPath);
-    res.set('X-Sendfile', storagePath);
+
+    // const isDownload = opts?.download ?? false;
+    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    applyHeaders(res, [
+      // ...contentHeaders.toExpressHttpHeaders(),
+      { field: 'X-Accel-Redirect', value: internalPath },
+      { field: 'X-Sendfile', value: storagePath },
+    ]);
+
     return res.end();
   };
 

Fișier diff suprimat deoarece este prea mare
+ 0 - 35
apps/app/src/server/service/file-uploader/none.js


+ 70 - 0
apps/app/src/server/service/file-uploader/utils/headers.ts

@@ -0,0 +1,70 @@
+import type { Response } from 'express';
+
+import type { ExpressHttpHeader, IContentHeaders } from '~/server/interfaces/attachment';
+import type { IAttachmentDocument } from '~/server/models';
+
+
+export class ContentHeaders implements IContentHeaders {
+
+  contentType?: ExpressHttpHeader<'Content-Type'>;
+
+  contentLength?: ExpressHttpHeader<'Content-Length'>;
+
+  contentSecurityPolicy?: ExpressHttpHeader<'Content-Security-Policy'>;
+
+  contentDisposition?: ExpressHttpHeader<'Content-Disposition'>;
+
+  constructor(attachment: IAttachmentDocument, opts?: {
+    inline?: boolean,
+  }) {
+
+    this.contentType = {
+      field: 'Content-Type',
+      value: attachment.fileFormat,
+    };
+    this.contentSecurityPolicy = {
+      field: 'Content-Security-Policy',
+      // eslint-disable-next-line max-len
+      value: "script-src 'unsafe-hashes'; style-src 'self' 'unsafe-inline'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
+    };
+    this.contentDisposition = {
+      field: 'Content-Disposition',
+      value: `${opts?.inline ? 'inline' : 'attachment'};filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+    };
+
+    if (attachment.fileSize) {
+      this.contentLength = {
+        field: 'Content-Length',
+        value: attachment.fileSize.toString(),
+      };
+    }
+  }
+
+  /**
+   * Convert to ExpressHttpHeader[]
+   */
+  toExpressHttpHeaders(): ExpressHttpHeader[] {
+    return [
+      this.contentType,
+      this.contentLength,
+      this.contentSecurityPolicy,
+      this.contentDisposition,
+    ]
+      // exclude undefined
+      .filter((member): member is NonNullable<typeof member> => member != null);
+  }
+
+}
+
+/**
+ * Convert Record to ExpressHttpHeader[]
+ */
+export const toExpressHttpHeaders = (records: Record<string, string | string[]>): ExpressHttpHeader[] => {
+  return Object.entries(records).map(([field, value]) => { return { field, value } });
+};
+
+export const applyHeaders = (res: Response, headers: ExpressHttpHeader[]): void => {
+  headers.forEach((header) => {
+    res.header(header.field, header.value);
+  });
+};

+ 1 - 0
apps/app/src/server/service/file-uploader/utils/index.ts

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

+ 7 - 4
apps/app/src/server/service/g2g-transfer.ts

@@ -17,8 +17,12 @@ import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 import { TransferKey } from '~/utils/vo/transfer-key';
 
+import type Crowi from '../crowi';
+import { Attachment } from '../models';
 import { G2GTransferError, G2GTransferErrorCode } from '../models/vo/g2g-transfer-error';
 
+import { configManager } from './config-manager';
+
 const logger = loggerFactory('growi:service:g2g-transfer');
 
 /**
@@ -208,7 +212,7 @@ interface Receiver {
  */
 export class G2GTransferPusherService implements Pusher {
 
-  crowi: any;
+  crowi: Crowi;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   constructor(crowi: any) {
@@ -241,7 +245,7 @@ export class G2GTransferPusherService implements Pusher {
   }
 
   public async getTransferability(destGROWIInfo: IDataGROWIInfo): Promise<Transferability> {
-    const { fileUploadService, configManager } = this.crowi;
+    const { fileUploadService } = this.crowi;
 
     const version = this.crowi.version;
     if (version !== destGROWIInfo.version) {
@@ -322,7 +326,6 @@ export class G2GTransferPusherService implements Pusher {
     const BATCH_SIZE = 100;
     const { fileUploadService, socketIoService } = this.crowi;
     const socket = socketIoService.getAdminSocket();
-    const Attachment = this.crowi.model('Attachment');
     const filesFromSrcGROWI = await this.listFilesInStorage(tk);
 
     /**
@@ -424,7 +427,7 @@ export class G2GTransferPusherService implements Pusher {
     const targetConfigKeys = UPLOAD_CONFIG_KEYS;
 
     const uploadConfigs = Object.fromEntries(targetConfigKeys.map((key) => {
-      return [key, this.crowi.configManager.getConfig('crowi', key)];
+      return [key, configManager.getConfig('crowi', key)];
     }));
 
     let zipFileStream: ReadStream;

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

@@ -32,6 +32,7 @@ import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { Attachment } from '../models';
 import { PathAlreadyExistsError } from '../models/errors';
 import { IOptionsForCreate, IOptionsForUpdate } from '../models/interfaces/page-operation';
 import PageOperation, { PageOperationDocument } from '../models/page-operation';
@@ -1695,7 +1696,6 @@ class PageService {
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
-    const Attachment = this.crowi.model('Attachment');
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
 
     const { attachmentService } = this.crowi;

+ 1 - 1
apps/app/src/utils/admin-page-util.ts

@@ -24,7 +24,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 export const retrieveServerSideProps: any = async(
     context: GetServerSidePropsContext, injectServerConfigurations?:(context: GetServerSidePropsContext, props) => Promise<void>,
 ) => {
-  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const req = context.req as CrowiRequest;
   const { user } = req;
 
   const result = await getServerSideCommonProps(context);

+ 8 - 3
packages/core/src/interfaces/attachment.ts

@@ -6,13 +6,18 @@ import type { IUser } from './user';
 export type IAttachment = {
   page?: Ref<IPage>,
   creator?: Ref<IUser>,
-  createdAt: Date,
+  fileName: string,
+  fileFormat: string,
   fileSize: number,
+  originalName: string,
+  temporaryUrlCached?: string,
+  temporaryUrlExpiredAt?: Date,
+
+  createdAt: Date,
+
   // virtual property
   filePathProxied: string,
-  fileFormat: string,
   downloadPathProxied: string,
-  originalName: string,
 };
 
 export type IAttachmentHasId = IAttachment & HasObjectId;

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

@@ -48,6 +48,7 @@
     "axios": "^0.24.0",
     "bunyan": "^1.8.15",
     "hast-util-select": "^5.0.5",
+    "mongoose": "^6.11.3",
     "swr": "^2.0.3",
     "universal-bunyan": "^0.9.2"
   },

+ 7 - 4
packages/remark-attachment-refs/src/server/routes/refs.ts

@@ -1,4 +1,6 @@
+import type { IAttachment } from '@growi/core';
 import { OptionParser } from '@growi/core/dist/remark-plugins';
+import { model } from 'mongoose';
 
 import loggerFactory from '../../utils/logger';
 
@@ -24,7 +26,6 @@ export const routesFactory = (crowi): any => {
 
   const User = crowi.model('User');
   const Page = crowi.model('Page');
-  const Attachment = crowi.model('Attachment');
 
   const { PageQueryBuilder } = Page;
 
@@ -100,6 +101,7 @@ export const routesFactory = (crowi): any => {
       orConditions.push({ _id: ObjectId(fileNameOrId) });
     }
 
+    const Attachment = model<IAttachment>('Attachment');
     const attachment = await Attachment
       .findOne({
         page: page._id,
@@ -189,15 +191,16 @@ export const routesFactory = (crowi): any => {
     logger.debug('retrieve attachments for pages:', pageIds);
 
     // create query to find
+    const Attachment = model<IAttachment>('Attachment');
     let query = Attachment
       .find({
         page: { $in: pageIds },
       });
     // add regex condition
     if (regex != null) {
-      query = query.and({
-        originalName: { $regex: regex },
-      });
+      query = query.and([
+        { originalName: { $regex: regex } },
+      ]);
     }
 
     const attachments = await query

Fișier diff suprimat deoarece este prea mare
+ 516 - 1438
yarn.lock


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff