Skip to content

Commit d2efd8b

Browse files
authored
feat: switch to s3 storage (#926)
* feat: switch to s3 storage * chore: make env var names consistent * chore: make s3 loading lazy * feat: migrations * feat: fm migration * chore: move migrations * chore: s3 access issue
1 parent 3d61412 commit d2efd8b

File tree

25 files changed

+1934
-158
lines changed

25 files changed

+1934
-158
lines changed

platforms/esigner/api/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
"migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts"
1414
},
1515
"dependencies": {
16+
"@aws-sdk/client-s3": "^3.1009.0",
17+
"@aws-sdk/s3-request-presigner": "^3.1009.0",
1618
"axios": "^1.6.7",
1719
"cors": "^2.8.5",
1820
"dotenv": "^16.4.5",
@@ -23,9 +25,9 @@
2325
"multer": "^1.4.5-lts.1",
2426
"pg": "^8.11.3",
2527
"reflect-metadata": "^0.2.1",
28+
"signature-validator": "workspace:*",
2629
"typeorm": "^0.3.24",
2730
"uuid": "^9.0.1",
28-
"signature-validator": "workspace:*",
2931
"web3-adapter": "workspace:*"
3032
},
3133
"devDependencies": {
@@ -44,5 +46,3 @@
4446
"typescript": "^5.3.3"
4547
}
4648
}
47-
48-

platforms/esigner/api/src/controllers/FileController.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Request, Response } from "express";
22
import { FileService, ReservedFileNameError } from "../services/FileService";
33
import multer from "multer";
4+
import { v4 as uuidv4 } from "uuid";
45

56
export const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB limit
67

@@ -16,6 +17,80 @@ export class FileController {
1617
this.fileService = new FileService();
1718
}
1819

