Skip to content
Open
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
94 changes: 91 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@ export default class Pasterly extends Plugin {
new Notice('Please set your S3 credentials in settings first.');
return;
}
} else if (this.settings.storageType === 'r2') {
if (!this.settings.r2AccountId) {
new Notice('Please set your R2 Account ID in settings first.');
return;
}
if (!this.settings.r2BucketName) {
new Notice('Please set your R2 Bucket Name in settings first.');
return;
}
if (!this.settings.r2AccessKeyId || !this.settings.r2SecretAccessKey) {
new Notice('Please set your R2 credentials in settings first.');
return;
}
}
Comment on lines +104 to 117
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

🧩 Analysis chain

🌐 Web query:

Cloudflare R2 public buckets custom domains r2.dev public URL required for object access

💡 Result:

No, enabling a Cloudflare R2 public bucket with a custom domain does not require enabling or using the r2.dev public URL for object access. The two options—custom domains and r2.dev subdomains—are independent. You can expose your bucket solely via a custom domain you control without enabling r2.dev access. Key points from official Cloudflare R2 documentation: - Public buckets can be set up in two independent ways: custom domain or r2.dev subdomain (for non-production, rate-limited use). - "Enabling custom domains does not require enabling r2.dev access." - Custom domains enable advanced features like WAF, caching, access controls, and bot management, which are unavailable with r2.dev. - Disabling r2.dev public access is recommended when using custom domains with security products, but it's optional—r2.dev can remain disabled entirely. - Objects are accessible directly via the custom domain (e.g., https://yourdomain.com/object.txt) after connecting the domain in the R2 dashboard and adding the required CNAME record. To set up: 1. Add your domain as a zone in the same Cloudflare account. 2. In R2 bucket Settings > Custom Domains, add and connect the domain. 3. Public access via that domain is enabled independently of r2.dev. r2.dev is optional, development-only, and should be avoided for production or CNAME pointing.

Citations:


🏁 Script executed:

# First, find the storageProviders.ts file and check how r2PublicBaseUrl is used
fd -t f "storageProviders" -o -name "*storage*"

Repository: easternkite/Pasterly

Length of output: 294


🏁 Script executed:

# Search for r2PublicBaseUrl usage in the codebase
rg "r2PublicBaseUrl" -A 3 -B 3

Repository: easternkite/Pasterly

Length of output: 3326


🏁 Script executed:

# Also check for R2 provider implementation to see how it handles the public URL
rg "r2|R2" src/storageProviders.ts | head -50

Repository: easternkite/Pasterly

Length of output: 1043


🏁 Script executed:

# Find the S3 provider implementation
fd -t f "\.ts$" | xargs rg "class.*S3.*Provider|publicBaseUrl" | head -40

Repository: easternkite/Pasterly

Length of output: 718


🏁 Script executed:

# Look for URL generation logic in storage providers
rg "publicBaseUrl|getUrl|return.*http" src/storageProviders.ts -A 5 -B 2

Repository: easternkite/Pasterly

Length of output: 2588


🏁 Script executed:

# Check if there's an S3 provider implementation file
fd -t f "\.(ts|js)$" src/ | xargs rg "publicBaseUrl.*null|null.*publicBaseUrl"

Repository: easternkite/Pasterly

Length of output: 484


🏁 Script executed:

# Get the complete buildPublicUrl method
rg -A 20 "private buildPublicUrl" src/storageProviders.ts

Repository: easternkite/Pasterly

Length of output: 911


🏁 Script executed:

# Get the complete buildPublicUrl method including the default case
rg -A 30 "private buildPublicUrl" src/storageProviders.ts

Repository: easternkite/Pasterly

Length of output: 1269


🏁 Script executed:

# Let's check what the R2 endpoint looks like and how it's constructed
rg -B 5 -A 5 "r2.cloudflarestorage.com" src/storageProviders.ts

Repository: easternkite/Pasterly

Length of output: 573


🏁 Script executed:

# Check if there's any documentation or comments about R2 public access
rg -i "r2|cloudflare" src/ -A 2 -B 2 | head -80

Repository: easternkite/Pasterly

Length of output: 3700


🏁 Script executed:

# Let's verify the complete flow: check if there's any bucket policy or public access handling
rg "bucket|public|acl|policy" src/storageProviders.ts -i | head -20

Repository: easternkite/Pasterly

Length of output: 1466


🏁 Script executed:

# Double-check: when endpoint is used in buildPublicUrl, what URL is constructed for R2?
# Let's trace through the logic for R2 with null publicBaseUrl
cat > /tmp/check_r2_url.js << 'EOF'
// R2 endpoint: https://ACCOUNT_ID.r2.cloudflarestorage.com
const endpoint = 'https://example123.r2.cloudflarestorage.com';
const bucketName = 'my-bucket';
const objectPath = 'pasterly/image.png';

// Simulate buildPublicUrl logic when publicBaseUrl is null
const endpointUrl = new URL(endpoint);
const cleanPathname = endpointUrl.pathname.replace(/\/+$/, '');

// R2 doesn't set forcePathStyle, so this branch:
endpointUrl.hostname = `${bucketName}.${endpointUrl.hostname}`;
const resultUrl = endpointUrl.toString() + '/' + objectPath;

console.log('Generated URL:', resultUrl);
console.log('Note: This uses the PRIVATE R2 API endpoint, not public access');
EOF
node /tmp/check_r2_url.js

Repository: easternkite/Pasterly

Length of output: 215


Require R2 Public Base URL validation before initializing.

R2 uploads require a public custom domain or R2.dev URL to generate accessible image links. Without one, the code falls back to the private R2 API endpoint (*.r2.cloudflarestorage.com), which cannot be accessed publicly and produces broken embedded images. The validation must be added, and the UI description should reflect that this field is required, not optional.

Proposed validation and UI copy update
 			} else if (this.settings.storageType === 'r2') {
 				if (!this.settings.r2AccountId) {
 					new Notice('Please set your R2 Account ID in settings first.');
 					return;
 				}
 				if (!this.settings.r2BucketName) {
 					new Notice('Please set your R2 Bucket Name in settings first.');
 					return;
 				}
 				if (!this.settings.r2AccessKeyId || !this.settings.r2SecretAccessKey) {
 					new Notice('Please set your R2 credentials in settings first.');
 					return;
 				}
+				if (!this.settings.r2PublicBaseUrl) {
+					new Notice('Please set your R2 Public Base URL in settings first.');
+					return;
+				}
 			}
 			new Setting(containerEl)
 				.setName('R2 Public Base URL')
