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
132 changes: 132 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,137 @@
# =====================================================================
# SUPABASE CONFIGURATION
# =====================================================================
# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api

NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

# =====================================================================
# HOSTED MODE (Optional - for paid hosted version only)
# =====================================================================
# Set to 'true' to enable hosted features (projects, billing, analytics)
# Leave commented out or set to 'false' for self-hosted/OSS mode

# PAPERCLIP_HOSTED=true
# NEXT_PUBLIC_PAPERCLIP_HOSTED=true

# =====================================================================
# BILLING (Hosted Mode Only)
# =====================================================================
# Uncomment the provider you're using

# Polar.sh
# POLAR_API_KEY=your-polar-api-key
# POLAR_WEBHOOK_SECRET=your-polar-webhook-secret
# POLAR_PRODUCT_ID=your-polar-product-id

# Flowglad (alternative)
# FLOWGLAD_API_KEY=your-flowglad-api-key
# FLOWGLAD_WEBHOOK_SECRET=your-flowglad-webhook-secret

# Stripe (alternative)
# STRIPE_SECRET_KEY=your-stripe-secret-key
# STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret
# STRIPE_PRICE_ID=your-stripe-price-id

# =====================================================================
# ANALYTICS (Hosted Mode Only)
# =====================================================================

# PostHog
# POSTHOG_API_KEY=your-posthog-api-key
# POSTHOG_HOST=https://app.posthog.com
# NEXT_PUBLIC_POSTHOG_KEY=your-posthog-public-key
# NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com

# =====================================================================
# CACHE (Optional - available for both OSS and Hosted)
# =====================================================================
# Content caching using object storage (R2, Vercel Blob, Supabase Storage)
#
# Architecture:
# User → Next.js API → Object Storage Cache → DB (if miss)
#
# Your app is the gateway - users never access storage directly.
# This gives you control over URLs, metrics, rate limiting, auth.
# CDN accelerates the object storage reads.

# Options: disabled (default), filesystem, supabase-storage, r2, vercel-blob
# CACHE_PROVIDER=disabled

# File system cache (local disk, no external dependencies)
# Good for single-instance OSS deployments
# CACHE_PROVIDER=filesystem
# CACHE_FILESYSTEM_DIR=./.cache

# Supabase Storage cache (uses your existing Supabase instance)
# FREE for OSS users, persistent, globally distributed via CDN
# Requires: 'cache' bucket to be created (public, no RLS)
# CACHE_PROVIDER=supabase-storage
# CACHE_STORAGE_BUCKET=cache

# Cloudflare R2 cache (cheap, fast, globally distributed)
# $0.015/GB storage, FREE egress - recommended for production/hosted
# Requires: @aws-sdk/client-s3 package (npm install @aws-sdk/client-s3)
# CACHE_PROVIDER=r2
# R2_ACCOUNT_ID=your-cloudflare-account-id
# R2_ACCESS_KEY_ID=your-r2-access-key-id
# R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
# R2_BUCKET_NAME=cache
# R2_CDN_URL=https://your-custom-domain.com (optional)

# Vercel Blob cache (simple, integrated with Vercel deployments)
# Requires: @vercel/blob package (npm install @vercel/blob)
# CACHE_PROVIDER=vercel-blob
# BLOB_READ_WRITE_TOKEN=your-vercel-blob-token (set by Vercel)

# =====================================================================
# MEDIA STORAGE (Images & Files)
# =====================================================================
# Storage for user-uploaded images and files
# Default: Supabase Storage (OSS)

# Options: supabase-storage (default), r2
# MEDIA_STORAGE_PROVIDER=supabase-storage

# Supabase Storage (default for OSS)
# Uses your existing Supabase instance
# Free tier: 1GB storage, 2GB bandwidth/month
# MEDIA_STORAGE_PROVIDER=supabase-storage
# MEDIA_STORAGE_BUCKET=media

# Cloudflare R2 (hosted option)
# Cheap ($0.015/GB storage, FREE egress)
# Requires: @aws-sdk/client-s3
# MEDIA_STORAGE_PROVIDER=r2
# MEDIA_R2_ACCOUNT_ID=your-cloudflare-account-id
# MEDIA_R2_ACCESS_KEY_ID=your-r2-access-key
# MEDIA_R2_SECRET_ACCESS_KEY=your-r2-secret
# MEDIA_R2_BUCKET_NAME=media
# MEDIA_R2_CDN_URL=https://media.yourdomain.com (optional)

# =====================================================================
# VIDEO STORAGE (Optional)
# =====================================================================
# Video hosting with transcoding and adaptive streaming
# Disabled by default - enable if you need video support

# Options: disabled (default), cloudflare-stream, bunny
# VIDEO_PROVIDER=disabled

# Cloudflare Stream
# $5/1000 min stored, $1/1000 min delivered
# Good for small-medium video usage
# VIDEO_PROVIDER=cloudflare-stream
# CLOUDFLARE_STREAM_API_TOKEN=your-api-token
# CLOUDFLARE_STREAM_ACCOUNT_ID=your-account-id

# Bunny.net Stream (cheapest!)
# $0.005/GB stored, $0.01/GB delivered
# Best value for money
# VIDEO_PROVIDER=bunny
# BUNNY_STREAM_API_KEY=your-api-key
# BUNNY_STREAM_LIBRARY_ID=your-library-id
# BUNNY_STREAM_CDN_HOSTNAME=your-cdn-hostname.b-cdn.net
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ next-env.d.ts
.vscode

