From 9e00c64407426106e3f6c3664b2254af8c519379 Mon Sep 17 00:00:00 2001 From: Jordan Miguel Date: Sun, 27 Sep 2026 12:22:50 -0300 Subject: [PATCH 1/2] feat: add S3_PREFIX to store uploads under a folder in the bucket Uploads always landed at the bucket root and startup granted public read on the entire bucket, so Plunk could not share a bucket with another application without exposing that application's objects. S3_PREFIX places uploads under a folder and narrows the public-read policy to that folder only. Unset keeps the current layout and policy. --- .env.self-host.example | 4 + CLAUDE.md | 2 +- apps/api/.env.example | 2 + apps/api/src/app/constants.ts | 5 + apps/api/src/services/S3Service.ts | 11 +- .../src/services/__tests__/S3Service.test.ts | 123 ++++++++++++++++++ .../self-hosting/environment-variables.mdx | 1 + docker-compose.yml | 1 + turbo.json | 3 + 9 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/services/__tests__/S3Service.test.ts diff --git a/.env.self-host.example b/.env.self-host.example index 75663841c..021c25e2d 100644 --- a/.env.self-host.example +++ b/.env.self-host.example @@ -86,6 +86,10 @@ 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 -> /plunk//...). +# Set it when sharing the bucket with another application: Plunk then only makes +# objects under this prefix publicly readable, instead of the entire bucket. +S3_PREFIX= # ======================================== # OPTIONAL: Notifications (ntfy.sh) diff --git a/CLAUDE.md b/CLAUDE.md index deb33eb28..9dbb51f16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/apps/api/.env.example b/apps/api/.env.example index 194de6345..2c1d9d8a5 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -47,6 +47,8 @@ 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 +# S3_PREFIX=plunk # ============================================================================== # AWS SES (Required - for sending emails) diff --git a/apps/api/src/app/constants.ts b/apps/api/src/app/constants.ts index a331b8931..63cc07f28 100644 --- a/apps/api/src/app/constants.ts +++ b/apps/api/src/app/constants.ts @@ -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) diff --git a/apps/api/src/services/S3Service.ts b/apps/api/src/services/S3Service.ts index a36d89b3e..2dea98ae1 100644 --- a/apps/api/src/services/S3Service.ts +++ b/apps/api/src/services/S3Service.ts @@ -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 */ @@ -81,7 +84,7 @@ export async function initializeBucket(): Promise { } } - // 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', @@ -91,7 +94,7 @@ export async function initializeBucket(): Promise { Effect: 'Allow', Principal: '*', Action: ['s3:GetObject'], - Resource: [`arn:aws:s3:::${S3_BUCKET}/*`], + Resource: [`arn:aws:s3:::${S3_BUCKET}/${KEY_PREFIX}*`], }, ], }; @@ -104,7 +107,7 @@ export async function initializeBucket(): Promise { ); 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); @@ -138,7 +141,7 @@ export async function uploadFile(params: UploadFileParams): Promise; +} + +const sent = vi.hoisted(() => [] as SentCommand[]); + +vi.mock('@aws-sdk/client-s3', () => { + const command = (name: string) => + class { + readonly name = name; + constructor(readonly input: Record) {} + }; + + 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>) { + 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/*']); + }); + }); +}); diff --git a/apps/wiki/content/docs/self-hosting/environment-variables.mdx b/apps/wiki/content/docs/self-hosting/environment-variables.mdx index 8d262c53c..e9b0d2033 100644 --- a/apps/wiki/content/docs/self-hosting/environment-variables.mdx +++ b/apps/wiki/content/docs/self-hosting/environment-variables.mdx @@ -54,6 +54,7 @@ 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. | — | ## Attachments diff --git a/docker-compose.yml b/docker-compose.yml index 81bb0340e..5ff0fe369 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/turbo.json b/turbo.json index c99fb6d54..77a8b1f20 100644 --- a/turbo.json +++ b/turbo.json @@ -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", @@ -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", @@ -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", From a9350e57d2b56ae2cee555d697462bf095d800ec Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Sat, 17 Oct 2026 10:57:24 +0200 Subject: [PATCH 2/2] docs: warn that setting S3_PREFIX on an existing install breaks old uploads Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.self-host.example | 3 +++ apps/api/.env.example | 1 + .../content/docs/self-hosting/environment-variables.mdx | 8 ++++++++ 3 files changed, 12 insertions(+) diff --git a/.env.self-host.example b/.env.self-host.example index 021c25e2d..b96de6dac 100644 --- a/.env.self-host.example +++ b/.env.self-host.example @@ -89,6 +89,9 @@ S3_FORCE_PATH_STYLE=true # Optional folder for uploads inside the bucket (e.g. plunk -> /plunk//...). # 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= # ======================================== diff --git a/apps/api/.env.example b/apps/api/.env.example index 2c1d9d8a5..da08f9402 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -48,6 +48,7 @@ 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 # ============================================================================== diff --git a/apps/wiki/content/docs/self-hosting/environment-variables.mdx b/apps/wiki/content/docs/self-hosting/environment-variables.mdx index e9b0d2033..f9a51974b 100644 --- a/apps/wiki/content/docs/self-hosting/environment-variables.mdx +++ b/apps/wiki/content/docs/self-hosting/environment-variables.mdx @@ -56,6 +56,14 @@ The bundled Docker setup includes Minio with defaults that work out of the box. | `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. | — | + + 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. + + ## Attachments Plunk supports attachments on transactional emails. AWS SES caps total message size at 40 MB; the defaults below leave headroom but can be tuned for your use case.