mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-06 17:57:47 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ca64f5e8 |
+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")}`
|
||||
|
||||
@@ -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