Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
41 changes: 41 additions & 0 deletions docs/content/docs/api/configuration/upload-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ createUploadConfig().provider("cloudflareR2",{ // [!code highlight]
})
```

**Public R2 bucket with custom domain:**

```typescript title="R2 Public Bucket"
createUploadConfig().provider("cloudflareR2", {
// ...credentials
customDomain: 'https://cdn.yourdomain.com', // [!code highlight]
visibility: 'public', // [!code highlight]
})
```

When `visibility: 'public'` is set, pushduck returns the custom domain URL directly after upload instead of a presigned URL. For R2, `customDomain` is **required** when `visibility: 'public'` — R2 presigned URLs cannot be used with custom domains (TypeScript will enforce this).

### AWS S3

```typescript title="AWS S3 Configuration"
Expand Down Expand Up @@ -112,6 +124,35 @@ createUploadConfig().provider("s3Compatible",{
})
```

## Bucket Visibility

The `visibility` option controls what kind of download URL pushduck returns after a file is uploaded.

```typescript
createUploadConfig().provider("aws", {
// ...
visibility: 'private', // default
})
```

| Value | Download URL returned | When to use |
|-------|----------------------|-------------|
| `'private'` (default) | Presigned GET URL (time-limited, signed against S3 API endpoint) | Private buckets — most setups |
| `'public'` | Plain URL (`customDomain/key` or S3 URL) | Publicly accessible buckets |

**Important:** `visibility` must match your actual bucket configuration. Setting `visibility: 'public'` on a private bucket will return URLs that result in 403 errors. pushduck does not verify your bucket's access settings.

| Bucket type | `customDomain` | `visibility` | URL returned |
|------------|---------------|-------------|-------------|
| Private | — | `'private'` | Presigned GET (S3 API endpoint) |
| Private | CDN | `'private'` | Presigned GET (S3 API endpoint — CDN bypassed) |
| Public | — | `'public'` | Plain S3 URL |
| Public | CDN | `'public'` | `https://cdn.yourdomain.com/key` |

> **R2 note:** `visibility: 'public'` requires `customDomain` to be set. R2 presigned URLs cannot be used with custom domains — TypeScript will show an error if you omit `customDomain` when setting `visibility: 'public'` on an R2 provider.

`storage.download.presignedUrl(key, expiresIn?)` always generates a presigned URL regardless of `visibility` — useful when you explicitly need a time-limited signed URL for a specific file.

## Configuration Methods

### .defaults()
Expand Down
29 changes: 20 additions & 9 deletions docs/content/docs/providers/aws-s3.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -300,30 +300,41 @@ If your bucket is public (not recommended for production):

Set up pushduck with your AWS S3 configuration:

**Private bucket (default — recommended):**

```typescript
// lib/upload.ts
import { createUploadConfig } from "pushduck/server";

export const { s3, config } = createUploadConfig()
export const { s3 } = createUploadConfig()
.provider("aws", {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
region: process.env.AWS_REGION!,
bucket: process.env.AWS_S3_BUCKET_NAME!,
// Optional: Custom domain for public files
customDomain: process.env.S3_CUSTOM_DOMAIN,
})
.defaults({
maxFileSize: "10MB",
acl: "private", // Use 'public-read' for public files
})
.defaults({ maxFileSize: "10MB" })
.build();
```

export { s3 };
**Public bucket with CloudFront CDN:**

```typescript
export const { s3 } = createUploadConfig()
.provider("aws", {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
region: process.env.AWS_REGION!,
bucket: process.env.AWS_S3_BUCKET_NAME!,
customDomain: process.env.S3_CUSTOM_DOMAIN, // e.g. https://cdn.yourdomain.com // [!code highlight]
visibility: 'public', // returns CDN URLs after upload instead of presigned URLs // [!code highlight]
})
.defaults({ maxFileSize: "10MB" })
.build();
```

<Callout type="info">
**Custom Domain**: When `customDomain` is configured, public URLs will use your custom domain instead of the S3 URL. Internal operations (upload, delete) still use S3 endpoints.
**`visibility`**: Controls what download URL pushduck returns after upload. `'private'` (default) generates a presigned GET URL valid for 1 hour. `'public'` returns the plain CDN/S3 URL — use this only when your bucket or objects are publicly accessible. Upload signing always uses the S3 API endpoint regardless of this setting.
</Callout>

</Step>
Expand Down
31 changes: 23 additions & 8 deletions docs/content/docs/providers/cloudflare-r2.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,26 +123,41 @@ For better performance and branding, you can use a custom domain:

## 6. Update Your Upload Configuration

**Private bucket (default):**

```typescript
// lib/upload.ts
import { createUploadConfig } from "pushduck/server";

export const { s3, } = createUploadConfig()
.provider("cloudflareR2",{
export const { s3 } = createUploadConfig()
.provider("cloudflareR2", {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
accountId: process.env.R2_ACCOUNT_ID!, // Found in R2 dashboard
accountId: process.env.R2_ACCOUNT_ID!,
bucket: process.env.S3_BUCKET_NAME!,
// Optional: Custom domain for faster access
customDomain: process.env.CLOUDFLARE_R2_CUSTOM_DOMAIN,
})
.defaults({
maxFileSize: "10MB",
acl: "public-read", // For public access
.defaults({ maxFileSize: "10MB" })
.build();
```

