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
7 changes: 7 additions & 0 deletions .env.self-host.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ S3_ACCESS_KEY_SECRET=plunkminiopass
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# Optional folder for uploads inside the bucket (e.g. plunk -> <bucket>/plunk/<projectId>/...).
# Set it when sharing the bucket with another application: Plunk then only makes
# objects under this prefix publicly readable, instead of the entire bucket.
# WARNING: setting this on an instance that already has uploads breaks every image uploaded
# before it (they stay at the bucket root and lose public read). Only set it on a new install.
# S3_PUBLIC_URL must still point at the bucket root.
S3_PREFIX=

# ========================================
# OPTIONAL: Notifications (ntfy.sh)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Required for builds and deployment (see turbo.json and .env.example):
- **Application URLs** (injected at runtime into Next.js apps): `API_URI`, `DASHBOARD_URI`, `LANDING_URI`, `WIKI_URI` (
optional)
- S3-compatible Storage (Minio): `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_ACCESS_KEY_SECRET`, `S3_BUCKET`,
`S3_PUBLIC_URL`, `S3_FORCE_PATH_STYLE`
`S3_PUBLIC_URL`, `S3_FORCE_PATH_STYLE`, `S3_PREFIX` (optional)
- AWS SES: `AWS_SES_REGION`, `AWS_SES_ACCESS_KEY_ID`, `AWS_SES_SECRET_ACCESS_KEY`, `SES_CONFIGURATION_SET`,
`SES_CONFIGURATION_SET_NO_TRACKING`
- OAuth (optional): `GITHUB_OAUTH_CLIENT`, `GITHUB_OAUTH_SECRET`, `GOOGLE_OAUTH_CLIENT`, `GOOGLE_OAUTH_SECRET`
Expand Down
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ S3_ACCESS_KEY_SECRET=plunkminiopass
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# Optional: store uploads under a folder, e.g. when sharing the bucket with another app
# Warning: only set it on a new install, existing uploads lose public read and their images break
# S3_PREFIX=plunk

# ==============================================================================
# AWS SES (Required - for sending emails)
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/app/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export const S3_ACCESS_KEY_SECRET = validateEnv('S3_ACCESS_KEY_SECRET', '');
export const S3_BUCKET = validateEnv('S3_BUCKET', 'uploads');
export const S3_PUBLIC_URL = validateEnv('S3_PUBLIC_URL', '');
export const S3_FORCE_PATH_STYLE = validateEnv('S3_FORCE_PATH_STYLE', 'true') === 'true';
// Optional folder inside the bucket for uploads (e.g. `plunk`), so the bucket can be
// shared with other applications. Leading and trailing slashes are ignored.
export const S3_PREFIX = validateEnv('S3_PREFIX', '')
.trim()
.replace(/^\/+|\/+$/g, '');
export const S3_ENABLED = S3_ACCESS_KEY_ID !== '' && S3_ACCESS_KEY_SECRET !== '';

// AWS SES (required for email sending)
Expand Down
11 changes: 7 additions & 4 deletions apps/api/src/services/S3Service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ import {
S3_ENABLED,
S3_ENDPOINT,
S3_FORCE_PATH_STYLE,
S3_PREFIX,
S3_PUBLIC_URL,
} from '../app/constants.js';

const KEY_PREFIX = S3_PREFIX ? `${S3_PREFIX}/` : '';

/**
* S3-compatible storage client for Minio
*/
Expand Down Expand Up @@ -81,7 +84,7 @@ export async function initializeBucket(): Promise<void> {
}
}

