Browse Source

feat: add S3 presigned URL direct download support

- Add s3.directLinks config to enable S3 presigned URL redirects for downloads
- Add s3.presignedUrlExpiration config to control presigned URL lifetime
- Implement createPresignedUrl in S3FileService using @aws-sdk/s3-request-presigner
- Add getPresignedDownloadUrl to FileService for storage-aware URL generation
- Modify FileController to redirect download requests to S3 when directLinks enabled
- Update config.example.yaml and config seed with new S3 options
lihe 1 week ago
parent
commit
245fb07f6f

+ 195 - 138
backend/package-lock.json

@@ -9,6 +9,7 @@
       "version": "1.13.0",
       "dependencies": {
         "@aws-sdk/client-s3": "^3.787.0",
+        "@aws-sdk/s3-request-presigner": "^3.787.0",
         "@keyv/redis": "^4.4.0",
         "@nestjs/cache-manager": "^3.0.1",
         "@nestjs/common": "^11.0.17",
@@ -893,6 +894,117 @@
         "node": ">=18.0.0"
       }
     },
+    "node_modules/@aws-sdk/s3-request-presigner": {
+      "version": "3.1063.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1063.0.tgz",
+      "integrity": "sha512-uYDuWEbBYz/DfS0sEbDA3BeBCNNaLiE+dmblIRrmU02bhoqsotJb3qWfx0+I3JlukC7Vk8wRuyRgjZzq1FyXGA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@aws-sdk/core": "^3.974.18",
+        "@aws-sdk/signature-v4-multi-region": "^3.996.32",
+        "@aws-sdk/types": "^3.973.11",
+        "@smithy/core": "^3.24.6",
+        "@smithy/types": "^4.14.3",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/core": {
+      "version": "3.974.18",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.18.tgz",
+      "integrity": "sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@aws-sdk/types": "^3.973.11",
+        "@aws-sdk/xml-builder": "^3.972.28",
+        "@aws/lambda-invoke-store": "^0.2.2",
+        "@smithy/core": "^3.24.6",
+        "@smithy/signature-v4": "^5.4.6",
+        "@smithy/types": "^4.14.3",
+        "bowser": "^2.11.0",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/signature-v4-multi-region": {
+      "version": "3.996.32",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.32.tgz",
+      "integrity": "sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@aws-sdk/types": "^3.973.11",
+        "@smithy/signature-v4": "^5.4.6",
+        "@smithy/types": "^4.14.3",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/types": {
+      "version": "3.973.11",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.11.tgz",
+      "integrity": "sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@smithy/types": "^4.14.3",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/xml-builder": {
+      "version": "3.972.28",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.28.tgz",
+      "integrity": "sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@smithy/types": "^4.14.3",
+        "fast-xml-parser": "5.7.3",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@aws-sdk/s3-request-presigner/node_modules/fast-xml-parser": {
+      "version": "5.7.3",
+      "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
+      "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@nodable/entities": "^2.1.0",
+        "fast-xml-builder": "^1.1.7",
+        "path-expression-matcher": "^1.5.0",
+        "strnum": "^2.2.3"
+      },
+      "bin": {
+        "fxparser": "src/cli/cli.js"
+      }
+    },
+    "node_modules/@aws-sdk/s3-request-presigner/node_modules/strnum": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
+      "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/@aws-sdk/signature-v4-multi-region": {
       "version": "3.775.0",
       "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.775.0.tgz",
@@ -1019,6 +1131,15 @@
         "node": ">=18.0.0"
       }
     },
+    "node_modules/@aws/lambda-invoke-store": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
+      "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
     "node_modules/@babel/code-frame": {
       "version": "7.26.2",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -3003,6 +3124,18 @@
         "reflect-metadata": "^0.1.13 || ^0.2.0"
       }
     },
+    "node_modules/@nodable/entities": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz",
+      "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodable"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/@nodelib/fs.scandir": {
       "version": "2.1.5",
       "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3369,17 +3502,13 @@
       }
     },
     "node_modules/@smithy/core": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.2.0.tgz",
