Skip to content

Commit 7477639

Browse files
committed
feat(storage): add AWS S3 and MinIO storage provider support
Add native support for AWS S3 and S3-compatible storage services (MinIO, DigitalOcean Spaces, Backblaze B2) as an alternative to existing local and Cloudflare R2 storage options. New features: - Generic 's3' storage provider using AWS SDK v3 - Support for custom endpoints (MinIO/self-hosted S3-compatible services) - Multipart upload support for large files (videos up to 1GB) - Date-based directory organization (YYYY/MM/DD/filename) - Automatic file deletion from storage when media is deleted Bug fixes included: - Enable actual file deletion from Cloudflare R2 (was no-op) - Add file deletion from local storage - Fix URL trailing slash handling for all providers - Add null checks and error handling for edge cases - Add fallback for unrecognized MIME types New environment variables: - S3_ENDPOINT (optional, for MinIO) - S3_ACCESS_KEY - S3_SECRET_KEY - S3_BUCKET - S3_REGION - S3_BUCKET_URL (optional) Fully backward compatible - existing local and cloudflare providers unchanged.
1 parent 41090bd commit 7477639

File tree

16 files changed

+693
-51
lines changed

16 files changed

+693
-51
lines changed

.env.example

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ CLOUDFLARE_REGION="auto"
3030
#EMAIL_FROM_NAME=""
3131
#DISABLE_REGISTRATION=false
3232

33-
# Where will social media icons be saved - local or cloudflare.
33+
# Where will social media icons be saved - local, cloudflare, or s3.
3434
STORAGE_PROVIDER="local"
3535

3636
# Your upload directory path if you host your files locally, otherwise Cloudflare will be used.
@@ -39,6 +39,13 @@ STORAGE_PROVIDER="local"
3939
# Your upload directory path if you host your files locally, otherwise Cloudflare will be used.
4040
#NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY=""
4141

42+
# S3/MinIO Storage (use STORAGE_PROVIDER="s3")
43+
#S3_ENDPOINT="" # Only for MinIO (e.g., "https://minio.example.com")
44+
#S3_ACCESS_KEY=""
45+
#S3_SECRET_KEY=""
46+
#S3_BUCKET=""
47+
#S3_REGION="us-east-1"
48+
4249
# Social Media API Settings
4350
X_API_KEY=""
4451
X_API_SECRET=""
@@ -117,4 +124,4 @@ POSTIZ_OAUTH_CLIENT_SECRET=""
117124

118125
# LINK_DRIP_API_KEY="" # Your LinkDrip API key
119126
# LINK_DRIP_API_ENDPOINT="https://api.linkdrip.com/v1/" # Your self-hosted LinkDrip API endpoint
120-
# LINK_DRIP_SHORT_LINK_DOMAIN="dripl.ink" # Your self-hosted LinkDrip domain
127+
# LINK_DRIP_SHORT_LINK_DOMAIN="dripl.ink" # Your self-hosted LinkDrip domain

apps/backend/src/api/routes/media.controller.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,34 @@ import { Organization } from '@prisma/client';
1818
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
1919
import { ApiTags } from '@nestjs/swagger';
2020
import handleR2Upload from '@gitroom/nestjs-libraries/upload/r2.uploader';
21+
import {
22+
S3Uploader,
23+
createS3Uploader,
24+
} from '@gitroom/nestjs-libraries/upload/s3.uploader';
2125
import { FileInterceptor } from '@nestjs/platform-express';
2226
import { CustomFileValidationPipe } from '@gitroom/nestjs-libraries/upload/custom.upload.validation';
2327
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
2428
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
2529
import { SaveMediaInformationDto } from '@gitroom/nestjs-libraries/dtos/media/save.media.information.dto';
2630
import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto';
2731
import { VideoFunctionDto } from '@gitroom/nestjs-libraries/dtos/videos/video.function.dto';
32+
import { getCurrentBucketUrl } from '@gitroom/nestjs-libraries/upload/s3.utils';
2833

2934
@ApiTags('Media')
3035
@Controller('/media')
3136
export class MediaController {
3237
private storage = UploadFactory.createStorage();
38+
private s3Uploader: S3Uploader | null = null;
39+
3340
constructor(
3441
private _mediaService: MediaService,
3542
private _subscriptionService: SubscriptionService
36-
) {}
43+
) {
44+
// Initialize S3 uploader if using S3 provider
45+
if (process.env.STORAGE_PROVIDER === 's3') {
46+
this.s3Uploader = createS3Uploader();
47+
}
48+
}
3749