**Public bucket with custom domain:**

```typescript
export const { s3 } = createUploadConfig()
.provider("cloudflareR2", {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
accountId: process.env.R2_ACCOUNT_ID!,
bucket: process.env.S3_BUCKET_NAME!,
customDomain: process.env.CLOUDFLARE_R2_CUSTOM_DOMAIN!, // e.g. https://cdn.yourdomain.com // [!code highlight]
visibility: 'public', // returns CDN URLs after upload instead of presigned URLs // [!code highlight]
})
.defaults({ maxFileSize: "10MB" })
.build();
Comment on lines +131 to 155
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Align this example with the env vars documented above.

This snippet now depends on process.env.R2_ACCOUNT_ID!, but the setup section still tells users to define AWS_ENDPOINT_URL instead. As written, the guide no longer gives readers the env var they need to make this example work.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/providers/cloudflare-r2.mdx` around lines 132 - 156, The
example under provider("cloudflareR2") is inconsistent with the earlier setup:
it uses process.env.R2_ACCOUNT_ID! but the docs instruct readers to set
AWS_ENDPOINT_URL; either change the example to use the documented env var
(replace R2_ACCOUNT_ID with AWS_ENDPOINT_URL and keep accountId/endpoint mapping
consistent) or update the setup section to require R2_ACCOUNT_ID instead; adjust
the provider(...) block (accountId/endpoint/customDomain fields) and any
explanatory text so the env var names (AWS_ENDPOINT_URL or R2_ACCOUNT_ID) match
across createUploadConfig(), provider("cloudflareR2"), and the setup
instructions.

```

> **Note:** For R2, `visibility: 'public'` requires `customDomain` to be set. R2 presigned URLs cannot be used with custom domains — TypeScript enforces this at compile time.

## 7. Test Your Setup

```bash
Expand Down
214 changes: 214 additions & 0 deletions packages/pushduck/src/__tests__/download-url-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* Tests for download URL generation with visibility config
*
* Covers:
* - Private bucket (default): presigned GET signed against S3 API endpoint
* - Private bucket + custom domain: presigned GET still against S3 API, not CDN
* - Public bucket + no custom domain: plain S3 URL, no signing
* - Public bucket + custom domain: custom domain URL, no signing
* - R2 private: presigned GET against R2 API endpoint
* - R2 public + custom domain: custom domain URL, no signing
* - storage.download.presignedUrl(): always presigns regardless of visibility
*/

import { describe, expect, it } from "vitest";
import { createUploadConfig } from "../core/config/upload-config";
import {
generateDownloadUrl,
generatePresignedDownloadUrl,
} from "../core/storage/client";

// ─── Helpers ────────────────────────────────────────────────────────────────

function makeAWS(overrides: Record<string, unknown> = {}) {
return createUploadConfig()
.provider("aws", {
accessKeyId: "test-key",
secretAccessKey: "test-secret",
region: "us-east-1",
bucket: "test-bucket",
...overrides,
})
.build().config;
}