-      "integrity": "sha512-k17bgQhVZ7YmUvA8at4af1TDpl0NDMBuBKJl8Yg0nrefwmValU+CnA5l/AriVdQNthU/33H3nK71HrLgqOPr1Q==",
+      "version": "3.24.6",
+      "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz",
+      "integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==",
+      "license": "Apache-2.0",
       "dependencies": {
-        "@smithy/middleware-serde": "^4.0.3",
-        "@smithy/protocol-http": "^5.1.0",
-        "@smithy/types": "^4.2.0",
-        "@smithy/util-body-length-browser": "^4.0.0",
-        "@smithy/util-middleware": "^4.0.2",
-        "@smithy/util-stream": "^4.2.0",
-        "@smithy/util-utf8": "^4.0.0",
+        "@aws-crypto/crc32": "5.2.0",
+        "@smithy/types": "^4.14.3",
         "tslib": "^2.6.2"
       },
       "engines": {
@@ -3746,17 +3875,13 @@
       }
     },
     "node_modules/@smithy/signature-v4": {
-      "version": "5.0.2",
-      "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.0.2.tgz",
-      "integrity": "sha512-Mz+mc7okA73Lyz8zQKJNyr7lIcHLiPYp0+oiqiMNc/t7/Kf2BENs5d63pEj7oPqdjaum6g0Fc8wC78dY1TgtXw==",
+      "version": "5.4.6",
+      "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz",
+      "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==",
+      "license": "Apache-2.0",
       "dependencies": {
-        "@smithy/is-array-buffer": "^4.0.0",
-        "@smithy/protocol-http": "^5.1.0",
-        "@smithy/types": "^4.2.0",
-        "@smithy/util-hex-encoding": "^4.0.0",
-        "@smithy/util-middleware": "^4.0.2",
-        "@smithy/util-uri-escape": "^4.0.0",
-        "@smithy/util-utf8": "^4.0.0",
+        "@smithy/core": "^3.24.6",
+        "@smithy/types": "^4.14.3",
         "tslib": "^2.6.2"
       },
       "engines": {
@@ -3781,9 +3906,10 @@
       }
     },
     "node_modules/@smithy/types": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.2.0.tgz",
-      "integrity": "sha512-7eMk09zQKCO+E/ivsjQv+fDlOupcFUCSC/L2YUPgwhvowVGWbPQHjEFcmjt7QQ4ra5lyowS92SV53Zc6XD4+fg==",
+      "version": "4.14.3",
+      "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz",
+      "integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==",
+      "license": "Apache-2.0",
       "dependencies": {
         "tslib": "^2.6.2"
       },
@@ -3997,32 +4123,6 @@
         "node": ">=18.0.0"
       }
     },
-    "node_modules/@tokenizer/inflate": {
-      "version": "0.2.7",
-      "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
-      "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "debug": "^4.4.0",
-        "fflate": "^0.8.2",
-        "token-types": "^6.0.0"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
-    "node_modules/@tokenizer/token": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
-      "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
-      "optional": true,
-      "peer": true
-    },
     "node_modules/@tsconfig/node10": {
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
@@ -6612,6 +6712,22 @@
         }
       ]
     },
+    "node_modules/fast-xml-builder": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
+      "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "path-expression-matcher": "^1.5.0",
+        "xml-naming": "^0.1.0"
+      }
+    },
     "node_modules/fast-xml-parser": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz",
@@ -6642,13 +6758,6 @@
         "reusify": "^1.0.4"
       }
     },
-    "node_modules/fflate": {
-      "version": "0.8.2",
-      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
-      "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
-      "optional": true,
-      "peer": true
-    },
     "node_modules/file-entry-cache": {
       "version": "8.0.0",
       "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6661,25 +6770,6 @@
         "node": ">=16.0.0"
       }
     },
-    "node_modules/file-type": {
-      "version": "20.4.1",
-      "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz",
-      "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "@tokenizer/inflate": "^0.2.6",
-        "strtok3": "^10.2.0",
-        "token-types": "^6.0.0",
-        "uint8array-extras": "^1.4.0"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sindresorhus/file-type?sponsor=1"
-      }
-    },
     "node_modules/filesize": {
       "version": "10.1.4",
       "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.4.tgz",
@@ -8562,6 +8652,21 @@
         "node": ">=8"
       }
     },
+    "node_modules/path-expression-matcher": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
+      "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/path-key": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -8607,20 +8712,6 @@
       "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
       "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
     },
-    "node_modules/peek-readable": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-7.0.0.tgz",
-      "integrity": "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ==",
-      "optional": true,
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
     "node_modules/performance-now": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -9995,24 +10086,6 @@
         }
       ]
     },
-    "node_modules/strtok3": {
-      "version": "10.2.2",
-      "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.2.2.tgz",
-      "integrity": "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg==",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "@tokenizer/token": "^0.3.0",
-        "peek-readable": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
     "node_modules/supports-color": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -10219,24 +10292,6 @@
         "node": ">=0.6"
       }
     },
