Skip to content

Commit c1ea56a

Browse files
author
Rajat Saxena
committed
WIP: tus routes; upload creation done; upload offset is getting updated in the db
1 parent 07375b9 commit c1ea56a

File tree

18 files changed

+1436
-22
lines changed

18 files changed

+1436
-22
lines changed

apps/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
"@medialit/models": "workspace:*",
3939
"@medialit/thumbnail": "workspace:*",
4040
"@medialit/utils": "workspace:^0.1.0",
41+
"@tus/file-store": "^2.0.0",
42+
"@tus/server": "^2.3.0",
4143
"aws-sdk": "^2.1692.0",
4244
"cors": "^2.8.5",
4345
"dotenv": "^16.4.7",

apps/api/src/apikey/middleware.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default async function apikey(
1515
const reqKey = req.body.apikey;
1616

1717
if (!reqKey) {
18+
console.log("API key missing in request");
1819
return res.status(400).json({ error: BAD_REQUEST });
1920
}
2021

apps/api/src/config/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,11 @@ export const CDN_MAX_AGE = process.env.CDN_MAX_AGE
6262

6363
export const ENDPOINT = USE_CLOUDFRONT ? CLOUDFRONT_ENDPOINT : S3_ENDPOINT;
6464
export const HOSTNAME_OVERRIDE = process.env.HOSTNAME_OVERRIDE || ""; // Useful for hosting via Docker
65+
66+
// Tus upload config
67+
export const TUS_UPLOAD_EXPIRATION_HOURS = parseInt(
68+
process.env.TUS_UPLOAD_EXPIRATION_HOURS || "48",
69+
);
70+
export const TUS_CHUNK_SIZE = parseInt(
71+
process.env.TUS_CHUNK_SIZE || "10485760",
72+
); // 10MB default

apps/api/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import passport from "passport";
77
import mediaRoutes from "./media/routes";
88
import presignedUrlRoutes from "./presigning/routes";
99
import mediaSettingsRoutes from "./media-settings/routes";
10+
import tusRoutes from "./tus/routes";
1011
import logger from "./services/log";
1112
import { createUser, findByEmail } from "./user/queries";
1213
import { Apikey, Constants, User } from "@medialit/models";
1314
import { createApiKey } from "./apikey/queries";
1415
import { spawn } from "child_process";
16+
import { cleanupExpiredTusUploads } from "./tus/queries";
1517

1618
connectToDatabase();
1719
const app = express();
@@ -29,7 +31,9 @@ app.get("/health", (req, res) => {
2931

3032
app.use("/settings/media", mediaSettingsRoutes(passport));
3133
app.use("/media/presigned", presignedUrlRoutes);
34+
app.use("/media", tusRoutes);
3235
app.use("/media", mediaRoutes);
36+
// Mount TUS routes under /media so paths like /media/create/tus match correctly
3337

3438
const port = process.env.PORT || 80;
3539

@@ -41,6 +45,14 @@ checkDependencies().then(() => {
4145
app.listen(port, () => {
4246
logger.info(`Medialit server running at ${port}`);
4347
});
48+
49+
// Setup background cleanup job for expired tus uploads
50+
setInterval(
51+
async () => {
52+
await cleanupExpiredTusUploads();
53+
},
54+
1000 * 60 * 60,
55+
); // Run every hour
4456
});
4557

4658
async function checkDependencies() {

apps/api/src/presigning/handlers.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,30 @@ export async function getPresignedUrl(
3636
return res.status(500).json(err.message);
3737
}
3838
}
39+
40+
export async function getPresignedTusUrl(
41+
req: any,
42+
res: any,
43+
next: (...args: any[]) => void,
44+
) {
45+
const { error } = validatePresigningOptions(req);
46+
if (error) {
47+
return res.status(400).json({ error: error.message });
48+
}
49+
50+
try {
51+
const presignedUrl = await preSignedUrlService.generateSignedUrlForTus({
52+
userId: req.user.id,
53+
apikey: req.apikey,
54+
protocol: req.protocol,
55+
host: HOSTNAME_OVERRIDE || req.get("Host"),
56+
group: req.body.group,
57+
});
58+
// Extract signature from the URL
59+
const signature = presignedUrl.split("signature=")[1];
60+
return res.status(200).json({ signature });
61+
} catch (err: any) {
62+
logger.error({ err }, err.message);
63+
return res.status(500).json(err.message);
64+
}
65+
}

apps/api/src/presigning/routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import express from "express";
22
import apikey from "../apikey/middleware";
3-
import { getPresignedUrl } from "./handlers";
3+
import { getPresignedUrl, getPresignedTusUrl } from "./handlers";
44

55
const router = express.Router();
66
router.post("/create", apikey, getPresignedUrl);
7+
router.post("/tus/create", apikey, getPresignedTusUrl);
78

89
export default router;

apps/api/src/presigning/service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,26 @@ export async function cleanup(userId: string, signature: string) {
6969
await queries.deleteBySignature(signature);
7070
await queries.cleanupExpiredLinks(userId);
7171
}
72+
73+
export async function generateSignedUrlForTus({
74+
userId,
75+
apikey,
76+
protocol,
77+
host,
78+
group,
79+
}: GenerateSignedUrlProps): Promise<string> {
80+
const presignedUrl = await queries.createPresignedUrl(
81+
userId,
82+
apikey,
83+
group,
84+
);
85+
86+
queries.cleanupExpiredLinks(userId).catch((err: any) => {
87+
logger.error(
88+
{ err },
89+
`Error while cleaning up expired links for ${userId}`,
90+
);
91+
});
92+
93+
return `${protocol}://${host}/media/create/tus?signature=${presignedUrl?.signature}`;
94+
}

apps/api/src/tus/finalize.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { readFileSync, createReadStream, rmdirSync, existsSync } from "fs";
2+
import path from "path";
3+
import thumbnail from "@medialit/thumbnail";
4+
import mongoose from "mongoose";
5+
import {
6+
tempFileDirForUploads,
7+
imagePattern,
8+
imagePatternForThumbnailGeneration,
9+
videoPattern,
10+
USE_CLOUDFRONT,
11+
} from "../config/constants";
12+
import imageUtils from "@medialit/images";
13+
import {
14+
foldersExist,
15+
createFolders,
16+
} from "../media/utils/manage-files-on-disk";
17+
import type { MediaWithUserId } from "../media/model";
18+
import { putObject, UploadParams } from "../services/s3";
19+
import logger from "../services/log";
20+
import generateKey from "../media/utils/generate-key";
21+
import { getMediaSettings } from "../media-settings/queries";
22+
import generateFileName from "../media/utils/generate-file-name";
23+
import { createMedia } from "../media/queries";
24+
import getTags from "../media/utils/get-tags";
25+
import { getTusUpload, markTusUploadComplete } from "./queries";
26+
import * as presignedUrlService from "../presigning/service";
27+
28+
const generateAndUploadThumbnail = async ({
29+
workingDirectory,
30+
key,
31+
mimetype,
32+
originalFilePath,
33+
tags,
34+
}: {
35+
workingDirectory: string;
36+
key: string;
37+
mimetype: string;
38+
originalFilePath: string;
39+
tags: string;
40+
}): Promise<boolean> => {
41+
const thumbPath = `${workingDirectory}/thumb.webp`;
42+
43+
let isThumbGenerated = false;
44+
if (imagePatternForThumbnailGeneration.test(mimetype)) {
45+
await thumbnail.forImage(originalFilePath, thumbPath);
46+
isThumbGenerated = true;
47+
}
48+
if (videoPattern.test(mimetype)) {
49+
await thumbnail.forVideo(originalFilePath, thumbPath);
50+
isThumbGenerated = true;
51+
}
52+
53+
if (isThumbGenerated) {
54+
await putObject({
55+
Key: key,
56+
Body: createReadStream(thumbPath),
57+
ContentType: "image/webp",
58+
ACL: USE_CLOUDFRONT ? "private" : "public-read",
59+
Tagging: tags,
60+
});
61+
}
62+
63+
return isThumbGenerated;
64+
};
65+
66+
export default async function finalizeUpload(uploadId: string) {
67+
logger.info({ uploadId }, "Finalizing tus upload");
68+
69+
const tusUpload = await getTusUpload(uploadId);
70+
if (!tusUpload) {
71+
throw new Error(`Tus upload not found: ${uploadId}`);
72+
}
73+
74+
if (tusUpload.isComplete) {
75+
logger.info({ uploadId }, "Upload already finalized");
76+
return;
77+
}
78+
79+
const { userId, apikey, metadata, uploadLength, tempFilePath, signature } =
80+
tusUpload;
81+
82+
// Read the completed file from tus data store
83+
const tusFilePath = path.join(
84+
`${tempFileDirForUploads}/tus-uploads`,
85+
tempFilePath,
86+
);
87+
88+
if (!existsSync(tusFilePath)) {
89+
logger.error({ uploadId, tusFilePath }, "Tus file not found");
90+
throw new Error(`Tus file not found: ${tusFilePath}`);
91+
}
92+
93+
const mediaSettings = await getMediaSettings(userId, apikey);
94+
const useWebP = mediaSettings?.useWebP || false;
95+
const webpOutputQuality = mediaSettings?.webpOutputQuality || 0;
96+
97+
// Generate unique media ID
98+
const fileName = generateFileName(metadata.filename);
99+
const temporaryFolderForWork = `${tempFileDirForUploads}/${fileName.name}`;
100+
if (!foldersExist([temporaryFolderForWork])) {
101+
createFolders([temporaryFolderForWork]);
102+
}
103+
104+
let fileExtension = path.extname(metadata.filename).replace(".", "");
105+
let mimeType = metadata.mimetype;
106+
if (useWebP && imagePattern.test(mimeType)) {
107+
fileExtension = "webp";
108+
mimeType = "image/webp";
109+
}
110+
111+
const mainFilePath = `${temporaryFolderForWork}/main.${fileExtension}`;
112+
113+
// Copy file from tus store to working directory
114+
const tusFileContent = readFileSync(tusFilePath);
115+
require("fs").writeFileSync(mainFilePath, tusFileContent);
116+
117+
// Apply WebP conversion if needed
118+
if (useWebP && imagePattern.test(metadata.mimetype)) {
119+
await imageUtils.convertToWebp(mainFilePath, webpOutputQuality);
120+
}
121+
122+
const uploadParams: UploadParams = {
123+
Key: generateKey({
124+
mediaId: fileName.name,
125+
access: metadata.access === "public" ? "public" : "private",
126+
filename: `main.${fileExtension}`,
127+
}),
128+
Body: createReadStream(mainFilePath),
129+
ContentType: mimeType,
130+
ACL: USE_CLOUDFRONT
131+
? "private"
132+
: metadata.access === "public"
133+
? "public-read"
134+
: "private",
135+
};
136+
const tags = getTags(userId, metadata.group);
137+
uploadParams.Tagging = tags;
138+
139+
await putObject(uploadParams);
140+
141+
let isThumbGenerated = false;
142+
try {
143+
isThumbGenerated = await generateAndUploadThumbnail({
144+
workingDirectory: temporaryFolderForWork,
145+
mimetype: metadata.mimetype,
146+
originalFilePath: mainFilePath,
147+
key: generateKey({
148+
mediaId: fileName.name,
149+
access: "public",
150+
filename: "thumb.webp",
151+
}),
152+
tags,
153+
});
154+
} catch (err: any) {
155+
logger.error({ err }, err.message);
156+
}
157+
158+
rmdirSync(temporaryFolderForWork, { recursive: true });
159+
160+
const mediaObject: MediaWithUserId = {
161+
fileName: `main.${fileExtension}`,
162+
mediaId: fileName.name,
163+
userId: new mongoose.Types.ObjectId(userId),
164+
apikey,
165+
originalFileName: metadata.filename,
166+
mimeType,
167+
size: uploadLength,
168+
thumbnailGenerated: isThumbGenerated,
169+
caption: metadata.caption,
170+
accessControl: metadata.access === "public" ? "public-read" : "private",
171+
group: metadata.group,
172+
};
173+
const media = await createMedia(mediaObject);
174+
175+
// Mark upload as complete
176+
await markTusUploadComplete(uploadId);
177+
178+
// Cleanup presigned URL if used
179+
if (signature) {
180+
presignedUrlService.cleanup(userId, signature).catch((err: any) => {
181+
logger.error(
182+
{ err },
183+
`Error in cleaning up expired links for ${userId}`,
184+
);
185+
});
186+
}
187+
188+
// Cleanup tus file
189+
try {
190+
if (existsSync(tusFilePath)) {
191+
require("fs").unlinkSync(tusFilePath);
192+
}
193+
} catch (err) {
194+
logger.error({ err }, "Error cleaning up tus file");
195+
}
196+
197+
logger.info(
198+
{ uploadId, mediaId: media.mediaId },
199+
"Tus upload finalized successfully",
200+
);
201+
202+
return media.mediaId;
203+
}

apps/api/src/tus/model.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Media } from "@medialit/models";
2+
import mongoose from "mongoose";
3+
4+
export interface TusUpload {
5+
uploadId: string;
6+
userId: string;
7+
apikey: string;
8+
uploadLength: number;
9+
uploadOffset: number;
10+
metadata: Pick<
11+
Media,
12+
"fileName" | "mimeType" | "accessControl" | "caption" | "group"
13+
>;
14+
tempFilePath: string;
15+
isComplete: boolean;
16+
expiresAt?: Date;
17+
signature?: string;
18+
}
19+
20+
const TusUploadSchema = new mongoose.Schema<TusUpload>(
21+
{
22+
uploadId: { type: String, required: true, unique: true },
23+
userId: { type: String, required: true },
24+
apikey: { type: String, required: true },
25+
uploadLength: { type: Number, required: true },
26+
uploadOffset: { type: Number, required: true, default: 0 },
27+
metadata: {
28+
fileName: { type: String, required: true },
29+
mimeType: { type: String, required: true },
30+
accessControl: { type: String, required: true, default: "private" },
31+
caption: String,
32+
group: String,
33+
},
34+
signature: String,
35+
tempFilePath: String,
36+
isComplete: Boolean,
37+
expiresAt: Date,
38+
},
39+
{ timestamps: true },
40+
);
41+
42+
export default mongoose.models.TusUpload ||
43+
mongoose.model("TusUpload", TusUploadSchema);

0 commit comments

Comments
 (0)