Skip to content
Merged
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
23 changes: 21 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ EMAIL_FROM="onboarding@resend.dev"
# Just uncomment the ones you want to use and set your credentials
# Uncommented ones must have their credentials set

# Google OAuth2
# Go to https://console.developers.google.com/ to create your OAuth2 credentials
# Google OAuth2 ("Sign in with Google" — user login)
# Create OAuth 2.0 credentials at https://console.cloud.google.com/apis/credentials
# Application type = Web application, then Client ID + Client secret
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GOOGLE_CALLBACK_URL="http://localhost:4000/auth/google/callback"
Expand All @@ -70,3 +71,21 @@ MICROSOFT_CALLBACK_URL="http://localhost:4000/auth/microsoft/callback"
# SLACK_CLIENT_ID="xxxxxxxxxxxxx.xxxxxxxxxxxxx"
# SLACK_CLIENT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# SLACK_CALLBACK_URL="http://localhost:4000/auth/slack/callback"

# Google Cloud Storage (avatar / background uploads — optional)
# This is NOT the same as OAuth: you need a GCP service account with Storage access.
#
# 1) Bucket name (e.g. from Terraform: myproject-epitrello-uploads).
# If unset, files are saved under backend/uploads/
# GCS_BUCKET_NAME="myproject-epitrello-uploads"
#
# 2) GCS authentication (one option is enough):
# - GCP secret: GCP_SERVICE_ACCOUNT = raw JSON content of the service account key
# (e.g. value from GCP Secret Manager or env var with pasted JSON).
# - Key file: GOOGLE_APPLICATION_CREDENTIALS = path to the service account JSON file.
# - Base64 key: GCS_SERVICE_ACCOUNT_KEY_BASE64 (for env without a file).
# - In prod (Cloud Run): leave unset; the service uses the runtime service account.
#
# GCP_SERVICE_ACCOUNT='{"type":"service_account","project_id":"...", ...}'
# GOOGLE_APPLICATION_CREDENTIALS="./backend/service-account-key.json"
# GCS_SERVICE_ACCOUNT_KEY_BASE64="eyJ0eXBlIjoi..."
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.7",
"@google-cloud/storage": "^7.14.0",
"dataloader": "^2.2.3",
"express": "^4.21.2",
"graphql": "^16.8.1",
Expand Down
329 changes: 329 additions & 0 deletions backend/pnpm-lock.yaml

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions backend/src/common/utils/sanitize.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { escapeSingleQuotes } from './sanitize';

describe('sanitize', () => {
describe('escapeSingleQuotes', () => {
it('should double single quotes (SQL-style)', () => {
expect(escapeSingleQuotes("it's")).toBe("it''s");
expect(escapeSingleQuotes("'quoted'")).toBe("''quoted''");
});

it('should return empty string when input is empty', () => {
expect(escapeSingleQuotes('')).toBe('');
});

it('should replace all occurrences, not just the first', () => {
expect(escapeSingleQuotes("a'b'c")).toBe("a''b''c");
});

it('should leave string unchanged when no single quote', () => {
expect(escapeSingleQuotes('hello')).toBe('hello');
});
});
});
194 changes: 194 additions & 0 deletions backend/src/modules/upload/storage.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { StorageService } from './storage.service';
import { Storage } from '@google-cloud/storage';

const mockBucket = {
file: jest.fn().mockReturnValue({
save: jest.fn().mockResolvedValue(undefined),
}),
};
const mockStorageInstance = {
bucket: jest.fn().mockReturnValue(mockBucket),
};

jest.mock('@google-cloud/storage', () => ({
Storage: jest.fn().mockImplementation(() => mockStorageInstance),
}));

