Skip to content

Commit 36d7706

Browse files
authored
Merge pull request #128 from codelitdev/issue-127
Cloudfront compatible hosting
2 parents 0923b66 + 9bc224c commit 36d7706

12 files changed

Lines changed: 179 additions & 72 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
MediaLit is a Node.js based app to store, convert and optimise the media files on any AWS S3 compatible storage.
44

5+
## Setting up correct access on AWS S3 bucket
6+
7+
Before you start uploading to your bucket, make sure you have set up the correct access on your S3 bucket.
8+
9+
### 1. Without Cloudfront
10+
11+
![BLock public access](./apps/api/assets/without-cloudfront.png)
12+
13+
### 2. With Cloudfront
14+
15+
![BLock public access](./apps/api/assets/with-cloudfront.png)
16+
17+
## Using Cloudfront
18+
19+
If you need to use a Cloudfront CDN, you can enable it in the app, by setting up the following values in your .env file.
20+
21+
```sh
22+
USE_CLOUDFRONT=true
23+
CLOUDFRONT_ENDPOINT=CLOUDFRONT_DISTRIBUTION_NAME
24+
CLOUDFRONT_PRIVATE_KEY="PRIVATE_KEY"
25+
CLOUDFRONT_KEY_PAIR_ID=KEY_PAIR_ID
26+
```
27+
28+
We assume that since you are using Cloudfront, you have locked down your bucket from public access. Therefore, all the files uploaded to the bucket will have ACL set to `private` i.e. they will require signed URLs in order to access them.
29+
530
## Enable trust proxy
631

