Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ CLOUDFLARE_BUCKETNAME="your-bucket-name"
CLOUDFLARE_BUCKET_URL="https://your-bucket-url.r2.cloudflarestorage.com/"
CLOUDFLARE_REGION="auto"

## S3 / S3-compatible storage (AWS S3, MinIO, DigitalOcean Spaces, Backblaze B2)
## Set STORAGE_PROVIDER=s3 to use this instead of Cloudflare R2.
# S3_ACCESS_KEY=""
# S3_SECRET_KEY=""
# S3_BUCKET=""
# S3_REGION="us-east-1"
# S3_BUCKET_URL="https://your-bucket.s3.amazonaws.com"
# S3_ENDPOINT=

# === Common optional Settings

## This is a dummy key, you must create your own from Resend.
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/api/routes/media.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Organization } from '@prisma/client';
import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service';
import { ApiTags } from '@nestjs/swagger';
import handleR2Upload from '@gitroom/nestjs-libraries/upload/r2.uploader';
import handleS3Upload from '@gitroom/nestjs-libraries/upload/s3.uploader';
import { FileInterceptor } from '@nestjs/platform-express';
import { CustomFileValidationPipe } from '@gitroom/nestjs-libraries/upload/custom.upload.validation';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
Expand Down Expand Up @@ -158,7 +159,8 @@ export class MediaController {
@Res() res: Response,
@Param('endpoint') endpoint: string
) {
const upload = await handleR2Upload(endpoint, req, res);
const storageProvider = process.env.STORAGE_PROVIDER || 'local';
const upload = await (storageProvider === 's3' ? handleS3Upload : handleR2Upload)(endpoint, req, res);
if (endpoint !== 'complete-multipart-upload') {
return upload;
}
Expand Down
10 changes: 8 additions & 2 deletions apps/frontend/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
>
<VariableContextComponent
storageProvider={
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare' | 's3'
}
environment={process.env.NODE_ENV!}
backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!}
Expand All @@ -71,7 +71,13 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
oauthLogoUrl={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL!}
oauthDisplayName={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME!}
uploadDirectory={process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY!}
cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''}
cloudflareUrl={
process.env.STORAGE_PROVIDER === 's3'
? process.env.S3_BUCKET_URL || ''
: process.env.STORAGE_PROVIDER === 'cloudflare'
? process.env.CLOUDFLARE_BUCKET_URL || ''
: ''
}
mainUrl={process.env.MAIN_URL || ''}
mcpUrl={process.env.MCP_URL}
dub={!!process.env.STRIPE_PUBLISHABLE_KEY}
Expand Down
10 changes: 8 additions & 2 deletions apps/frontend/src/app/(extension)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
<VariableContextComponent
language="en"
storageProvider={
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare' | 's3'
}
stripeClient=""
environment={process.env.NODE_ENV!}
Expand All @@ -41,7 +41,13 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
oauthLogoUrl={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL!}
oauthDisplayName={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME!}
uploadDirectory={process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY!}
cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''}
cloudflareUrl={
process.env.STORAGE_PROVIDER === 's3'
? process.env.S3_BUCKET_URL || ''
: process.env.STORAGE_PROVIDER === 'cloudflare'
? process.env.CLOUDFLARE_BUCKET_URL || ''
: ''
}
mainUrl={process.env.MAIN_URL || ''}
mcpUrl={process.env.MCP_URL}
dub={false}
Expand Down
10 changes: 8 additions & 2 deletions apps/frontend/src/app/(provider)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
<VariableContextComponent
language="en"
storageProvider={
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare' | 's3'
}
stripeClient=""
environment={process.env.NODE_ENV!}
Expand All @@ -43,7 +43,13 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
oauthLogoUrl={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL!}
oauthDisplayName={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME!}
uploadDirectory={process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY!}
cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''}
cloudflareUrl={
process.env.STORAGE_PROVIDER === 's3'
? process.env.S3_BUCKET_URL || ''
: process.env.STORAGE_PROVIDER === 'cloudflare'
? process.env.CLOUDFLARE_BUCKET_URL || ''
: ''
}
mainUrl={process.env.MAIN_URL || ''}
mcpUrl={process.env.MCP_URL}
dub={false}
Expand Down
11 changes: 9 additions & 2 deletions apps/frontend/src/components/media/new.uploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ export function useUppyUploader(props: {
uppy2.on('file-added', (file) => {
setLocked(true);
uppy2.setFileMeta(file.id, {
useCloudflare: storageProvider === 'cloudflare' ? 'true' : 'false', // Example of adding a custom field
useCloudflare:
storageProvider === 'cloudflare' || storageProvider === 's3'
? 'true'
: 'false', // Example of adding a custom field
addedOrder: fileOrderIndex++, // Track original order for sorting after upload
// Add more fields as needed
});
Expand Down Expand Up @@ -225,7 +228,11 @@ export function useUppyUploader(props: {
if (transloadit.length > 0) {
// @ts-ignore
const allRes = result.transloadit[0].results;
const toSave = uniqBy<{ name: string; originalName: string; order: number }>(
const toSave = uniqBy<{
name: string;
originalName: string;
order: number;
}>(
// @ts-ignore
Object.values(allRes).flatMap((p: any[]) => {
return p.flatMap((item) => ({
Expand Down
1 change: 0 additions & 1 deletion libraries/nestjs-libraries/src/upload/r2.uploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,6 @@ export async function signPart(req: Request, res: Response) {
Key: key,
PartNumber: partNumber,
UploadId: uploadId,
Expires: 3600,
};

const command = new UploadPartCommand({ ...params });
Expand Down
130 changes: 130 additions & 0 deletions libraries/nestjs-libraries/src/upload/s3.storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import 'multer';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { IUploadProvider } from './upload.interface';
import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator';
import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
import { parseDataUrl } from '@gitroom/nestjs-libraries/upload/data.url';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { fromBuffer } = require('file-type');

const ALLOWED_MIME_TYPES = new Set<string>([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/tiff',
'video/mp4',
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
]);

class S3Storage implements IUploadProvider {
private _client: S3Client;

constructor(
accessKey: string,
secretKey: string,
private region: string,
private _bucketName: string,
private _uploadUrl: string,
endpoint?: string
) {
this._client = new S3Client({
region,
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretKey,
},
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
});
}

async uploadSimple(path: string) {
const dataUrl = path.startsWith('data:') ? parseDataUrl(path) : null;

let body: Buffer;
if (dataUrl) {
body = dataUrl.buffer;
} else {
if (!(await isSafePublicHttpsUrl(path))) {
throw new Error('Unsafe URL');
}
const loadImage = await fetch(path, {
// @ts-ignore — undici option, not in lib.dom fetch types
dispatcher: ssrfSafeDispatcher,
});
body = Buffer.from(await loadImage.arrayBuffer());
}
const detected = await fromBuffer(body);
if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) {
throw new Error('Unsupported file type.');
}
const extension = detected.ext;
const safeContentType = detected.mime;
const id = makeId(10);

const command = new PutObjectCommand({
Bucket: this._bucketName,
Key: `${id}.${extension}`,
Body: body,
ContentType: safeContentType,
});
await this._client.send(command);

return `${this._uploadUrl}/${id}.${extension}`;
}

async uploadFile(file: Express.Multer.File): Promise<any> {
try {
const detected = await fromBuffer(file.buffer);
if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) {
throw new Error('Unsupported file type.');
}
const id = makeId(10);
const extension = detected.ext;
const safeContentType = detected.mime;

const command = new PutObjectCommand({
Bucket: this._bucketName,
ACL: 'public-read',
Key: `${id}.${extension}`,
Body: file.buffer,
ContentType: safeContentType,
});
await this._client.send(command);

return {
filename: `${id}.${extension}`,
mimetype: file.mimetype,
size: file.size,
buffer: file.buffer,
originalname: `${id}.${extension}`,
fieldname: 'file',
path: `${this._uploadUrl}/${id}.${extension}`,
destination: `${this._uploadUrl}/${id}.${extension}`,
encoding: '7bit',
stream: file.buffer as any,
};
} catch (err) {
console.error('Error uploading file to S3:', err);
throw err;
}
}

async removeFile(filePath: string): Promise<void> {
const key = filePath.replace(`${this._uploadUrl}/`, '');
const command = new DeleteObjectCommand({
Bucket: this._bucketName,
Key: key,
});
await this._client.send(command);
}
}

export { S3Storage };
export default S3Storage;
Loading