Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
67 changes: 49 additions & 18 deletions packages/pushduck/src/core/providers/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,24 @@ export interface BaseProviderConfig {
customDomain?: string;
/** Force path-style URLs instead of virtual-hosted style */
forcePathStyle?: boolean;
/**
* Controls what kind of download URL the library returns after a file is uploaded.
*
* - `'private'` (default) — generates a presigned GET URL signed against the
* provider's S3 API endpoint. Works for private buckets. The URL expires
* after `expiresIn` seconds (default 3600).
* - `'public'` — returns the plain public URL (custom domain if configured,
* otherwise the provider's public URL). No signing. Use this when your
* bucket/objects are already publicly accessible.
*
* **Important:** This setting must match your actual bucket configuration.
* Setting `visibility: 'public'` on a private bucket will result in 403 errors
* when clients try to access the returned URLs. pushduck does not verify or
* enforce your bucket's access settings.
*
* @default 'private'
*/
visibility?: "public" | "private";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// ========================================
Expand Down Expand Up @@ -140,14 +158,34 @@ export interface AWSProviderConfig extends BaseProviderConfig {
sessionToken?: string;
}

/**
* Base fields for Cloudflare R2 object storage (without visibility/customDomain).
* Use the exported `CloudflareR2Config` type, not this interface directly.
*/
interface CloudflareR2BaseConfig extends Omit<BaseProviderConfig, "customDomain" | "visibility"> {
provider: "cloudflare-r2";
/** Cloudflare Account ID */
accountId: string;
/** R2 Access Key ID */
accessKeyId: string;
/** R2 Secret Access Key */
secretAccessKey: string;
/** Region (typically 'auto' for R2) */
region?: "auto";
/** Custom endpoint (auto-generated from accountId if not provided) */
endpoint?: string;
}

/**
* Configuration for Cloudflare R2 object storage.
* S3-compatible storage with zero egress fees and global distribution.
*
* @interface CloudflareR2Config
* @extends BaseProviderConfig
* R2 presigned URLs only work with the R2 S3 API endpoint — they cannot be
* used with custom domains. Therefore `visibility: 'public'` requires a
* `customDomain` to be set (R2 public access requires a custom domain or
* the r2.dev subdomain; the API endpoint does not serve public content).
*
* @example Basic Configuration
* @example Basic Configuration (private bucket)
* ```typescript
* const r2Config: CloudflareR2Config = {
* provider: 'cloudflare-r2',
Expand All @@ -158,31 +196,24 @@ export interface AWSProviderConfig extends BaseProviderConfig {
* };
* ```
*
* @example With Custom Domain
* @example Public bucket with custom domain
* ```typescript
* const r2WithDomain: CloudflareR2Config = {
* provider: 'cloudflare-r2',
* bucket: 'assets',
* accountId: 'abc123',
* accessKeyId: 'key123',
* secretAccessKey: 'secret123',
* customDomain: 'assets.myapp.com',
* customDomain: 'assets.myapp.com', // required when visibility is 'public'
* visibility: 'public',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* };
* ```
*/
export interface CloudflareR2Config extends BaseProviderConfig {
provider: "cloudflare-r2";
/** Cloudflare Account ID */
accountId: string;
/** R2 Access Key ID */
accessKeyId: string;
/** R2 Secret Access Key */
secretAccessKey: string;
/** Region (typically 'auto' for R2) */
region?: "auto";
/** Custom endpoint (auto-generated from accountId if not provided) */
endpoint?: string;
}
export type CloudflareR2Config = CloudflareR2BaseConfig &
(
| { visibility: "public"; customDomain: string }
| { visibility?: "private"; customDomain?: string }
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Configuration for DigitalOcean Spaces object storage.
Expand Down
4 changes: 2 additions & 2 deletions packages/pushduck/src/core/router/router-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import { createUniversalHandler } from "../handler/universal-handler";
import { InferS3Input, InferS3Output, S3Schema } from "../schema";
import {
generateFileKey,
generatePresignedDownloadUrl,
generateDownloadUrl,
generatePresignedUploadUrl,
getFileUrl,
} from "../storage/client";
Expand Down Expand Up @@ -987,7 +987,7 @@ export class S3Router<TRoutes extends S3RouterDefinition> {
const url = getFileUrl(this.config, completion.key);

// Generate presigned download URL (expires in 1 hour by default)
const presignedUrl = await generatePresignedDownloadUrl(
const presignedUrl = await generateDownloadUrl(
this.config,
completion.key,
3600
Expand Down
33 changes: 29 additions & 4 deletions packages/pushduck/src/core/storage/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ interface S3CompatibleConfig {
customDomain?: string;
/** Enable debug logging */
debug?: boolean;
/** Whether the bucket is public or private (controls download URL format) */
visibility?: "public" | "private";
}

/**
Expand Down Expand Up @@ -131,6 +133,7 @@ function getS3CompatibleConfig(
acl: config.acl,
customDomain: config.customDomain,
forcePathStyle: config.forcePathStyle,
visibility: config.visibility,
debug: options.debug ?? false,
};

Expand Down Expand Up @@ -605,18 +608,40 @@ export interface PresignedUrlResult {
}

/**
* Generates a presigned URL for downloading/viewing a file from S3
* Generates a download URL for a file from S3.
*
* - When `visibility` is `'public'` on the provider config, returns the plain
* public URL (custom domain if configured, otherwise the provider's public URL).
* No signing is performed — the bucket/objects must already be publicly accessible.
*
* - When `visibility` is `'private'` (default), generates a presigned GET URL
* signed against the provider's S3 API endpoint. The URL expires after
* `expiresIn` seconds (default 3600).
*
* Note: Presigned URLs are always signed against the S3 API endpoint (`buildS3Url`),
* never the custom domain. The `host` header is part of the SigV4 canonical request
* and must match the endpoint being called. Custom domains (CDNs, CloudFront, R2
* custom domains) cannot be used as the signing base.
*/
export async function generatePresignedDownloadUrl(
export async function generateDownloadUrl(
uploadConfig: UploadConfig,
key: string,
expiresIn: number = 3600
): Promise<string> {
const awsClient = createS3Client(uploadConfig);
const config = getS3CompatibleConfig(uploadConfig.provider);

// Public bucket: return the plain URL (custom domain if set, otherwise S3 URL).
// No signing needed — objects are already publicly accessible.
if (config.visibility === "public") {
return buildPublicUrl(key, config);
}

// Private bucket (default): generate a presigned GET URL.
// Must sign against the S3 API endpoint, never the custom domain.
const awsClient = createS3Client(uploadConfig);

try {
const s3Url = buildPublicUrl(key, config);
const s3Url = buildS3Url(key, config);
const url = new URL(s3Url);

// Add expiration as query parameter
Expand Down
2 changes: 1 addition & 1 deletion packages/pushduck/src/core/storage/storage-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class StorageInstance {
// Download operations - grouped under 'download' namespace
download = {
presignedUrl: (key: string, expiresIn?: number) =>
client.generatePresignedDownloadUrl(this.config, key, expiresIn),
client.generateDownloadUrl(this.config, key, expiresIn),

url: (key: string) => client.getFileUrl(this.config, key),
};
Expand Down
Loading