mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-08 02:37:46 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78c4bcfa3f | ||
|
|
18a20aa1ca |
+10
@@ -163,6 +163,16 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Set to "false" to block private IPs, localhost, and internal hostnames
|
||||
# ALLOW_PRIVATE_URLS=false
|
||||
|
||||
# Behind a CDN such as Cloudflare (Optional)
|
||||
# The daily quota is counted per IP. By default the IP is the first
|
||||
# X-Forwarded-For entry, which visitors can set to anything. Name the header
|
||||
# your CDN fills with the visitor's real IP instead:
|
||||
# CLIENT_IP_HEADER=cf-connecting-ip
|
||||
# Then have the CDN add an X-Origin-Secret header with this value to every
|
||||
# request. API calls without it get 403, so nobody can skip the CDN and fake
|
||||
# the IP header above.
|
||||
# ORIGIN_SECRET=a-long-random-string
|
||||
|
||||
# Self-hosted deployment (Optional)
|
||||
# Self-hosted users may implement custom quota-management solutions,
|
||||
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
|
||||
|
||||
+11
-2
@@ -2,10 +2,19 @@
|
||||
* Generate a userId from request for tracking purposes.
|
||||
* Uses base64url encoding of IP for URL-safe identifier.
|
||||
* Note: base64 is reversible - this is NOT privacy protection.
|
||||
*
|
||||
* The first X-Forwarded-For entry is whatever the visitor sent, so behind a
|
||||
* CDN set CLIENT_IP_HEADER to the header it fills with the real IP (e.g.
|
||||
* cf-connecting-ip), and ORIGIN_SECRET so requests that skip the CDN are
|
||||
* refused (see proxy.ts).
|
||||
*/
|
||||
export function getUserIdFromRequest(req: Request): string {
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
const ipHeader = process.env.CLIENT_IP_HEADER
|
||||
const rawIp =
|
||||
(ipHeader
|
||||
? req.headers.get(ipHeader)?.trim()
|
||||
: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()) ||
|
||||
"anonymous"
|
||||
return rawIp === "anonymous"
|
||||
? rawIp
|
||||
: `user-${Buffer.from(rawIp).toString("base64url")}`
|
||||
|
||||
Generated
+630
-387
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -31,15 +31,15 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.191",
|
||||
"@ai-sdk/anthropic": "^3.0.127",
|
||||
"@ai-sdk/azure": "^3.0.133",
|
||||
"@ai-sdk/deepseek": "^2.0.71",
|
||||
"@ai-sdk/google": "^3.0.130",
|
||||
"@ai-sdk/google-vertex": "^4.0.210",
|
||||
"@ai-sdk/openai": "^3.0.124",
|
||||
"@ai-sdk/openai-compatible": "^2.0.81",
|
||||
"@ai-sdk/react": "^3.0.303",
|
||||
"@ai-sdk/amazon-bedrock": "^5.0.0",
|
||||
"@ai-sdk/anthropic": "^4.0.0",
|
||||
"@ai-sdk/azure": "^4.0.0",
|
||||
"@ai-sdk/deepseek": "^3.0.0",
|
||||
"@ai-sdk/google": "^4.0.0",
|
||||
"@ai-sdk/google-vertex": "^5.0.0",
|
||||
"@ai-sdk/openai": "^4.0.0",
|
||||
"@ai-sdk/openai-compatible": "^3.0.0",
|
||||
"@ai-sdk/react": "^4.0.0",
|
||||
"@aihubmix/ai-sdk-provider": "^2.2.1",
|
||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
@@ -50,7 +50,7 @@
|
||||
"@langfuse/tracing": "^4.4.9",
|
||||
"@next/third-parties": "^16.0.6",
|
||||
"@opennextjs/cloudflare": "^1.17.1",
|
||||
"@openrouter/ai-sdk-provider": "^2.10.0",
|
||||
"@openrouter/ai-sdk-provider": "^3.0.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
@@ -66,7 +66,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@xmldom/xmldom": "^0.9.8",
|
||||
"ai": "^6.0.300",
|
||||
"ai": "^7.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -77,7 +77,7 @@
|
||||
"nanoid": "^5.0.0",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.0.7",
|
||||
"ollama-ai-provider-v2": "^3.6.0",
|
||||
"ollama-ai-provider-v2": "^4.0.0",
|
||||
"pako": "^2.1.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.1.2",
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@openrouter/ai-sdk-provider": {
|
||||
"ai": "^6.0.1"
|
||||
"ai": "^7.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,19 @@ function getLocale(request: NextRequest): string | undefined {
|
||||
export function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname
|
||||
|
||||
// Skip API routes, static files, and Next.js internals
|
||||
if (pathname.startsWith("/api/")) {
|
||||
// With ORIGIN_SECRET set, API calls must come through the CDN that
|
||||
// adds this header. A call straight to the origin could fake the
|
||||
// CLIENT_IP_HEADER and get a fresh quota for every made-up IP.
|
||||
const secret = process.env.ORIGIN_SECRET
|
||||
if (secret && request.headers.get("x-origin-secret") !== secret) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip static files and Next.js internals
|
||||
if (
|
||||
pathname.startsWith("/api/") ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/drawio") ||
|
||||
pathname.includes("/favicon") ||
|
||||
@@ -58,6 +68,9 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Matcher ignoring `/_next/` and `/api/`
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
// API routes (for ORIGIN_SECRET), and pages without `/_next/` assets
|
||||
matcher: [
|
||||
"/api/:path*",
|
||||
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest } from "next/server"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
import { proxy } from "@/proxy"
|
||||
|
||||
const idFor = (ip: string) => `user-${Buffer.from(ip).toString("base64url")}`
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLIENT_IP_HEADER
|
||||
delete process.env.ORIGIN_SECRET
|
||||
})
|
||||
|
||||
describe("getUserIdFromRequest", () => {
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
headers: {
|
||||
"x-forwarded-for": "203.0.113.9, 198.51.100.7",
|
||||
"cf-connecting-ip": "198.51.100.7",
|
||||
},
|
||||
})
|
||||
|
||||
it("uses the first X-Forwarded-For entry by default", () => {
|
||||
expect(getUserIdFromRequest(req)).toBe(idFor("203.0.113.9"))
|
||||
})
|
||||
|
||||
it("uses CLIENT_IP_HEADER when set", () => {
|
||||
process.env.CLIENT_IP_HEADER = "cf-connecting-ip"
|
||||
expect(getUserIdFromRequest(req)).toBe(idFor("198.51.100.7"))
|
||||
})
|
||||
|
||||
it("is anonymous when the configured header is missing", () => {
|
||||
process.env.CLIENT_IP_HEADER = "cf-connecting-ip"
|
||||
expect(
|
||||
getUserIdFromRequest(new Request("http://localhost/api/chat")),
|
||||
).toBe("anonymous")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ORIGIN_SECRET", () => {
|
||||
const call = (path: string, secret?: string) =>
|
||||
proxy(
|
||||
new NextRequest(`http://localhost${path}`, {
|
||||
headers: secret ? { "x-origin-secret": secret } : {},
|
||||
}),
|
||||
)
|
||||
|
||||
it("lets every API call through when unset", () => {
|
||||
expect(call("/api/chat")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("refuses API calls without the right header", async () => {
|
||||
process.env.ORIGIN_SECRET = "s3cret"
|
||||
for (const res of [call("/api/chat"), call("/api/chat", "wrong")]) {
|
||||
expect(res?.status).toBe(403)
|
||||
}
|
||||
expect(call("/api/chat", "s3cret")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("leaves pages alone, such as the health check on /", () => {
|
||||
process.env.ORIGIN_SECRET = "s3cret"
|
||||
expect(call("/")?.status).toBe(307)
|
||||
expect(call("/en")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user