describe('StorageService', () => {
let configGet: jest.Mock;

const createService = async (config: Record<string, string | undefined>) => {
configGet = jest.fn((key: string) => config[key]);
const module = await Test.createTestingModule({
providers: [
StorageService,
{ provide: ConfigService, useValue: { get: configGet } },
],
}).compile();
return module.get<StorageService>(StorageService);
};

beforeEach(() => {
jest.clearAllMocks();
});

describe('constructor', () => {
it('should disable GCS when GCS_BUCKET_NAME is not set', async () => {
const service = await createService({ GCS_BUCKET_NAME: undefined });
expect(service.isGcsEnabled()).toBe(false);
});

it('should disable GCS when GCS_BUCKET_NAME is empty string', async () => {
const service = await createService({ GCS_BUCKET_NAME: '' });
expect(service.isGcsEnabled()).toBe(false);
});

it('should enable GCS with bucket only (default Storage)', async () => {
const service = await createService({ GCS_BUCKET_NAME: 'my-bucket' });
expect(service.isGcsEnabled()).toBe(true);
expect(Storage).toHaveBeenCalledWith();
});

it('should enable GCS with GCP_SERVICE_ACCOUNT valid JSON', async () => {
const key = JSON.stringify({
type: 'service_account',
project_id: 'my-project',
private_key_id: 'key-id',
private_key: '-----BEGIN PRIVATE KEY-----\nxxx\n-----END PRIVATE KEY-----\n',
client_email: 'sa@my-project.iam.gserviceaccount.com',
client_id: '123',
});
const service = await createService({
GCS_BUCKET_NAME: 'my-bucket',
GCP_SERVICE_ACCOUNT: key,
});
expect(service.isGcsEnabled()).toBe(true);
expect(Storage).toHaveBeenCalledWith({
credentials: JSON.parse(key),
projectId: 'my-project',
});
});

it('should fallback to default Storage when GCP_SERVICE_ACCOUNT is invalid JSON', async () => {
const service = await createService({
GCS_BUCKET_NAME: 'my-bucket',
GCP_SERVICE_ACCOUNT: 'not-valid-json',
});
expect(service.isGcsEnabled()).toBe(true);
expect(Storage).toHaveBeenCalledWith();
});

it('should fallback to default Storage when GCP_SERVICE_ACCOUNT has no project_id', async () => {
const service = await createService({
GCS_BUCKET_NAME: 'my-bucket',
GCP_SERVICE_ACCOUNT: JSON.stringify({ type: 'service_account' }),
});
expect(service.isGcsEnabled()).toBe(true);
expect(Storage).toHaveBeenCalledWith();
});

it('should enable GCS with GCS_SERVICE_ACCOUNT_KEY_BASE64 when GCP_SERVICE_ACCOUNT not set', async () => {
const key = { project_id: 'proj-base64', type: 'service_account' };
const base64 = Buffer.from(JSON.stringify(key)).toString('base64');
const service = await createService({
GCS_BUCKET_NAME: 'my-bucket',
GCS_SERVICE_ACCOUNT_KEY_BASE64: base64,
});
expect(service.isGcsEnabled()).toBe(true);
expect(Storage).toHaveBeenCalledWith({
credentials: key,
projectId: 'proj-base64',
});
});

it('should prefer GCP_SERVICE_ACCOUNT over GCS_SERVICE_ACCOUNT_KEY_BASE64', async () => {
const gcpKey = JSON.stringify({ project_id: 'from-gcp', type: 'service_account' });
const base64Key = Buffer.from(JSON.stringify({ project_id: 'from-base64' })).toString('base64');
const service = await createService({
GCS_BUCKET_NAME: 'my-bucket',
GCP_SERVICE_ACCOUNT: gcpKey,
GCS_SERVICE_ACCOUNT_KEY_BASE64: base64Key,
});
expect(service.isGcsEnabled()).toBe(true);
expect(Storage).toHaveBeenCalledWith({
credentials: JSON.parse(gcpKey),
projectId: 'from-gcp',
});
});

it('should fallback to default Storage when GCS_SERVICE_ACCOUNT_KEY_BASE64 is invalid', async () => {
const service = await createService({
GCS_BUCKET_NAME: 'my-bucket',
GCS_SERVICE_ACCOUNT_KEY_BASE64: 'not-valid-base64-json!!!',
});
expect(service.isGcsEnabled()).toBe(true);
expect(Storage).toHaveBeenCalledWith();
});

it('should disable GCS when Storage constructor throws with credentials', async () => {
(Storage as unknown as jest.Mock).mockImplementationOnce(() => {
throw new Error('Auth failed');
});
const key = JSON.stringify({ project_id: 'p', type: 'service_account' });
const service = await createService({
GCS_BUCKET_NAME: 'my-bucket',
GCP_SERVICE_ACCOUNT: key,
});
expect(service.isGcsEnabled()).toBe(false);
});
});

describe('isGcsEnabled', () => {
it('should return true when bucket and storage are set', async () => {
const service = await createService({ GCS_BUCKET_NAME: 'b' });
expect(service.isGcsEnabled()).toBe(true);
});

it('should return false when bucket is not set', async () => {
const service = await createService({});
expect(service.isGcsEnabled()).toBe(false);
});
});

describe('uploadToGcs', () => {
it('should throw when GCS is not configured', async () => {
const service = await createService({});
await expect(
service.uploadToGcs(Buffer.from('x'), 'avatars', 'f.jpg', 'image/jpeg'),
).rejects.toThrow('GCS is not configured. Set GCS_BUCKET_NAME.');
});

it('should upload and return public URL when GCS is enabled', async () => {
const service = await createService({ GCS_BUCKET_NAME: 'my-bucket' });
const buffer = Buffer.from('image-data');
const url = await service.uploadToGcs(
buffer,
'backgrounds',
'bg-123.png',
'image/png',
);
expect(url).toBe('https://storage.googleapis.com/my-bucket/backgrounds/bg-123.png');
expect(mockStorageInstance.bucket).toHaveBeenCalledWith('my-bucket');
expect(mockBucket.file).toHaveBeenCalledWith('backgrounds/bg-123.png');
const fileInstance = mockBucket.file();
expect(fileInstance.save).toHaveBeenCalledWith(buffer, {
contentType: 'image/png',
metadata: { cacheControl: 'public, max-age=31536000' },
});
});

it('should upload to avatars folder', async () => {
const service = await createService({ GCS_BUCKET_NAME: 'b' });
await service.uploadToGcs(Buffer.from('x'), 'avatars', 'u1.jpg', 'image/jpeg');
expect(mockBucket.file).toHaveBeenCalledWith('avatars/u1.jpg');
});

it('should upload to attachments folder', async () => {
const service = await createService({ GCS_BUCKET_NAME: 'b' });
await service.uploadToGcs(Buffer.from('x'), 'attachments', 'a1.pdf', 'application/pdf');
expect(mockBucket.file).toHaveBeenCalledWith('attachments/a1.pdf');
});
});
});
96 changes: 96 additions & 0 deletions backend/src/modules/upload/storage.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Storage } from '@google-cloud/storage';