3850
@Delete('/:id')
3951
deleteMedia(@GetOrgFromRequest() org: Organization, @Param('id') id: string) {
@@ -108,11 +120,9 @@ export class MediaController {
108120
if (!name) {
109121
return false;
110122
}
111-
return this._mediaService.saveFile(
112-
org.id,
113-
name,
114-
process.env.CLOUDFLARE_BUCKET_URL + '/' + name
115-
);
123+
// Use appropriate bucket URL based on storage provider
124+
const bucketUrl = getCurrentBucketUrl();
125+
return this._mediaService.saveFile(org.id, name, bucketUrl + '/' + name);
116126
}
117127

118128
@Post('/information')
@@ -151,11 +161,27 @@ export class MediaController {
151161
@Res() res: Response,
152162
@Param('endpoint') endpoint: string
153163
) {
154-
const upload = await handleR2Upload(endpoint, req, res);
164+
// Route to appropriate handler based on storage provider
165+
let upload;
166+
if (process.env.STORAGE_PROVIDER === 's3' && this.s3Uploader) {
167+
upload = await this.s3Uploader.handleUpload(endpoint, req, res);
168+
} else {
169+
upload = await handleR2Upload(endpoint, req, res);
170+
}
171+
155172
if (endpoint !== 'complete-multipart-upload') {
156173
return upload;
157174
}
158175

176+
// Check if response was already sent (e.g., error handler sent it)
177+
if (res.headersSent) {
178+
return;
179+
}
180+
181+
// @ts-ignore - upload is CompleteMultipartUploadCommandOutput here
182+
if (!upload?.Location) {
183+
return res.status(500).json({ error: 'Upload failed - no location returned' });
184+
}
159185
// @ts-ignore
160186
const name = upload.Location.split('/').pop();
161187

apps/frontend/src/app/(app)/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
4747
>
4848
<VariableContextComponent
4949
storageProvider={
50-
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'
50+
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare' | 's3'
5151
}
5252
environment={process.env.NODE_ENV!}
5353
backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!}

apps/frontend/src/app/(extension)/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
2525
<VariableContextComponent
2626
language="en"
2727
storageProvider={
28-
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'
28+
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare' | 's3'
2929
}
3030
environment={process.env.NODE_ENV!}
3131
backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!}