-    "node_modules/token-types": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz",
-      "integrity": "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "@tokenizer/token": "^0.3.0",
-        "ieee754": "^1.2.1"
-      },
-      "engines": {
-        "node": ">=14.16"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
     "node_modules/tr46": {
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.0.tgz",
@@ -10469,19 +10524,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/uint8array-extras": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz",
-      "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==",
-      "optional": true,
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/underscore": {
       "version": "1.12.1",
       "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz",
@@ -10902,6 +10944,21 @@
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
       "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
     },
+    "node_modules/xml-naming": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
+      "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
     "node_modules/xmlbuilder": {
       "version": "15.1.1",
       "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

+ 1 - 0
backend/package.json

@@ -14,6 +14,7 @@
   },
   "dependencies": {
     "@aws-sdk/client-s3": "^3.787.0",
+    "@aws-sdk/s3-request-presigner": "^3.787.0",
     "@keyv/redis": "^4.4.0",
     "@nestjs/cache-manager": "^3.0.1",
     "@nestjs/common": "^11.0.17",

+ 8 - 0
backend/prisma/seed/config.seed.ts

@@ -372,6 +372,14 @@ export const configVariables = {
       type: "boolean",
       defaultValue: "true",
     },
+    directLinks: {
+      type: "boolean",
+      defaultValue: "false",
+    },
+    presignedUrlExpiration: {
+      type: "timespan",
+      defaultValue: "1 hour",
+    },
   },
   legal: {
     enabled: {

+ 13 - 0
backend/src/file/file.controller.ts

@@ -72,6 +72,19 @@ export class FileController {
     @Param("fileId") fileId: string,
     @Query("download") download = "true",
   ) {
+    // If downloading and S3 presigned URLs are enabled, redirect to S3 directly
+    if (download === "true") {
+      const presignedUrl = await this.fileService.getPresignedDownloadUrl(
+        shareId,
+        fileId,
+        true,
+      );
+      if (presignedUrl) {
+        res.redirect(302, presignedUrl);
+        return;
+      }
+    }
+
     const file = await this.fileService.get(shareId, fileId);
 
     const headers = {

+ 19 - 0
backend/src/file/file.service.ts

@@ -59,6 +59,25 @@ export class FileService {
     return storageService.deleteAllFiles(shareId);
   }
 
+  async getPresignedDownloadUrl(
+    shareId: string,
+    fileId: string,
+    download: boolean,
+  ): Promise<string | null> {
+    const share = await this.prisma.share.findFirst({
+      where: { id: shareId },
+    });
+    const storageService = this.getStorageService(share.storageProvider);
+
+    if (
+      storageService instanceof S3FileService &&
+      this.configService.get("s3.directLinks")
+    ) {
+      return storageService.createPresignedUrl(shareId, fileId, download);
+    }
+    return null;
+  }
+
   async getZip(shareId: string): Promise<Readable> {
     const storageService = this.getStorageService();
     return await storageService.getZip(shareId);

+ 32 - 0
backend/src/file/s3.service.ts

@@ -18,10 +18,12 @@ import {
   UploadPartCommand,
   UploadPartCommandOutput,
 } from "@aws-sdk/client-s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 import { PrismaService } from "src/prisma/prisma.service";
 import { ConfigService } from "src/config/config.service";
 import * as crypto from "crypto";
 import * as mime from "mime-types";
+import * as moment from "moment";
 import { File } from "./file.service";
 import { Readable } from "stream";
 import { validate as isValidUUID } from "uuid";
@@ -275,6 +277,36 @@ export class S3FileService {
     }
   }
 
+  async createPresignedUrl(
+    shareId: string,
+    fileId: string,
+    download: boolean,
+  ): Promise<string> {
+    const fileName = (
+      await this.prisma.file.findUnique({ where: { id: fileId } })
+    ).name;
+    const key = `${this.getS3Path()}${shareId}/${fileName}`;
+    const mimeType =
+      mime.contentType(fileName.split(".").pop()) ||
+      "application/octet-stream";
+    const dispositionType = download ? "attachment" : "inline";
+
+    const command = new GetObjectCommand({
+      Bucket: this.config.get("s3.bucketName"),
+      Key: key,
+      ResponseContentDisposition: `${dispositionType}; filename="${encodeURIComponent(fileName)}"`,
+      ResponseContentType: mimeType,
+    });
+
+    const timespan = this.config.get("s3.presignedUrlExpiration");
+    const expiresIn = moment
+      .duration(timespan.value, timespan.unit)
+      .asSeconds();
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    return getSignedUrl(this.getS3Instance() as any, command, { expiresIn });
+  }
+
   getS3Instance(): S3Client {
     const checksumCalculation =
       this.config.get("s3.useChecksum") === true ? null : "WHEN_REQUIRED";

+ 4 - 0
config.example.yaml

@@ -215,6 +215,10 @@ s3:
   secret: ""
   #Turn off for backends that do not support checksum (e.g. B2).
   useChecksum: "true"
+  #Whether to use presigned URLs for direct downloads from S3. When enabled, file downloads redirect to S3 instead of streaming through the server.
+  directLinks: "false"
+  #How long presigned download URLs are valid
+  presignedUrlExpiration: 1 hour
 legal:
   #Whether to show a link to imprint and privacy policy in the footer.
   enabled: "false"