export type UploadFolder = 'avatars' | 'backgrounds' | 'attachments';

@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private readonly bucketName: string | null;
private readonly storage: Storage | null;

constructor(private config: ConfigService) {
const bucket = this.config.get<string>('GCS_BUCKET_NAME');
if (bucket) {
this.bucketName = bucket;
const gcpSecret = this.config.get<string>('GCP_SERVICE_ACCOUNT');
const keyBase64 = this.config.get<string>('GCS_SERVICE_ACCOUNT_KEY_BASE64');
let credentials: { project_id?: string } | null = null;
if (gcpSecret?.trim()) {
try {
const parsed = JSON.parse(gcpSecret.trim()) as { project_id?: string };
if (parsed.project_id) credentials = parsed;
} catch {
this.logger.warn(
'GCP_SERVICE_ACCOUNT is set but invalid JSON. Using GOOGLE_APPLICATION_CREDENTIALS or default.',
);
}
}
if (!credentials && keyBase64) {
try {
const keyJson = Buffer.from(keyBase64, 'base64').toString('utf8');
credentials = JSON.parse(keyJson) as { project_id?: string };
} catch {
this.logger.warn(
'GCS_SERVICE_ACCOUNT_KEY_BASE64 is set but invalid. Using GOOGLE_APPLICATION_CREDENTIALS or default.',
);
}
}
if (credentials) {
try {
this.storage = new Storage({
credentials,
projectId: credentials.project_id,
});
this.logger.log(`Upload: GCS enabled (bucket: ${bucket})`);
} catch (err) {
this.bucketName = null;
this.storage = null;
this.logger.warn(
`Upload: GCS disabled — failed to create Storage client. Files will be saved to backend/uploads/. Error: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
} else {
this.storage = new Storage();
this.logger.log(
`Upload: GCS enabled (bucket: ${bucket}), using GOOGLE_APPLICATION_CREDENTIALS or default credentials`,
);
}
} else {
this.bucketName = null;
this.storage = null;
this.logger.log(
'Upload: GCS disabled — GCS_BUCKET_NAME not set. Files will be saved to backend/uploads/',
);
}
}

isGcsEnabled(): boolean {
return this.bucketName != null && this.storage != null;
}

/**
* Upload a file buffer to GCS and return the public URL.
* Throws if GCS is not configured.
*/
async uploadToGcs(
buffer: Buffer,
folder: UploadFolder,
filename: string,
mimetype: string,
): Promise<string> {
if (!this.bucketName || !this.storage) {
throw new Error('GCS is not configured. Set GCS_BUCKET_NAME.');
}
const path = `${folder}/${filename}`;
const bucket = this.storage.bucket(this.bucketName);
const file = bucket.file(path);
await file.save(buffer, {
contentType: mimetype,
metadata: { cacheControl: 'public, max-age=31536000' },
});
return `https://storage.googleapis.com/${this.bucketName}/${path}`;
}
}
Loading
Loading