732
This app is based on [Express](https://expressjs.com/) which cannot work reliably when it is behind a proxy. For example, it cannot detect if it behind a proxy.
128 KB
Loading
127 KB
Loading

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
},
3333
"dependencies": {
3434
"@aws-sdk/client-s3": "^3.55.0",
35+
"@aws-sdk/cloudfront-signer": "^3.572.0",
3536
"@aws-sdk/s3-request-presigner": "^3.55.0",
3637
"@medialit/images": "workspace:*",
3738
"@medialit/models": "workspace:*",

apps/api/src/config/constants.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,48 @@
1-
export const appName = process.env.APP_NAME || "Cloud Upload Service";
2-
export const dbConnectionString = process.env.DB_CONNECTION_STRING;
1+
// App config
2+
export const appName = process.env.APP_NAME || "MediaLit";
33
export const jwtSecret = process.env.JWT_SECRET || "r@nd0m1e";
44
export const jwtExpire = process.env.JWT_EXPIRES_IN || "1d";
5-
export const mailHost = process.env.EMAIL_HOST;
6-
export const mailUser = process.env.EMAIL_USER;
7-
export const mailPass = process.env.EMAIL_PASS;
8-
export const mailFrom = process.env.EMAIL_FROM;
9-
export const mailPort = parseInt(process.env.EMAIL_PORT || "") || 587;
5+
export const tempFileDirForUploads = process.env.TEMP_FILE_DIR_FOR_UPLOADS;
6+
export const maxFileUploadSize = process.env.MAX_UPLOAD_SIZE || 2147483648;
7+
export const PRESIGNED_URL_VALIDITY_MINUTES = 5;
8+
export const PRESIGNED_URL_LENGTH = 100;
9+
export const MEDIA_ID_LENGTH = 40;
1010
export const APIKEY_RESTRICTION_REFERRER = "referrer";
1111
export const APIKEY_RESTRICTION_IP = "ipaddress";
1212
export const APIKEY_RESTRICTION_CUSTOM = "custom";
13-
export const tempFileDirForUploads = process.env.TEMP_FILE_DIR_FOR_UPLOADS;
14-
export const maxFileUploadSize = process.env.MAX_UPLOAD_SIZE || 2147483648;
1513
export const imagePattern = /^image\/(jpe?g|png)$/;
1614
export const imagePatternIncludingGif = /^image\/(jpe?g|png|gif|webp)$/;
1715
export const videoPattern = /video/;
16+
export const thumbnailWidth = 120;
17+
export const thumbnailHeight = 69;
18+
export const numberOfRecordsPerPage = 10;
19+
20+
// Database config
21+
export const dbConnectionString = process.env.DB_CONNECTION_STRING;
22+
23+
// Mail config
24+
export const mailHost = process.env.EMAIL_HOST;
25+
export const mailUser = process.env.EMAIL_USER;
26+
export const mailPass = process.env.EMAIL_PASS;
27+
export const mailFrom = process.env.EMAIL_FROM;
28+
export const mailPort = parseInt(process.env.EMAIL_PORT || "") || 587;
29+
30+
// AWS S3 config
1831
export const cloudEndpoint = process.env.CLOUD_ENDPOINT || "";
1932
export const cloudRegion = process.env.CLOUD_REGION || "";
2033
export const cloudKey = process.env.CLOUD_KEY || "";
2134
export const cloudSecret = process.env.CLOUD_SECRET || "";
2235
export const cloudBucket = process.env.CLOUD_BUCKET_NAME || "";
23-
export const cdnEndpoint = process.env.CDN_ENDPOINT || "";
24-
export const thumbnailWidth = 120;
25-
export const thumbnailHeight = 69;
26-
export const numberOfRecordsPerPage = 10;
27-
export const PRESIGNED_URL_VALIDITY_MINUTES = 5;
28-
export const PRESIGNED_URL_LENGTH = 100;
29-
export const MEDIA_ID_LENGTH = 40;
3036
export const CLOUD_PREFIX = process.env.CLOUD_PREFIX || "";
37+
export const S3_ENDPOINT = process.env.S3_ENDPOINT || "";
38+
39+
// Cloudfront config
40+
export const USE_CLOUDFRONT = process.env.USE_CLOUDFRONT === "true";
41+
export const CLOUDFRONT_ENDPOINT = process.env.CLOUDFRONT_ENDPOINT || "";
42+
export const CLOUDFRONT_KEY_PAIR_ID = process.env.CLOUDFRONT_KEY_PAIR_ID || "";
43+
export const CLOUDFRONT_PRIVATE_KEY = process.env.CLOUDFRONT_PRIVATE_KEY || "";
44+
export const CDN_MAX_AGE = process.env.CDN_MAX_AGE
45+
? +process.env.CDN_MAX_AGE
46+
: 1000 * 60 * 60; // one hour
47+
48+
export const ENDPOINT = USE_CLOUDFRONT ? CLOUDFRONT_ENDPOINT : S3_ENDPOINT;

apps/api/src/media/service.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@ import {
55
tempFileDirForUploads,
66
imagePattern,
77
videoPattern,
8-
thumbnailWidth,
9-
thumbnailHeight,
108
imagePatternIncludingGif,
9+
USE_CLOUDFRONT,
1110
} from "../config/constants";
1211
import imageUtils from "@medialit/images";
1312
import {
@@ -18,6 +17,7 @@ import {
1817
import type { MediaWithUserId } from "./model";
1918
import {
2019
generateSignedUrl,
20+
generateCDNSignedUrl,
2121
putObject,
2222
deleteObject,
2323
UploadParams,
@@ -37,7 +37,7 @@ import {
3737
} from "./queries";
3838
import * as presignedUrlService from "../presigning/service";
3939
import getTags from "./utils/get-tags";
40-
import { getMainFileUrl, getThumbnailUrl } from "./utils/get-cdn-urls";
40+
import { getMainFileUrl, getThumbnailUrl } from "./utils/get-public-urls";
4141

4242
const generateAndUploadThumbnail = async ({
4343
workingDirectory,
@@ -69,7 +69,7 @@ const generateAndUploadThumbnail = async ({
6969
Key: key,
7070
Body: createReadStream(thumbPath),
7171
ContentType: "image/webp",
72-
ACL: "public-read",
72+
ACL: USE_CLOUDFRONT ? "private" : "public-read",
7373
Tagging: tags,
7474
});
7575
}
@@ -122,12 +122,16 @@ async function upload({
122122
const uploadParams: UploadParams = {
123123
Key: generateKey({
124124
mediaId: fileName.name,
125-
extension: fileExtension,
126-
type: "main",
125+
access: access === "public" ? "public" : "private",
126+
filename: `main.${fileExtension}`,
127127
}),
128128
Body: createReadStream(mainFilePath),
129129
ContentType: mimeType,
130-
ACL: access === "public" ? "public-read" : "private",
130+
ACL: USE_CLOUDFRONT
131+
? "private"
132+
: access === "public"
133+
? "public-read"
134+
: "private",
131135
};
132136
const tags = getTags(userId, group);
133137
uploadParams.Tagging = tags;
@@ -142,7 +146,8 @@ async function upload({
142146
originalFilePath: mainFilePath,
143147
key: generateKey({
144148
mediaId: fileName.name,
145-
type: "thumb",
149+
access: "public",
150+
filename: "thumb.webp",
146151
}),
147152
tags,
148153
});
@@ -238,6 +243,12 @@ async function getMediaDetails({
238243
return null;
239244
}
240245

246+
const key = generateKey({
247+
mediaId: media.mediaId,
248+
access: media.accessControl === "private" ? "private" : "public",
249+
filename: `main.${path.extname(media.fileName).replace(".", "")}`,
250+
});
251+
241252
return {
242253
mediaId: media.mediaId,
243254
originalFileName: media.originalFileName,
@@ -246,15 +257,9 @@ async function getMediaDetails({
246257
access: media.accessControl === "private" ? "private" : "public",
247258
file:
248259
media.accessControl === "private"
249-
? await generateSignedUrl({
250-
name: generateKey({
251-
mediaId: media.mediaId,
252-
extension: path
253-
.extname(media.fileName)
254-
.replace(".", ""),
255-
type: "main",
256-
}),
257-
})
260+
? USE_CLOUDFRONT
261+
? generateCDNSignedUrl(key)
262+
: await generateSignedUrl(key)
258263
: getMainFileUrl(media),
259264
thumbnail: media.thumbnailGenerated
260265
? getThumbnailUrl(media.mediaId)
@@ -278,16 +283,16 @@ async function deleteMedia({
278283

279284
const key = generateKey({
280285
mediaId,
281-
extension: media.mimeType.split("/")[1],
282-
type: "main",
286+
access: media.accessControl === "private" ? "private" : "public",
287+
filename: `main.${media.fileName.split(".")[1]}`,
283288
});
284289
await deleteObject({ Key: key });
285290

286291
if (media.thumbnailGenerated) {
287292
const thumbKey = generateKey({
288293
mediaId,
289-
extension: media.mimeType.split("/")[1],
290-
type: "thumb",
294+
access: "public",
295+
filename: "thumb.webp",
291296
});
292297
await deleteObject({ Key: thumbKey });
293298
}
Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import { CLOUD_PREFIX } from "../../config/constants";
22

3-
interface GenerateKeyProps {
4-
mediaId: string;
5-
type: "main" | "thumb";
6-
extension?: string;
7-
}
8-
93
export default function generateKey({
104
mediaId,
11-
type,
12-
extension,
13-
}: GenerateKeyProps): string {
14-
return `${CLOUD_PREFIX ? `${CLOUD_PREFIX}/` : ""}${mediaId}/${type}.${
15-
type === "thumb" ? "webp" : extension
16-
}`;
5+
access,
6+
filename,
7+
}: {
8+
mediaId: string;
9+
access: "private" | "public";
10+
filename: string;
11+
}): string {
12+
return `${
13+
CLOUD_PREFIX ? `${CLOUD_PREFIX}/` : ""
14+
}${access}/${mediaId}/${filename}`;
1715
}

apps/api/src/media/utils/get-cdn-urls.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import path from "path";
2+
import { ENDPOINT, CLOUD_PREFIX } from "../../config/constants";
3+
import { Media } from "@medialit/models";
4+
5+
const prefix = CLOUD_PREFIX ? `${CLOUD_PREFIX}/` : "";
6+
7+
export function getMainFileUrl(media: Media) {
8+
return `${ENDPOINT}/${prefix}public/${media.mediaId}/main${path.extname(
9+
media.fileName
10+
)}`;
11+
}
12+
13+
export function getThumbnailUrl(mediaId: string) {
14+
return `${ENDPOINT}/${prefix}public/${mediaId}/thumb.webp`;
15+
}

apps/api/src/services/s3.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ReadStream } from "fs";
2-
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
2+
import { getSignedUrl as getS3SignedUrl } from "@aws-sdk/s3-request-presigner";
33
import {
44
S3Client,
55
PutObjectCommand,
@@ -13,7 +13,12 @@ import {
1313
cloudSecret,
1414
cloudBucket,
1515
cloudRegion,
16+
CLOUDFRONT_KEY_PAIR_ID,
17+
CLOUDFRONT_PRIVATE_KEY,
18+
CDN_MAX_AGE,
19+
CLOUDFRONT_ENDPOINT,
1620
} from "../config/constants";
21+
import { getSignedUrl as getCfSignedUrl } from "@aws-sdk/cloudfront-signer";
1722

1823
export interface UploadParams {
1924
Key: string;
@@ -65,14 +70,29 @@ export const getObjectTagging = async (params: { Key: string }) => {
6570
return response;
6671
};
6772

68-
export const generateSignedUrl = async ({
69-
name,
70-
mimetype,
71-
}: PresignedURLParams): Promise<string> => {
73+
export const generateSignedUrl = async (key: string): Promise<string> => {
7274
const command = new GetObjectCommand({
7375
Bucket: cloudBucket,
74-
Key: name,
76+
Key: key,
77+
});
78+
const url = await getS3SignedUrl(s3Client, command);
79+
return url;
80+
};
81+
82+
export const generateCDNSignedUrl = (key: string): string => {
83+
if (
84+
!CLOUDFRONT_ENDPOINT ||
85+
!CLOUDFRONT_KEY_PAIR_ID ||
86+
!CLOUDFRONT_PRIVATE_KEY
87+
) {
88+
throw new Error("CDN configuration is missing");
89+
}
90+
91+
const url = getCfSignedUrl({
92+
url: `${CLOUDFRONT_ENDPOINT}/${key}`,
93+
keyPairId: CLOUDFRONT_KEY_PAIR_ID,
94+
privateKey: CLOUDFRONT_PRIVATE_KEY,
95+
dateLessThan: new Date(Date.now() + CDN_MAX_AGE).toISOString(),
7596
});
76-
const url = await getSignedUrl(s3Client, command);
7797
return url;
7898
};

0 commit comments

Comments
 (0)