20+
presignUpload = async (req: Request, res: Response) => {
21+
try {
22+
if (!req.user) {
23+
return res.status(401).json({ error: "Authentication required" });
24+
}
25+
26+
const { filename, mimeType, size } = req.body;
27+
28+
if (!filename || !mimeType || !size) {
29+
return res.status(400).json({ error: "filename, mimeType, and size are required" });
30+
}
31+
32+
const fileId = uuidv4();
33+
const key = this.fileService.s3Service.generateKey(req.user.id, fileId, filename);
34+
const uploadUrl = await this.fileService.s3Service.generateUploadUrl(key, mimeType);
35+
36+
res.json({ uploadUrl, key, fileId });
37+
} catch (error) {
38+
console.error("Error generating presigned URL:", error);
39+
res.status(500).json({ error: "Failed to generate upload URL" });
40+
}
41+
};
42+
43+
confirmUpload = async (req: Request, res: Response) => {
44+
try {
45+
if (!req.user) {
46+
return res.status(401).json({ error: "Authentication required" });
47+
}
48+
49+
const { key, fileId, filename, mimeType, size, displayName, description } = req.body;
50+
51+
if (!key || !fileId || !filename || !mimeType || !size) {
52+
return res.status(400).json({ error: "key, fileId, filename, mimeType, and size are required" });
53+
}
54+
55+
const head = await this.fileService.s3Service.headObject(key);
56+
const md5Hash = head.etag;
57+
const url = this.fileService.s3Service.getPublicUrl(key);
58+
59+
const file = await this.fileService.createFileWithUrl(
60+
fileId,
61+
filename,
62+
mimeType,
63+
size,
64+
md5Hash,
65+
url,
66+
req.user.id,
67+
displayName,
68+
description,
69+
);
70+
71+
res.status(201).json({
72+
id: file.id,
73+
name: file.name,
74+
displayName: file.displayName,
75+
description: file.description,
76+
mimeType: file.mimeType,
77+
size: file.size,
78+
md5Hash: file.md5Hash,
79+
url: file.url,
80+
createdAt: file.createdAt,
81+
});
82+
} catch (error) {
83+
console.error("Error confirming upload:", error);
84+
if (error instanceof ReservedFileNameError) {
85+
return res.status(400).json({ error: error.message });
86+
}
87+
if (error instanceof Error) {
88+
return res.status(400).json({ error: error.message });
89+
}
90+
res.status(500).json({ error: "Failed to confirm upload" });
91+
}
92+
};
93+
1994
uploadFile = [
2095
upload.single('file'),
2196
async (req: Request, res: Response) => {
@@ -97,6 +172,7 @@ export class FileController {
97172
mimeType: file.mimeType,
98173
size: file.size,
99174
md5Hash: file.md5Hash,
175+
url: file.url,
100176
ownerId: file.ownerId,
101177
createdAt: file.createdAt,
102178
updatedAt: file.updatedAt,
@@ -158,6 +234,11 @@ export class FileController {
158234
return res.status(404).json({ error: "File not found" });
159235
}
160236

237+
if (file.url) {
238+
return res.redirect(file.url);
239+
}
240+
241+
// Legacy fallback for files still in DB
161242
res.setHeader('Content-Type', file.mimeType);
162243
res.setHeader('Content-Disposition', `attachment; filename="${file.name}"`);
163244
res.setHeader('Content-Length', file.size.toString());

platforms/esigner/api/src/controllers/WebhookController.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -299,21 +299,18 @@ export class WebhookController {
299299
file.md5Hash = local.data.md5Hash as string;
300300
file.ownerId = owner.id;
301301

302-
// Decode base64 data if provided
302+
// Store URL if provided
303+
if (local.data.url && typeof local.data.url === "string") {
304+
file.url = local.data.url;
305+
}
306+
// Legacy: decode base64 data if provided (backward compat)
303307
if (local.data.data && typeof local.data.data === "string") {
304308
file.data = Buffer.from(local.data.data, "base64");
305309
}
306310

307311
this.adapter.addToLockedIds(localId);
308312
await this.fileRepository.save(file);
309313
} else {
310-
// Create new file with binary data
311-
// Decode base64 data if provided
312-
let fileData: Buffer = Buffer.alloc(0);
313-
if (local.data.data && typeof local.data.data === "string") {
314-
fileData = Buffer.from(local.data.data, "base64");
315-
}
316-
317314
const file = this.fileRepository.create({
318315
name: local.data.name as string,
319316
displayName: local.data.displayName as string | null,
@@ -322,7 +319,8 @@ export class WebhookController {
322319
size: local.data.size as number,
323320
md5Hash: local.data.md5Hash as string,
324321
ownerId: owner.id,
325-
data: fileData,
322+
url: (local.data.url as string) || null,
323+
data: local.data.data ? Buffer.from(local.data.data as string, "base64") : null,
326324
});
327325

328326
this.adapter.addToLockedIds(file.id);

platforms/esigner/api/src/database/entities/File.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ export class File {
3535
@Column({ type: "text" })
3636
md5Hash!: string;
3737

38-
@Column({ type: "bytea" })
39-
data!: Buffer;
38+
@Column({ type: "bytea", nullable: true })
39+
data!: Buffer | null;
40+
41+
@Column({ type: "text", nullable: true })
42+
url!: string | null;
4043

4144
@Column()
4245
ownerId!: string;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class Addurl1773657072411 implements MigrationInterface {
4+
name = 'Addurl1773657072411'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`ALTER TABLE "files" ADD "url" text`);
8+
await queryRunner.query(`ALTER TABLE "files" ALTER COLUMN "data" DROP NOT NULL`);
9+
}
10+
11+
public async down(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.query(`ALTER TABLE "files" ALTER COLUMN "data" SET NOT NULL`);
13+
await queryRunner.query(`ALTER TABLE "files" DROP COLUMN "url"`);
14+
}
15+
16+
}

platforms/esigner/api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ app.post("/api/webhook", webhookController.handleWebhook);
8383
app.use(authMiddleware);
8484

8585
// File routes
86+
app.post("/api/files/presign", authGuard, fileController.presignUpload);
87+
app.post("/api/files/confirm", authGuard, fileController.confirmUpload);
8688
app.post("/api/files", authGuard, fileController.uploadFile);
8789
app.get("/api/files", authGuard, fileController.getFiles);
8890
app.get("/api/files/:id", authGuard, fileController.getFile);

platforms/esigner/api/src/services/FileService.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AppDataSource } from "../database/data-source";
22
import { File } from "../database/entities/File";
33
import { FileSignee } from "../database/entities/FileSignee";
44
import { SignatureContainer } from "../database/entities/SignatureContainer";
5+
import { S3Service } from "./S3Service";
56
import crypto from "crypto";
67

78
/** Soft-deleted marker from File Manager (no delete webhook); hide these in eSigner. */
@@ -19,6 +20,7 @@ export class FileService {
1920
private fileRepository = AppDataSource.getRepository(File);
2021
private fileSigneeRepository = AppDataSource.getRepository(FileSignee);
2122
private signatureRepository = AppDataSource.getRepository(SignatureContainer);
23+
public s3Service = new S3Service();
2224

2325
/**
2426
* Validates that the given filename is not the reserved soft-delete sentinel.
@@ -67,6 +69,39 @@ export class FileService {
6769
return savedFile;
6870
}
6971

72+
async createFileWithUrl(
73+
id: string,
74+
name: string,
75+
mimeType: string,
76+
size: number,
77+
md5Hash: string,
78+
url: string,
79+
ownerId: string,
80+
displayName?: string,
81+
description?: string
82+
): Promise<File> {
83+
this.validateFileName(name);
84+
85+
const fileData: Partial<File> = {
86+
id,
87+
name,
88+
displayName: displayName || name,
89+
mimeType,
90+
size,
91+
md5Hash,
92+
url,
93+
ownerId,
94+
};
95+
96+
if (description !== undefined) {
97+
fileData.description = description || null;
98+
}
99+
100+
const file = this.fileRepository.create(fileData);
101+
const savedFile = await this.fileRepository.save(file);
102+
return savedFile;
103+
}
104+
70105
async getFileById(id: string, userId?: string): Promise<File | null> {
71106
const file = await this.fileRepository.findOne({
72107
where: { id },
@@ -174,6 +209,7 @@ export class FileService {
174209
mimeType: file.mimeType,
175210
size: file.size,
176211
md5Hash: file.md5Hash,
212+
url: file.url,
177213
ownerId: file.ownerId,
178214
owner: file.owner ? {
179215
id: file.owner.id,
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
2+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
3+
import { Readable } from "stream";
4+
5+
export class S3Service {
6+
private _client: S3Client | null = null;
7+
private _bucket: string = "";
8+
private _region: string = "";
9+
private _cdnEndpoint: string = "";
10+
private _originEndpoint: string = "";
11+
private _initialized = false;
12+
13+
private init() {
14+
if (this._initialized) return;
15+
this._initialized = true;
16+
17+
this._originEndpoint = process.env.S3_ORIGIN || "";
18+
this._cdnEndpoint = process.env.S3_CDN || "";
19+
20+
if (!this._originEndpoint) {
21+
console.warn("S3_ORIGIN not set — S3 features will not work");
22+
return;
23+
}
24+
25+
const originUrl = new URL(this._originEndpoint);
26+
const hostParts = originUrl.hostname.split(".");
27+
this._bucket = hostParts[0];
28+
this._region = hostParts[1];
29+
30+
this._client = new S3Client({
31+
endpoint: `https://${this._region}.digitaloceanspaces.com`,
32+
region: this._region,
33+
credentials: {
34+
accessKeyId: process.env.S3_ACCESS_KEY || "",
35+
secretAccessKey: process.env.S3_SECRET_KEY || "",
36+
},
37+
forcePathStyle: false,
38+
});
39+
}
40+
41+
private get client(): S3Client {
42+
this.init();
43+
if (!this._client) throw new Error("S3 not configured — set S3_ORIGIN env var");
44+
return this._client;
45+
}
46+
47+
private get bucket(): string {
48+
this.init();
49+
return this._bucket;
50+
}
51+
52+
generateKey(userId: string, fileId: string, filename: string): string {
53+
return `files/${userId}/${fileId}/${filename}`;
54+
}
55+
56+
async generateUploadUrl(key: string, contentType: string): Promise<string> {
57+
const command = new PutObjectCommand({
58+
Bucket: this.bucket,
59+
Key: key,
60+
ContentType: contentType,
61+
ACL: "public-read",
62+
});
63+
64+
return getSignedUrl(this.client, command, { expiresIn: 900 });
65+
}
66+
67+
getPublicUrl(key: string): string {
68+
this.init();
69+
return `${this._cdnEndpoint}/${key}`;
70+
}
71+
72+
extractKeyFromUrl(url: string): string {
73+
this.init();
74+
if (this._cdnEndpoint && url.startsWith(this._cdnEndpoint)) {
75+
return url.slice(this._cdnEndpoint.length + 1);
76+
}
77+
if (this._originEndpoint && url.startsWith(this._originEndpoint)) {
78+
return url.slice(this._originEndpoint.length + 1);
79+
}
80+
const urlObj = new URL(url);
81+
return urlObj.pathname.slice(1);
82+
}
83+
84+
async headObject(key: string): Promise<{ contentLength: number; etag: string }> {
85+
const command = new HeadObjectCommand({
86+
Bucket: this.bucket,
87+
Key: key,
88+
});
89+
90+
const response = await this.client.send(command);
91+
return {
92+
contentLength: response.ContentLength || 0,
93+
etag: (response.ETag || "").replace(/"/g, ""),
94+
};
95+
}
96+
97+
async deleteObject(key: string): Promise<void> {
98+
const command = new DeleteObjectCommand({
99+
Bucket: this.bucket,
100+
Key: key,
101+
});
102+
await this.client.send(command);
103+
}
104+
105+
async getObjectStream(key: string): Promise<Readable> {
106+
const command = new GetObjectCommand({
107+
Bucket: this.bucket,
108+
Key: key,
109+
});
110+
111+
const response = await this.client.send(command);
112+
return response.Body as Readable;
113+
}
114+
}

platforms/esigner/api/src/web3adapter/mappings/file.mapping.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"size": "size",
1212
"md5Hash": "md5Hash",
1313
"data": "data",
14+
"url": "url",
1415
"ownerId": "users(owner.id),ownerId",
1516
"createdAt": "__date(createdAt)",
1617
"updatedAt": "__date(updatedAt)"

0 commit comments

Comments
 (0)