function makeR2(overrides: Record<string, unknown> = {}) {
return createUploadConfig()
.provider("cloudflareR2", {
accessKeyId: "test-key",
secretAccessKey: "test-secret",
accountId: "abc123",
bucket: "test-bucket",
...overrides,
})
.build().config;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

const KEY = "uploads/photo.jpg";
const S3_URL = `https://test-bucket.s3.us-east-1.amazonaws.com/${KEY}`;
const R2_API_URL = `https://abc123.r2.cloudflarestorage.com/test-bucket/${KEY}`;
// customDomain must include the protocol — buildPublicUrl uses the value as-is
const CUSTOM_DOMAIN = "https://cdn.example.com";
const CDN_URL = `${CUSTOM_DOMAIN}/${KEY}`;

// ─── generateDownloadUrl — private (default) ────────────────────────────────

describe("generateDownloadUrl — private bucket (default)", () => {
it("returns a presigned URL signed against the S3 API endpoint", async () => {
const config = makeAWS();
const url = await generateDownloadUrl(config, KEY);
expect(url).toContain(S3_URL);
expect(url).toContain("X-Amz-Signature");
expect(url).toContain("X-Amz-Expires=3600");
});

it("respects custom expiresIn", async () => {
const config = makeAWS();
const url = await generateDownloadUrl(config, KEY, 300);
expect(url).toContain("X-Amz-Expires=300");
});

it("signs against S3 API endpoint even when customDomain is set", async () => {
const config = makeAWS({ customDomain: CUSTOM_DOMAIN });
const url = await generateDownloadUrl(config, KEY);
// Must use the S3 API endpoint for signing, NOT the custom domain
expect(url).toContain("s3.us-east-1.amazonaws.com");
expect(url).not.toContain("cdn.example.com");
expect(url).toContain("X-Amz-Signature");
});

it("explicit visibility: 'private' behaves the same as default", async () => {
const config = makeAWS({ visibility: "private" });
const url = await generateDownloadUrl(config, KEY);
expect(url).toContain(S3_URL);
expect(url).toContain("X-Amz-Signature");
});
});

// ─── generateDownloadUrl — public bucket ────────────────────────────────────

describe("generateDownloadUrl — public bucket", () => {
it("returns plain S3 URL with no signing when no custom domain", async () => {
const config = makeAWS({ visibility: "public" });
const url = await generateDownloadUrl(config, KEY);
expect(url).toBe(S3_URL);
expect(url).not.toContain("X-Amz-Signature");
expect(url).not.toContain("X-Amz-Expires");
});

it("returns custom domain URL with no signing when customDomain is set", async () => {
const config = makeAWS({
visibility: "public",
customDomain: CUSTOM_DOMAIN,
});
const url = await generateDownloadUrl(config, KEY);
expect(url).toBe(CDN_URL);
expect(url).not.toContain("X-Amz-Signature");
expect(url).not.toContain("amazonaws.com");
});

it("strips trailing slash from custom domain", async () => {
const config = makeAWS({
visibility: "public",
customDomain: CUSTOM_DOMAIN + "/",
});
const url = await generateDownloadUrl(config, KEY);
expect(url).toBe(CDN_URL);
});
});

// ─── generateDownloadUrl — R2 ───────────────────────────────────────────────

describe("generateDownloadUrl — Cloudflare R2", () => {
it("private (default): presigned GET against R2 API endpoint", async () => {
const config = makeR2();
const url = await generateDownloadUrl(config, KEY);
expect(url).toContain(R2_API_URL);
expect(url).toContain("X-Amz-Signature");
});

it("private + custom domain: still signs against R2 API, not custom domain", async () => {
const config = makeR2({ customDomain: CUSTOM_DOMAIN });
const url = await generateDownloadUrl(config, KEY);
// R2 presigned URLs must use the API endpoint, not custom domain
expect(url).toContain("r2.cloudflarestorage.com");
expect(url).not.toContain("cdn.example.com");
expect(url).toContain("X-Amz-Signature");
});

it("public + custom domain: returns custom domain URL with no signing", async () => {
const config = makeR2({ visibility: "public", customDomain: CUSTOM_DOMAIN });
const url = await generateDownloadUrl(config, KEY);
expect(url).toBe(CDN_URL);
expect(url).not.toContain("X-Amz-Signature");
expect(url).not.toContain("r2.cloudflarestorage.com");
});
});

// ─── generatePresignedDownloadUrl — always presigns ─────────────────────────

describe("generatePresignedDownloadUrl — always presigns (ignores visibility)", () => {
it("presigns for private bucket", async () => {
const config = makeAWS({ visibility: "private" });
const url = await generatePresignedDownloadUrl(config, KEY);
expect(url).toContain("X-Amz-Signature");
expect(url).toContain(S3_URL);
});

it("presigns even when visibility is 'public'", async () => {
const config = makeAWS({
visibility: "public",
customDomain: CUSTOM_DOMAIN,
});
const url = await generatePresignedDownloadUrl(config, KEY);
// Ignores visibility — always presigns against S3 API endpoint
expect(url).toContain("X-Amz-Signature");
expect(url).toContain("s3.us-east-1.amazonaws.com");
expect(url).not.toContain("cdn.example.com");
});

it("respects custom expiresIn", async () => {
const config = makeAWS();
const url = await generatePresignedDownloadUrl(config, KEY, 900);
expect(url).toContain("X-Amz-Expires=900");
});

it("defaults to 3600s expiry", async () => {
const config = makeAWS();
const url = await generatePresignedDownloadUrl(config, KEY);
expect(url).toContain("X-Amz-Expires=3600");
});
});

// ─── R2 TypeScript discriminated union (documented, not runtime) ─────────────

describe("R2 visibility: 'public' TypeScript constraint", () => {
it("R2 private without customDomain is valid", () => {
expect(() =>
createUploadConfig()
.provider("cloudflareR2", {
accessKeyId: "k",
secretAccessKey: "s",
accountId: "id",
bucket: "b",
// no customDomain, no visibility — valid private config
})
.build()
).not.toThrow();
});

it("R2 public with customDomain is valid", () => {
expect(() =>
createUploadConfig()
.provider("cloudflareR2", {
accessKeyId: "k",
secretAccessKey: "s",
accountId: "id",
bucket: "b",
customDomain: CUSTOM_DOMAIN,
visibility: "public",
})
.build()
).not.toThrow();
});
});
4 changes: 2 additions & 2 deletions packages/pushduck/src/__tests__/s3-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createUploadConfig } from "../core/config/upload-config";
import {
generatePresignedDownloadUrl,
generateDownloadUrl,
generatePresignedUploadUrl,
getFileUrl,
} from "../core/storage/client";
Expand Down Expand Up @@ -66,7 +66,7 @@ describe("S3 Fallback Behavior", () => {
})
.build();

const presignedUrl = await generatePresignedDownloadUrl(
const presignedUrl = await generateDownloadUrl(
config,
"uploads/test-image.jpg",
3600
Expand Down
Loading
Loading