-				.setDesc('Optional: Custom Domain or R2.dev URL (e.g., https://images.example.com)')
+				.setDesc('Required: Custom Domain or R2.dev URL used for returned image links (e.g., https://images.example.com)')
 				.addText(text => text

Also applies to: 514-524

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

In `@src/main.ts` around lines 104 - 117, The R2 branch in the storage
initialization (the else if checking this.settings.storageType === 'r2')
currently only validates account, bucket and keys; add a required validation for
the R2 public base URL (e.g. this.settings.r2PublicBaseUrl) and ensure it is a
publicly accessible domain (custom domain or r2.dev pattern) before proceeding,
returning a Notice if missing/invalid to prevent fallback to the private
*.r2.cloudflarestorage.com endpoint; also update the UI/settings
label/description for r2PublicBaseUrl to mark it required and explain it must be
a public custom domain or r2.dev URL so embedded images are accessible.


this.storageProvider = createStorageProvider(this.settings.storageType, {
Expand All @@ -117,6 +130,11 @@ export default class Pasterly extends Plugin {
s3SessionToken: this.settings.s3SessionToken,
s3PublicBaseUrl: this.settings.s3PublicBaseUrl,
s3ForcePathStyle: this.settings.s3ForcePathStyle,
r2AccountId: this.settings.r2AccountId,
r2BucketName: this.settings.r2BucketName,
r2AccessKeyId: this.settings.r2AccessKeyId,
r2SecretAccessKey: this.settings.r2SecretAccessKey,
r2PublicBaseUrl: this.settings.r2PublicBaseUrl,
});
} catch (error) {
console.error('Failed to initialize storage provider:', error);
Expand Down Expand Up @@ -206,14 +224,18 @@ export default class Pasterly extends Plugin {
const normalizedCdnBaseUrl = normalizeOptionalBaseUrl(this.settings.gcsCdnBaseUrl);
const normalizedS3Endpoint = normalizeOptionalBaseUrl(this.settings.s3Endpoint);
const normalizedS3PublicBaseUrl = normalizeOptionalBaseUrl(this.settings.s3PublicBaseUrl);
const normalizedR2PublicBaseUrl = normalizeOptionalBaseUrl(this.settings.r2PublicBaseUrl);

if (
normalizedCdnBaseUrl !== this.settings.gcsCdnBaseUrl ||
normalizedS3Endpoint !== this.settings.s3Endpoint ||
normalizedS3PublicBaseUrl !== this.settings.s3PublicBaseUrl
normalizedS3PublicBaseUrl !== this.settings.s3PublicBaseUrl ||
normalizedR2PublicBaseUrl !== this.settings.r2PublicBaseUrl
) {
this.settings.gcsCdnBaseUrl = normalizedCdnBaseUrl;
this.settings.s3Endpoint = normalizedS3Endpoint;
this.settings.s3PublicBaseUrl = normalizedS3PublicBaseUrl;
this.settings.r2PublicBaseUrl = normalizedR2PublicBaseUrl;
await this.saveSettings();
}
}
Expand Down Expand Up @@ -242,9 +264,10 @@ class PasterlySettingTab extends PluginSettingTab {
.addDropdown(dropdown => dropdown
.addOption('firebase', 'Firebase Storage')
.addOption('gcs', 'Google Cloud Storage')
.addOption('s3', 'S3-compatible Storage (AWS S3 / R2)')
.addOption('s3', 'S3-compatible Storage (AWS S3 / MinIO)')
.addOption('r2', 'Cloudflare R2')
.setValue(this.plugin.settings.storageType)
.onChange(async (value: 'firebase' | 'gcs' | 's3') => {
.onChange(async (value: 'firebase' | 'gcs' | 's3' | 'r2') => {
this.plugin.settings.storageType = value;
await this.plugin.saveSettings();
this.plugin.debouncedInitializeStorage();
Expand Down Expand Up @@ -436,6 +459,71 @@ class PasterlySettingTab extends PluginSettingTab {
}));
}

if (this.plugin.settings.storageType === 'r2') {
new Setting(containerEl)
.setName('R2 Account ID')
.setDesc('Cloudflare Account ID (found in R2 dashboard)')
.addText(text => text
.setPlaceholder('your-account-id')
.setValue(this.plugin.settings.r2AccountId)
.onChange(async (value) => {
this.plugin.settings.r2AccountId = value.trim();
await this.plugin.saveSettings();
this.plugin.debouncedInitializeStorage();
}));

new Setting(containerEl)
.setName('R2 Bucket Name')
.setDesc('Name of your R2 bucket')
.addText(text => text
.setPlaceholder('my-bucket-name')
.setValue(this.plugin.settings.r2BucketName)
.onChange(async (value) => {
this.plugin.settings.r2BucketName = value.trim();
await this.plugin.saveSettings();
this.plugin.debouncedInitializeStorage();
}));

new Setting(containerEl)
.setName('R2 Access Key ID')
.setDesc('R2 API Token Access Key ID')
.addText(text => text
.setPlaceholder('access-key-id')
.setValue(this.plugin.settings.r2AccessKeyId)
.onChange(async (value) => {
this.plugin.settings.r2AccessKeyId = value.trim();
await this.plugin.saveSettings();
this.plugin.debouncedInitializeStorage();
}));

new Setting(containerEl)
.setName('R2 Secret Access Key')
.setDesc('R2 API Token Secret Access Key')
.addText(text => {
text
.setPlaceholder('••••••••')
.setValue(this.plugin.settings.r2SecretAccessKey)
.onChange(async (value) => {
this.plugin.settings.r2SecretAccessKey = value.trim();
await this.plugin.saveSettings();
this.plugin.debouncedInitializeStorage();
});
text.inputEl.type = 'password';
});

new Setting(containerEl)
.setName('R2 Public Base URL')
.setDesc('Optional: Custom Domain or R2.dev URL (e.g., https://images.example.com)')
.addText(text => text
.setPlaceholder('https://images.example.com')
.setValue(this.plugin.settings.r2PublicBaseUrl)
.onChange(async (value) => {
this.plugin.settings.r2PublicBaseUrl = normalizeOptionalBaseUrl(value);
await this.plugin.saveSettings();
this.plugin.debouncedInitializeStorage();
}));
}

// Common Settings
new Setting(containerEl)
.setName('Fixed Size')
Expand Down
26 changes: 25 additions & 1 deletion src/storageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export class S3CompatibleStorageProvider implements StorageProvider {
* Factory function to create the appropriate storage provider
*/
export function createStorageProvider(
type: 'firebase' | 'gcs' | 's3',
type: 'firebase' | 'gcs' | 's3' | 'r2',
config: {
firebaseBucketUrl?: string;
gcsBucketName?: string;
Expand All @@ -317,6 +317,11 @@ export function createStorageProvider(
s3SessionToken?: string;
s3PublicBaseUrl?: string;
s3ForcePathStyle?: boolean;
r2AccountId?: string;
r2BucketName?: string;
r2AccessKeyId?: string;
r2SecretAccessKey?: string;
r2PublicBaseUrl?: string;
}
): StorageProvider {
switch (type) {
Expand Down Expand Up @@ -362,6 +367,25 @@ export function createStorageProvider(
forcePathStyle: config.s3ForcePathStyle || false,
});

case 'r2':
if (!config.r2AccountId) {
throw new Error('R2 account ID is required');
}
if (!config.r2BucketName) {
throw new Error('R2 bucket name is required');
}
if (!config.r2AccessKeyId || !config.r2SecretAccessKey) {
throw new Error('R2 access key ID and secret access key are required');
}
return new S3CompatibleStorageProvider({
bucketName: config.r2BucketName,
region: 'auto',
endpoint: `https://${config.r2AccountId}.r2.cloudflarestorage.com`,
accessKeyId: config.r2AccessKeyId,
secretAccessKey: config.r2SecretAccessKey,
publicBaseUrl: config.r2PublicBaseUrl || null,
});
Comment on lines +370 to +387
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

🧩 Analysis chain

🌐 Web query:

Cloudflare R2 public access custom domain r2.dev S3 API endpoint authenticated object URL documentation

💡 Result:

Cloudflare R2 supports public access via custom domains or r2.dev subdomains for unauthenticated GET requests on public buckets. The S3 API, including authenticated object access and presigned URLs, uses a separate endpoint: https://<ACCOUNT_ID>.r2.cloudflarestorage.com/<BUCKET_NAME>/<OBJECT_KEY>. Custom domains are for public HTTP access only (read-only, no S3 compatibility or authentication via S3 API). Key documentation points: - Enable public buckets in R2 dashboard Settings > Public access: Add custom domain (must be in same account/zone) or enable r2.dev (dev-only, rate-limited, no advanced features like WAF/caching). - Custom domain setup: R2 auto-adds CNAME; use Origin Rules to route/rewrite from main site (e.g., example.com/images/* -> imagesbucket.example.com). - r2.dev: https://.r2.dev/<OBJECT_KEY> for direct public access. - S3 API endpoint is always *.r2.cloudflarestorage.com; presigned URLs generated for it (e.g., using AWS SDK with endpoint configured), explicitly not compatible with custom domains—for auth on custom domains, use WAF HMAC or Zero Trust. - No direct authenticated S3 access via custom domain or r2.dev; those are public HTTP endpoints. Examples: - Public custom domain object URL: https://imagesbucket.example.com/image.png - r2.dev public URL: https://abc123.r2.dev/image.png - S3 authenticated/presigned: https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/image.png?X-Amz-Algorithm=... (generated via SDK with R2 API token).

Citations:


🏁 Script executed:

# First, let's look at the file structure and find the S3CompatibleStorageProvider class
fd -t f "storageProviders" --type typescript

Repository: easternkite/Pasterly

Length of output: 241


🏁 Script executed:

# Read the storageProviders.ts file to understand the S3CompatibleStorageProvider implementation
wc -l src/storageProviders.ts

Repository: easternkite/Pasterly

Length of output: 91


🏁 Script executed:

# Let's read the relevant sections - the R2 case and the S3CompatibleStorageProvider class
cat -n src/storageProviders.ts | sed -n '1,50p'

Repository: easternkite/Pasterly

Length of output: 2174


🏁 Script executed:

# Search for the S3CompatibleStorageProvider class definition and buildPublicUrl method
ast-grep --pattern 'class S3CompatibleStorageProvider {
  $$$
  buildPublicUrl($$$) {
    $$$
  }
  $$$
}'

Repository: easternkite/Pasterly

Length of output: 46


🏁 Script executed:

# Also search for how publicBaseUrl is used in the buildPublicUrl method
rg -A 10 "buildPublicUrl" src/storageProviders.ts

Repository: easternkite/Pasterly

Length of output: 930


🏁 Script executed:

# Get more context around buildPublicUrl to see the complete fallback logic
rg -B 5 -A 20 "buildPublicUrl" src/storageProviders.ts

Repository: easternkite/Pasterly

Length of output: 1933


🏁 Script executed:

# Get the S3CompatibleStorageProvider class definition and constructor
rg -B 5 -A 30 "class S3CompatibleStorageProvider" src/storageProviders.ts

Repository: easternkite/Pasterly

Length of output: 1489


🏁 Script executed:

# Check the factory function to see all R2-related config handling
sed -n '360,400p' src/storageProviders.ts

Repository: easternkite/Pasterly

Length of output: 1441


Require r2PublicBaseUrl instead of falling back to the R2 S3 API endpoint.

When r2PublicBaseUrl is optional and null, buildPublicUrl() falls back to constructing URLs from the endpoint https://{accountId}.r2.cloudflarestorage.com. This endpoint is the S3 API for authenticated access, not a public HTTP endpoint. Generated URLs like https://accountid.r2.cloudflarestorage.com/bucket/image.png require S3 authentication and cannot be embedded in public markdown. Make r2PublicBaseUrl required with validation to ensure users provide a proper public access endpoint (custom domain or r2.dev subdomain).

Proposed factory validation
         case 'r2':
             if (!config.r2AccountId) {
                 throw new Error('R2 account ID is required');
             }
             if (!config.r2BucketName) {
                 throw new Error('R2 bucket name is required');
             }
             if (!config.r2AccessKeyId || !config.r2SecretAccessKey) {
                 throw new Error('R2 access key ID and secret access key are required');
             }
+            if (!config.r2PublicBaseUrl) {
+                throw new Error('R2 public base URL is required for returned image URLs');
+            }
             return new S3CompatibleStorageProvider({
                 bucketName: config.r2BucketName,
                 region: 'auto',
                 endpoint: `https://${config.r2AccountId}.r2.cloudflarestorage.com`,
                 accessKeyId: config.r2AccessKeyId,
                 secretAccessKey: config.r2SecretAccessKey,
-                publicBaseUrl: config.r2PublicBaseUrl || null,
+                publicBaseUrl: config.r2PublicBaseUrl,
             });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case 'r2':
if (!config.r2AccountId) {
throw new Error('R2 account ID is required');
}
if (!config.r2BucketName) {
throw new Error('R2 bucket name is required');
}
if (!config.r2AccessKeyId || !config.r2SecretAccessKey) {
throw new Error('R2 access key ID and secret access key are required');
}
return new S3CompatibleStorageProvider({
bucketName: config.r2BucketName,
region: 'auto',
endpoint: `https://${config.r2AccountId}.r2.cloudflarestorage.com`,
accessKeyId: config.r2AccessKeyId,
secretAccessKey: config.r2SecretAccessKey,
publicBaseUrl: config.r2PublicBaseUrl || null,
});
case 'r2':
if (!config.r2AccountId) {
throw new Error('R2 account ID is required');
}
if (!config.r2BucketName) {
throw new Error('R2 bucket name is required');
}
if (!config.r2AccessKeyId || !config.r2SecretAccessKey) {
throw new Error('R2 access key ID and secret access key are required');
}
if (!config.r2PublicBaseUrl) {
throw new Error('R2 public base URL is required for returned image URLs');
}
return new S3CompatibleStorageProvider({
bucketName: config.r2BucketName,
region: 'auto',
endpoint: `https://${config.r2AccountId}.r2.cloudflarestorage.com`,
accessKeyId: config.r2AccessKeyId,
secretAccessKey: config.r2SecretAccessKey,
publicBaseUrl: config.r2PublicBaseUrl,
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/storageProviders.ts` around lines 370 - 387, The R2 case should require
and validate r2PublicBaseUrl instead of falling back to the S3 API endpoint: in
the 'r2' branch verify config.r2PublicBaseUrl is present and non-empty and throw
a clear Error if missing, then construct the S3CompatibleStorageProvider using
config.r2PublicBaseUrl as publicBaseUrl (do not use
`https://${config.r2AccountId}.r2.cloudflarestorage.com` fallback); update any
validation messages to reference r2PublicBaseUrl and keep existing checks for
r2AccountId, r2BucketName, r2AccessKeyId and r2SecretAccessKey (refer to the
'r2' case and S3CompatibleStorageProvider / buildPublicUrl usage).


default:
throw new Error(`Unknown storage type: ${type}`);
}
Expand Down
14 changes: 13 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface StorageProvider {
/**
* Storage type enumeration
*/
export type StorageType = 'firebase' | 'gcs' | 's3';
export type StorageType = 'firebase' | 'gcs' | 's3' | 'r2';

/**
* Settings interface for the Pasterly plugin
Expand Down Expand Up @@ -43,6 +43,13 @@ export interface PasterlySettings {
s3SessionToken: string;
s3PublicBaseUrl: string;
s3ForcePathStyle: boolean;

// Cloudflare R2 settings
r2AccountId: string;
r2BucketName: string;
r2AccessKeyId: string;
r2SecretAccessKey: string;
r2PublicBaseUrl: string;
}

export const DEFAULT_SETTINGS: PasterlySettings = {
Expand All @@ -61,4 +68,9 @@ export const DEFAULT_SETTINGS: PasterlySettings = {
s3SessionToken: '',
s3PublicBaseUrl: '',
s3ForcePathStyle: false,
r2AccountId: '',
r2BucketName: '',
r2AccessKeyId: '',
r2SecretAccessKey: '',
r2PublicBaseUrl: '',
};