apps/frontend/src/components/media/new.uploader.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,11 @@ export function useUppyUploader(props: {
192192
uppy2.on('file-added', (file) => {
193193
setLocked(true);
194194
uppy2.setFileMeta(file.id, {
195-
useCloudflare: storageProvider === 'cloudflare' ? 'true' : 'false', // Example of adding a custom field
196-
// Add more fields as needed
195+
// Both cloudflare and s3 use multipart upload
196+
useCloudflare:
197+
storageProvider === 'cloudflare' || storageProvider === 's3'
198+
? 'true'
199+
: 'false',
197200
});
198201
});
199202
uppy2.on('error', (result) => {

libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
66
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
77
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
88
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
9+
import { isStorageUrl } from '@gitroom/nestjs-libraries/upload/s3.utils';
910

1011
@Injectable()
1112
export class IntegrationRepository {
@@ -143,11 +144,7 @@ export class IntegrationRepository {
143144
}
144145

145146
async updateIntegration(id: string, params: Partial<Integration>) {
146-
if (
147-
params.picture &&
148-
(params.picture.indexOf(process.env.CLOUDFLARE_BUCKET_URL!) === -1 ||
149-
params.picture.indexOf(process.env.FRONTEND_URL!) === -1)
150-
) {
147+
if (params.picture && !isStorageUrl(params.picture)) {
151148
params.picture = await this.storage.uploadSimple(params.picture);
152149
}
153150

libraries/nestjs-libraries/src/database/prisma/media/media.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ export class MediaService {
2525
) {}
2626

2727
async deleteMedia(org: string, id: string) {
28+
// Get the media record to get the file path
29+
const media = await this._mediaRepository.getMediaById(id);
30+
31+
// Delete from storage if file path exists
32+
if (media?.path) {
33+
try {
34+
await this.storage.removeFile(media.path);
35+
} catch (err) {
36+
console.error('Error removing file from storage:', err);
37+
// Continue with soft delete even if storage removal fails
38+
}
39+
}
40+
2841
return this._mediaRepository.deleteMedia(org, id);
2942
}
3043

libraries/nestjs-libraries/src/upload/cloudflare.storage.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
1+
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
22
import 'multer';
33
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
44
import mime from 'mime-types';
@@ -9,15 +9,19 @@ import axios from 'axios';
99

1010
class CloudflareStorage implements IUploadProvider {
1111
private _client: S3Client;
12+
private _uploadUrl: string;
1213

1314
constructor(
1415
accountID: string,
1516
accessKey: string,
1617
secretKey: string,
1718
private region: string,
1819
private _bucketName: string,
19-
private _uploadUrl: string
20+
uploadUrl: string
2021
) {
22+
// Normalize URL to remove trailing slash
23+
this._uploadUrl = uploadUrl.replace(/\/$/, '');
24+
2125
this._client = new S3Client({
2226
endpoint: `https://${accountID}.r2.cloudflarestorage.com`,
2327
region,
@@ -62,7 +66,8 @@ class CloudflareStorage implements IUploadProvider {
6266
const contentType =
6367
loadImage?.headers?.get('content-type') ||
6468
loadImage?.headers?.get('Content-Type');
65-
const extension = getExtension(contentType)!;
69+
// Fallback to 'bin' if MIME type is unrecognized, or try to extract from URL
70+
const extension = getExtension(contentType) || path.split('.').pop()?.split('?')[0] || 'bin';
6671
const id = makeId(10);
6772

6873
const params = {
@@ -112,14 +117,15 @@ class CloudflareStorage implements IUploadProvider {
112117
}
113118
}
114119

115-
// Implement the removeFile method from IUploadProvider
116120
async removeFile(filePath: string): Promise<void> {
117-
// const fileName = filePath.split('/').pop(); // Extract the filename from the path
118-
// const command = new DeleteObjectCommand({
119-
// Bucket: this._bucketName,
120-
// Key: fileName,
121-
// });
122-
// await this._client.send(command);
121+
const fileName = filePath.split('/').pop();
122+
if (!fileName) return;
123+
124+
const command = new DeleteObjectCommand({
125+
Bucket: this._bucketName,
126+
Key: fileName,
127+
});
128+
await this._client.send(command);
123129
}
124130
}
125131

libraries/nestjs-libraries/src/upload/local.storage.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export class LocalStorage implements IUploadProvider {
1313
const contentType =
1414
loadImage?.headers?.['content-type'] ||
1515
loadImage?.headers?.['Content-Type'];
16-
const findExtension = mime.getExtension(contentType)!;
16+
// Fallback to 'bin' if MIME type is unrecognized, or try to extract from URL
17+
const findExtension = mime.getExtension(contentType) || path.split('.').pop()?.split('?')[0] || 'bin';
1718

1819
const now = new Date();
1920
const year = now.getFullYear();
@@ -74,15 +75,44 @@ export class LocalStorage implements IUploadProvider {
7475
}
7576

7677
async removeFile(filePath: string): Promise<void> {
77-
// Logic to remove the file from the filesystem goes here
78-
return new Promise((resolve, reject) => {
79-
unlink(filePath, (err) => {
80-
if (err) {
81-
reject(err);
82-
} else {
83-
resolve();
84-
}
78+
// Convert URL to filesystem path
79+
// Input: http://localhost:4200/uploads/2025/01/15/abc123.png
80+
// Output: /upload-directory/2025/01/15/abc123.png
81+
try {
82+
const url = new URL(filePath);
83+
const pathAfterUploads = url.pathname.replace(/^\/uploads/, '');
84+
const fsPath = `${this.uploadDirectory}${pathAfterUploads}`;
85+
86+
return new Promise((resolve, reject) => {
87+
unlink(fsPath, (err) => {
88+
if (err) {
89+
// Don't reject if file doesn't exist
90+
if (err.code === 'ENOENT') {
91+
console.log(`File not found (already deleted?): ${fsPath}`);
92+
resolve();
93+
} else {
94+
console.error(`Error deleting file from local storage: ${fsPath}`, err);
95+
reject(err);
96+
}
97+
} else {
98+
console.log(`Successfully deleted file: ${fsPath}`);
99+
resolve();
100+
}
101+
});
85102
});
86-
});
103+
} catch (parseError) {
104+
console.error(`Error parsing file URL: ${filePath}`, parseError);
105+
// If filePath is not a valid URL, try it as a direct path
106+
return new Promise((resolve, reject) => {
107+
unlink(filePath, (err) => {
108+
if (err && err.code !== 'ENOENT') {
109+
console.error(`Error deleting file: ${filePath}`, err);
110+
reject(err);
111+
} else {
112+
resolve();
113+
}
114+
});
115+
});
116+
}
87117
}
88118
}

libraries/nestjs-libraries/src/upload/r2.uploader.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ export async function simpleUpload(
7575
const command = new PutObjectCommand({ ...params });
7676
await R2.send(command);
7777

78-
return CLOUDFLARE_BUCKET_URL + '/' + randomFilename;
78+
const bucketUrl = (CLOUDFLARE_BUCKET_URL || '').replace(/\/$/, '');
79+
return `${bucketUrl}/${randomFilename}`;
7980
}
8081

8182
export async function createMultipartUpload(req: Request, res: Response) {
@@ -159,24 +160,16 @@ export async function completeMultipartUpload(req: Request, res: Response) {
159160
const { key, uploadId, parts } = req.body;
160161

161162
try {
162-
const params = {
163-
Bucket: CLOUDFLARE_BUCKETNAME,
164-
Key: key,
165-
UploadId: uploadId,
166-
MultipartUpload: { Parts: parts },
167-
};
168-
169163
const command = new CompleteMultipartUploadCommand({
170164
Bucket: CLOUDFLARE_BUCKETNAME,
171165
Key: key,
172166
UploadId: uploadId,
173167
MultipartUpload: { Parts: parts },
174168
});
175169
const response = await R2.send(command);
176-
response.Location =
177-
process.env.CLOUDFLARE_BUCKET_URL +
178-
'/' +
179-
response?.Location?.split('/').at(-1);
170+
// Use the key from request (reliable) instead of parsing Location (fragile)
171+
const bucketUrl = (process.env.CLOUDFLARE_BUCKET_URL || '').replace(/\/$/, '');
172+
response.Location = `${bucketUrl}/${key}`;
180173
return response;
181174
} catch (err) {
182175
console.log('Error', err);

0 commit comments

Comments
 (0)