diff --git a/.vscode/settings.json b/.vscode/settings.json
index ddaa4892..f3ea8edd 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -24,6 +24,7 @@
"jpgs",
"Lightbox",
"Makernote",
+ "mitigations",
"nanoids",
"nextjs",
"parameterizes",
@@ -31,6 +32,8 @@
"Provia",
"qaub",
"QRSTUVWXYZ",
+ "ratelimit",
+ "ratelimiter",
"Reala",
"skippable",
"sonner",
@@ -38,6 +41,7 @@
"thephotoblog",
"trpc",
"unnest",
+ "upstash",
"UsKSGcbt",
"Velvia",
"WRHGZC",
diff --git a/README.md b/README.md
index 456ddc6d..7d84d313 100644
--- a/README.md
+++ b/README.md
@@ -68,12 +68,25 @@ Installation
2. Click "Speed Insights" tab
3. Follow "Enable Speed Insights" instructions (`@vercel/speed-insights` already included)
-### 7. Optional configuration
+### 7. Add experimental AI text generation
+
+_⚠️ READ BEFORE PROCEEDING_
+- _Usage of this feature will result in fees from OpenAI._
+- _When enabling AI text generation, follow all recommended mitigations in order to avoid unexpected charges and attacks._
+- _Make sure your OpenAI secret key is not prefixed with NEXT_PUBLIC._
+
+1. Setup OpenAI
+ - If you don't already have one, create an [OpenAI](https://openai.com) account
+ - Generate an API key and store as `OPENAI_SECRET_KEY`
+ - Setup usage limits to avoid unexpected charges (_recommended_)
+2. Add rate limiting (_recommended_)
+ - As an additional precaution, create a [Vercel KV](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) store and link it to your project in order to enable rate limiting
+
+### 8. Optional configuration
- `NEXT_PUBLIC_PRO_MODE = 1` enables higher quality image storage for jpgs (will result in increased storage usage)
- `NEXT_PUBLIC_BLUR_DISABLED = 1` prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage)
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data
-- `OPENAI_SECRET_KEY = [Your Key]` enables experimental support for AI-generated text descriptions
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api`
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
diff --git a/package.json b/package.json
index 5c86d0da..73431121 100644
--- a/package.json
+++ b/package.json
@@ -23,8 +23,10 @@
"@types/react-dom": "18.2.21",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
+ "@upstash/ratelimit": "^1.0.1",
"@vercel/analytics": "^1.2.2",
"@vercel/blob": "^0.22.1",
+ "@vercel/kv": "^1.0.1",
"@vercel/postgres": "0.7.2",
"@vercel/speed-insights": "^1.0.10",
"ai": "^3.0.13",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5c3c822e..70a1fae3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,12 +47,18 @@ dependencies:
'@typescript-eslint/parser':
specifier: ^7.2.0
version: 7.2.0(eslint@8.57.0)(typescript@5.4.2)
+ '@upstash/ratelimit':
+ specifier: ^1.0.1
+ version: 1.0.1
'@vercel/analytics':
specifier: ^1.2.2
version: 1.2.2(next@14.1.4)(react@18.2.0)
'@vercel/blob':
specifier: ^0.22.1
version: 0.22.1
+ '@vercel/kv':
+ specifier: ^1.0.1
+ version: 1.0.1
'@vercel/postgres':
specifier: 0.7.2
version: 0.7.2
@@ -3110,6 +3116,31 @@ packages:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
dev: false
+ /@upstash/core-analytics@0.0.7:
+ resolution: {integrity: sha512-lC2j5efqb1haX/fpTGaPUx1rue1WUkOZBVHDzCB7eMIVsRdFFp4xiHtyH/G9omiR1zj39fU5SCTWFiKJH3KOpw==}
+ engines: {node: '>=16.0.0'}
+ dependencies:
+ '@upstash/redis': 1.28.4
+ dev: false
+
+ /@upstash/ratelimit@1.0.1:
+ resolution: {integrity: sha512-G9LZ7idhlkuYknbUngCB3qzd7QnkK1xDkFG5jRtEJZuOUS5UKJ0UTKbhalCtp39eX2wu2Ubv8W7HCeaJQOWM0A==}
+ dependencies:
+ '@upstash/core-analytics': 0.0.7
+ dev: false
+
+ /@upstash/redis@1.25.1:
+ resolution: {integrity: sha512-ACj0GhJ4qrQyBshwFgPod6XufVEfKX2wcaihsEvSdLYnY+m+pa13kGt1RXm/yTHKf4TQi/Dy2A8z/y6WUEOmlg==}
+ dependencies:
+ crypto-js: 4.2.0
+ dev: false
+
+ /@upstash/redis@1.28.4:
+ resolution: {integrity: sha512-UalkSAny/dz1m8giEhD3Y5ru1o+CPHI32wFyS3MyzDzj2TRvEN+lTw+mPwi20ojk0H2gs8TBW3qsrvwuLLy+pA==}
+ dependencies:
+ crypto-js: 4.2.0
+ dev: false
+
/@vercel/analytics@1.2.2(next@14.1.4)(react@18.2.0):
resolution: {integrity: sha512-X0rctVWkQV1e5Y300ehVNqpOfSOufo7ieA5PIdna8yX/U7Vjz0GFsGf4qvAhxV02uQ2CVt7GYcrFfddXXK2Y4A==}
peerDependencies:
@@ -3136,6 +3167,13 @@ packages:
undici: 5.28.3
dev: false
+ /@vercel/kv@1.0.1:
+ resolution: {integrity: sha512-uTKddsqVYS2GRAM/QMNNXCTuw9N742mLoGRXoNDcyECaxEXvIHG0dEY+ZnYISV4Vz534VwJO+64fd9XeSggSKw==}
+ engines: {node: '>=14.6'}
+ dependencies:
+ '@upstash/redis': 1.25.1
+ dev: false
+
/@vercel/postgres@0.7.2:
resolution: {integrity: sha512-IqR/ZAvoPGcPaXl9eWWB5KaA+w/81RzZa/18P4izQRHpNBkTGt9HwGfYi9+wut5UgxNq4QSX9A7HIQR6QDvX2Q==}
engines: {node: '>=14.6'}
@@ -3991,6 +4029,10 @@ packages:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
dev: false
+ /crypto-js@4.2.0:
+ resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+ dev: false
+
/css-tree@2.3.1:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
diff --git a/src/services/openai.ts b/src/services/openai.ts
index 602d5a02..4ef1661b 100644
--- a/src/services/openai.ts
+++ b/src/services/openai.ts
@@ -2,13 +2,34 @@
import OpenAI from 'openai';
import { createStreamableValue, render } from 'ai/rsc';
+import { kv } from '@/services/vercel-kv';
+import { Ratelimit } from '@upstash/ratelimit';
+
+const RATE_LIMIT_IDENTIFIER = 'openai-image-query';
+const RATE_LIMIT_MAX_QUERIES_PER_HOUR = 100;
const provider = new OpenAI({ apiKey: process.env.OPENAI_SECRET_KEY });
+// Allows 100 requests per hour
+const ratelimit = kv
+ ? new Ratelimit({
+ redis: kv,
+ limiter: Ratelimit.slidingWindow(RATE_LIMIT_MAX_QUERIES_PER_HOUR, '1h'),
+ })
+ : undefined;
+
export const streamOpenAiImageQuery = async (
imageBase64: string,
query: string,
) => {
+ if (ratelimit) {
+ const { success } = await ratelimit.limit(RATE_LIMIT_IDENTIFIER);
+ if (!success) {
+ console.error('OpenAI rate limit exceeded');
+ throw new Error('OpenAI rate limit exceeded');
+ }
+ }
+
const stream = createStreamableValue('');
render({
diff --git a/src/services/vercel-kv.ts b/src/services/vercel-kv.ts
new file mode 100644
index 00000000..f6ef92e8
--- /dev/null
+++ b/src/services/vercel-kv.ts
@@ -0,0 +1,10 @@
+import { createClient } from '@vercel/kv';
+
+export const kv =
+ process.env.REDIS_REST_API_URL &&
+ process.env.REDIS_REST_API_TOKEN
+ ? createClient({
+ url: process.env.REDIS_REST_API_URL,
+ token: process.env.REDIS_REST_API_TOKEN,
+ })
+ : undefined;
diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx
index 1fb9ec0d..87c2442d 100644
--- a/src/site/SiteChecklistClient.tsx
+++ b/src/site/SiteChecklistClient.tsx
@@ -20,10 +20,12 @@ import { toastSuccess } from '@/toast';
import { ConfigChecklistStatus } from './config';
import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/services/storage';
+import { HiSparkles } from 'react-icons/hi';
export default function SiteChecklistClient({
- hasPostgres,
- hasStorage,
+ hasVercelPostgres,
+ hasVercelKV,
+ hasStorageProvider,
hasVercelBlobStorage,
hasCloudflareR2Storage,
hasAwsS3Storage,
@@ -140,7 +142,7 @@ export default function SiteChecklistClient({
>
{renderLink(
@@ -152,13 +154,13 @@ export default function SiteChecklistClient({
and connect to project
{renderSubStatus(
@@ -266,6 +268,38 @@ export default function SiteChecklistClient({
{renderEnvVars(['NEXT_PUBLIC_SITE_DOMAIN'])}
+ }
+ optional
+ >
+
+ Store your OpenAI secret key in order to add experimental support
+ for AI-generated text descriptions and enable an invisible field
+ called {'"Semantic Description"'} used to support CMD-K search
+ {renderEnvVars(['OPENAI_SECRET_KEY'])}
+
+
+ {renderLink(
+ // eslint-disable-next-line max-len
+ 'https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database',
+ 'Create Vercel KV store',
+ )}
+ {' '}
+ and connect to project in order to enable rate limiting
+
+
}
@@ -301,18 +335,6 @@ export default function SiteChecklistClient({
collection/display of location-based data
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
-
- Store your OpenAI secret key in order to add experimental support
- for AI-generated text descriptions and enable an invisible field
- called {'"Semantic Description"'} used to support CMD-K search
- {renderEnvVars(['OPENAI_SECRET_KEY'])}
-
0;
+
+// STORAGE: VERCEL KV
+export const HAS_VERCEL_KV =
+ (process.env.REDIS_REST_API_URL ?? '').length > 0 &&
+ (process.env.REDIS_REST_API_TOKEN ?? '').length > 0;
+
// STORAGE: VERCEL BLOB
export const HAS_VERCEL_BLOB_STORAGE =
(process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0;
@@ -102,11 +111,12 @@ export const OG_TEXT_BOTTOM_ALIGNMENT =
export const HIGH_DENSITY_GRID = GRID_ASPECT_RATIO <= 1;
export const CONFIG_CHECKLIST_STATUS = {
- hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0,
+ hasVercelPostgres: HAS_VERCEL_POSTGRES,
+ hasVercelKV: HAS_VERCEL_KV,
hasVercelBlobStorage: HAS_VERCEL_BLOB_STORAGE,
hasCloudflareR2Storage: HAS_CLOUDFLARE_R2_STORAGE,
hasAwsS3Storage: HAS_AWS_S3_STORAGE,
- hasStorage:
+ hasStorageProvider:
HAS_VERCEL_BLOB_STORAGE ||
HAS_CLOUDFLARE_R2_STORAGE ||
HAS_AWS_S3_STORAGE,
@@ -135,7 +145,7 @@ export const CONFIG_CHECKLIST_STATUS = {
export type ConfigChecklistStatus = typeof CONFIG_CHECKLIST_STATUS;
export const IS_SITE_READY =
- CONFIG_CHECKLIST_STATUS.hasPostgres &&
- CONFIG_CHECKLIST_STATUS.hasStorage &&
+ CONFIG_CHECKLIST_STATUS.hasVercelPostgres &&
+ CONFIG_CHECKLIST_STATUS.hasStorageProvider &&
CONFIG_CHECKLIST_STATUS.hasAuthSecret &&
CONFIG_CHECKLIST_STATUS.hasAdminUser;