notes

# cache
.cache
193 changes: 193 additions & 0 deletions lib/hosted/cache/r2-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import type { CacheProvider, CacheKey } from '@/lib/providers/cache/types'

/**
* Cloudflare R2 Cache Provider (Hosted)
*
* Stores cache as JSON blobs in Cloudflare R2.
* Cheap ($0.015/GB storage, FREE egress), fast, globally distributed.
* Perfect for production/hosted deployments.
*
* Requires: @cloudflare/workers-types or aws-sdk/client-s3 (S3-compatible API)
*
* Setup:
* 1. Create R2 bucket in Cloudflare dashboard
* 2. Get Access Key ID and Secret Access Key
* 3. Optional: Configure custom domain with CDN
*/
export class R2CacheProvider implements CacheProvider {
private s3Client: any
private bucketName: string
private cdnUrl?: string

constructor(config: {
accountId: string
accessKeyId: string
secretAccessKey: string
bucketName: string
cdnUrl?: string // Optional: Custom domain for R2 bucket
}) {
this.bucketName = config.bucketName
this.cdnUrl = config.cdnUrl

// Initialize S3-compatible client for R2
// Requires: npm install @aws-sdk/client-s3
// const { S3Client } = require('@aws-sdk/client-s3')

// this.s3Client = new S3Client({
// region: 'auto',
// endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
// credentials: {
// accessKeyId: config.accessKeyId,
// secretAccessKey: config.secretAccessKey,
// },
// })

throw new Error('R2 provider not yet implemented. Install @aws-sdk/client-s3 and uncomment.')
}

async get<T = any>(key: CacheKey): Promise<T | null> {
try {
// const { GetObjectCommand } = require('@aws-sdk/client-s3')
// const filePath = this.getFilePath(key)

// const command = new GetObjectCommand({
// Bucket: this.bucketName,
// Key: filePath,
// })

// const response = await this.s3Client.send(command)
// const text = await response.Body.transformToString()

// return JSON.parse(text) as T

return null
} catch (err: any) {
if (err?.name === 'NoSuchKey') {
return null
}
console.error('R2 cache read error:', err)
return null
}
}

async set<T = any>(key: CacheKey, value: T): Promise<void> {
try {
// const { PutObjectCommand } = require('@aws-sdk/client-s3')
// const filePath = this.getFilePath(key)
// const json = JSON.stringify(value)

// const command = new PutObjectCommand({
// Bucket: this.bucketName,
// Key: filePath,
// Body: json,
// ContentType: 'application/json',
// CacheControl: 'public, max-age=3600', // CDN cache for 1 hour
// })

// await this.s3Client.send(command)
} catch (err) {
console.error('R2 cache write error:', err)
throw err
}
}

async delete(key: CacheKey): Promise<void> {
try {
// const { DeleteObjectCommand } = require('@aws-sdk/client-s3')
// const filePath = this.getFilePath(key)

// const command = new DeleteObjectCommand({
// Bucket: this.bucketName,
// Key: filePath,
// })

// await this.s3Client.send(command)
} catch (err) {
console.error('R2 cache delete error:', err)
}
}

async deletePattern(pattern: string): Promise<void> {
try {
// const { ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3')

// // List all objects
// const listCommand = new ListObjectsV2Command({
// Bucket: this.bucketName,
// })

// const response = await this.s3Client.send(listCommand)
// if (!response.Contents) return

// // Convert glob pattern to regex
// const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$')

// // Find matching keys
// const toDelete = response.Contents
// .map((obj: any) => this.fileNameToKey(obj.Key))
// .filter((key: string) => regex.test(key))
// .map((key: string) => ({ Key: this.getFilePath(key) }))

// if (toDelete.length > 0) {
// const deleteCommand = new DeleteObjectsCommand({
// Bucket: this.bucketName,
// Delete: { Objects: toDelete },
// })

// await this.s3Client.send(deleteCommand)
// }
} catch (err) {
console.error('R2 cache pattern delete error:', err)
}
}

isEnabled(): boolean {
return true
}

async clear(): Promise<void> {
try {
// const { ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3')

// const listCommand = new ListObjectsV2Command({
// Bucket: this.bucketName,
// })

// const response = await this.s3Client.send(listCommand)
// if (!response.Contents || response.Contents.length === 0) return

// const toDelete = response.Contents.map((obj: any) => ({ Key: obj.Key }))

// const deleteCommand = new DeleteObjectsCommand({
// Bucket: this.bucketName,
// Delete: { Objects: toDelete },
// })

// await this.s3Client.send(deleteCommand)
} catch (err) {
console.error('R2 cache clear error:', err)
}
}

getPublicUrl(key: CacheKey): string | null {
if (!this.cdnUrl) return null

const filePath = this.getFilePath(key)
return `${this.cdnUrl}/${filePath}`
}

/**
* Convert cache key to file path
*/
private getFilePath(key: CacheKey): string {
const safeName = key.replace(/[^a-zA-Z0-9-_:]/g, '_')
return `${safeName}.json`
}

/**
* Convert file name back to cache key
*/
private fileNameToKey(fileName: string): CacheKey {
return fileName.replace(/\.json$/, '')
}
}
Loading