// Set public read policy for the bucket (both for new and existing buckets)
// Set public read policy for the upload prefix (both for new and existing buckets)
try {
const bucketPolicy = {
Version: '2012-10-17',
Expand All @@ -91,7 +94,7 @@ export async function initializeBucket(): Promise<void> {
Effect: 'Allow',
Principal: '*',
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${S3_BUCKET}/*`],
Resource: [`arn:aws:s3:::${S3_BUCKET}/${KEY_PREFIX}*`],
},
],
};
Expand All @@ -104,7 +107,7 @@ export async function initializeBucket(): Promise<void> {
);

if (!bucketExists) {
signale.info(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
signale.info(`[S3] Set public read policy for: ${S3_BUCKET}/${KEY_PREFIX}*`);
}
} catch (policyError) {
signale.error('[S3] Failed to set bucket policy:', policyError);
Expand Down Expand Up @@ -138,7 +141,7 @@ export async function uploadFile(params: UploadFileParams): Promise<UploadFileRe
const timestamp = Date.now();
const randomString = crypto.randomBytes(8).toString('hex');
const extension = filename.split('.').pop();
const key = `${projectId}/${timestamp}-${randomString}.${extension}`;
const key = `${KEY_PREFIX}${projectId}/${timestamp}-${randomString}.${extension}`;

// Upload to S3/Minio
await s3Client.send(
Expand Down
123 changes: 123 additions & 0 deletions apps/api/src/services/__tests__/S3Service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {afterEach, describe, expect, it, vi} from 'vitest';

interface SentCommand {
name: string;
input: Record<string, string>;
}

const sent = vi.hoisted(() => [] as SentCommand[]);

vi.mock('@aws-sdk/client-s3', () => {
const command = (name: string) =>
class {
readonly name = name;
constructor(readonly input: Record<string, string>) {}
};

return {
S3Client: class {
async send(sentCommand: SentCommand) {
sent.push(sentCommand);
return {};
}
},
CreateBucketCommand: command('CreateBucket'),
HeadBucketCommand: command('HeadBucket'),
PutBucketPolicyCommand: command('PutBucketPolicy'),
PutObjectCommand: command('PutObject'),
};
});

// constants.ts reads the environment at import time, so each case stubs the
// S3 variables and imports a fresh copy of the service.
async function loadS3Service(prefix: string) {
vi.resetModules();
vi.stubEnv('S3_ACCESS_KEY_ID', 'test-access-key');
vi.stubEnv('S3_ACCESS_KEY_SECRET', 'test-secret');
vi.stubEnv('S3_BUCKET', 'shared-bucket');
vi.stubEnv('S3_PUBLIC_URL', 'https://cdn.example.com');
vi.stubEnv('S3_PREFIX', prefix);

return import('../S3Service.js');
}

function uploadImage(S3Service: Awaited<ReturnType<typeof loadS3Service>>) {
return S3Service.uploadFile({
file: Buffer.from('image'),
filename: 'logo.png',
contentType: 'image/png',
projectId: 'project-1',
});
}

function sentInput(name: string) {
return sent.find(command => command.name === name)?.input;
}

function publicReadResources() {
const policy = JSON.parse(sentInput('PutBucketPolicy')?.Policy ?? '{}') as {
Statement: {Resource: string[]}[];
};
return policy.Statement.flatMap(statement => statement.Resource);
}

describe('S3Service', () => {
afterEach(() => {
sent.length = 0;
vi.unstubAllEnvs();
});

describe('uploadFile', () => {
it('stores objects under the project folder when no prefix is configured', async () => {
const S3Service = await loadS3Service('');

const {key, url} = await uploadImage(S3Service);

expect(key).toMatch(/^project-1\/\d+-[0-9a-f]{16}\.png$/);
expect(url).toBe(`https://cdn.example.com/${key}`);
expect(sentInput('PutObject')).toMatchObject({
Bucket: 'shared-bucket',
Key: key,
});
});

it('stores objects under S3_PREFIX when configured', async () => {
const S3Service = await loadS3Service('plunk');

const {key, url} = await uploadImage(S3Service);

expect(key).toMatch(/^plunk\/project-1\/\d+-[0-9a-f]{16}\.png$/);
expect(url).toBe(`https://cdn.example.com/${key}`);
expect(sentInput('PutObject')).toMatchObject({
Bucket: 'shared-bucket',
Key: key,
});
});

it('ignores leading and trailing slashes in S3_PREFIX', async () => {
const S3Service = await loadS3Service('/tenants/plunk/');

const {key} = await uploadImage(S3Service);

expect(key).toMatch(/^tenants\/plunk\/project-1\/\d+-[0-9a-f]{16}\.png$/);
});
});

describe('initializeBucket', () => {
it('grants public read on the whole bucket when no prefix is configured', async () => {
const S3Service = await loadS3Service('');

await S3Service.initializeBucket();

expect(publicReadResources()).toEqual(['arn:aws:s3:::shared-bucket/*']);
});

it('limits public read to S3_PREFIX when configured', async () => {
const S3Service = await loadS3Service('plunk');

await S3Service.initializeBucket();

expect(publicReadResources()).toEqual(['arn:aws:s3:::shared-bucket/plunk/*']);
});
});
});
9 changes: 9 additions & 0 deletions apps/wiki/content/docs/self-hosting/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ The bundled Docker setup includes Minio with defaults that work out of the box.
| `S3_BUCKET` | No | Bucket name for file uploads. | `uploads` |
| `S3_PUBLIC_URL` | No | Publicly accessible base URL for stored files. | — |
| `S3_FORCE_PATH_STYLE` | No | Use path-style URLs instead of virtual-hosted. Required for Minio. | `true` |
| `S3_PREFIX` | No | Upload folder in the bucket; only it is made publicly readable. | — |

<Callout type="warn">
Setting `S3_PREFIX` on an instance that already has uploads breaks every image uploaded before it. The bucket
policy is replaced to only make objects under the prefix publicly readable, so files at the bucket root (already
embedded in templates, campaigns and sent emails) start returning 403. Moving them under the prefix doesn't help,
since that changes their URLs. Only set it on a new install. `S3_PUBLIC_URL` must still point at the bucket root,
since the prefix is appended to it.
</Callout>

## Attachments

Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ services:
S3_BUCKET: ${S3_BUCKET:-uploads}
S3_PUBLIC_URL: ${S3_PUBLIC_URL:-http://localhost:9000/uploads}
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true}
S3_PREFIX: ${S3_PREFIX:-}

# Notifications (ntfy.sh)
NTFY_URL: ${NTFY_URL:-http://ntfy/plunk-notifications}
Expand Down
3 changes: 3 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"S3_BUCKET",
"S3_PUBLIC_URL",
"S3_FORCE_PATH_STYLE",
"S3_PREFIX",
"AWS_SES_REGION",
"AWS_SES_ACCESS_KEY_ID",
"AWS_SES_SECRET_ACCESS_KEY",
Expand Down Expand Up @@ -115,6 +116,7 @@
"S3_BUCKET",
"S3_PUBLIC_URL",
"S3_FORCE_PATH_STYLE",
"S3_PREFIX",
"AWS_SES_REGION",
"AWS_SES_ACCESS_KEY_ID",
"AWS_SES_SECRET_ACCESS_KEY",
Expand Down Expand Up @@ -172,6 +174,7 @@
"S3_BUCKET",
"S3_PUBLIC_URL",
"S3_FORCE_PATH_STYLE",
"S3_PREFIX",
"AWS_SES_REGION",
"AWS_SES_ACCESS_KEY_ID",
"AWS_SES_SECRET_ACCESS_KEY",
Expand Down
Loading