Skip to content

Add SVG format support to the QR code API#4184

Open
pepeladeira wants to merge 5 commits into
mainfrom
qr-format-svg
Open

Add SVG format support to the QR code API#4184
pepeladeira wants to merge 5 commits into
mainfrom
qr-format-svg

Conversation

@pepeladeira

@pepeladeira pepeladeira commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Added support for generating QR codes as raw SVG via format=svg, including image/svg+xml headers and an inline qr.svg filename.
    • Updated QR API/OpenAPI documentation to advertise SVG responses (image/svg+xml) alongside PNG.
  • Enhancements

    • bgColor omission now behaves format-dependently: PNG defaults to #ffffff, while SVG is transparent.
    • Improved QR SVG generation robustness by tolerating logo embedding failures without breaking the SVG.
    • When logo “excavation” is enabled, the QR error-correction level is automatically upgraded for improved robustness.

@pepeladeira
pepeladeira marked this pull request as draft July 20, 2026 22:14
@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Jul 21, 2026 8:10pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The QR API now accepts format=svg, returns raw SVG responses, documents the new response type, and preserves PNG output. SVG backgrounds are transparent unless specified, and logo embedding supports excavated QR codes with failure handling.

Changes

QR SVG support

Layer / File(s) Summary
Format contract and response schema
apps/web/lib/zod/schemas/qr.ts, apps/web/lib/openapi/qr/index.ts
The query schema adds png and svg formats, documents format-specific background defaults, and OpenAPI describes SVG string responses.
SVG rendering and background behavior
apps/web/lib/qr/api.tsx, apps/web/lib/qr/index.tsx
SVG generation returns strings, omits unspecified backgrounds, upgrades error correction for excavated logos, and omits logos when embedding fails.
QR endpoint format branching
apps/web/app/api/qr/route.tsx
The route builds shared QR properties, returns SVG with SVG response headers, and retains PNG output with a default background.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant QRRoute
  participant getQRAsSVG
  participant Response
  Client->>QRRoute: GET QR request with format=svg
  QRRoute->>getQRAsSVG: Generate SVG from qrProps
  getQRAsSVG-->>QRRoute: SVG string
  QRRoute->>Response: Set SVG headers and return SVG
  Response-->>Client: image/svg+xml response
Loading

Possibly related PRs

  • dubinc/dub#3788: Extends the related QR SVG data URI pipeline with dot and marker styling props.

Suggested reviewers: steven-tey

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding SVG format support to the QR code API.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch qr-format-svg

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pepeladeira
pepeladeira marked this pull request as ready for review July 21, 2026 19:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/lib/qr/index.tsx (1)

389-396: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

getQRAsSVGDataUri ignores explicit bgColor for downloads. The caller in apps/web/ui/modals/qr-code-design-fields.tsx passes the full qrData, so a user-selected background color is dropped here while getQRAsSVG still paints it conditionally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/qr/index.tsx` around lines 389 - 396, The getQRAsSVGDataUri
serialization in getQRAsSVGDataUri must honor an explicitly provided bgColor,
matching the conditional background behavior in getQRAsSVG. Use the
caller-provided qrData background color when constructing the SVG, while
preserving transparent output when no background color is selected.
🧹 Nitpick comments (1)
apps/web/app/api/qr/route.tsx (1)

63-66: 🚀 Performance & Scalability | 🔵 Trivial

Consider caching headers for the SVG response.

The new SVG response only sets Content-Type/Content-Disposition; no Cache-Control is set. If ImageResponse applies caching headers by default for the PNG branch, the SVG branch may end up less cacheable at the CDN/edge, impacting scalability under repeated requests for the same QR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/api/qr/route.tsx` around lines 63 - 66, Update the SVG response
in the QR route to include an appropriate Cache-Control header, matching the
caching behavior used by the ImageResponse PNG branch where applicable. Preserve
the existing Content-Type and Content-Disposition headers while ensuring
repeated SVG requests can be cached by the CDN or edge.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/lib/qr/api.tsx`:
- Around line 41-68: Move the excavation call in the image-generation flow so
`excavateModules` runs only after the logo fetch succeeds; failed embedding must
retain the original `cells`. Add an `AbortController`-based timeout to the
`fetch` request for `imageSettings.src`, aborting slow responses and allowing
the existing `catch` fallback to return a non-excavated QR.

---

Outside diff comments:
In `@apps/web/lib/qr/index.tsx`:
- Around line 389-396: The getQRAsSVGDataUri serialization in getQRAsSVGDataUri
must honor an explicitly provided bgColor, matching the conditional background
behavior in getQRAsSVG. Use the caller-provided qrData background color when
constructing the SVG, while preserving transparent output when no background
color is selected.

---

Nitpick comments:
In `@apps/web/app/api/qr/route.tsx`:
- Around line 63-66: Update the SVG response in the QR route to include an
appropriate Cache-Control header, matching the caching behavior used by the
ImageResponse PNG branch where applicable. Preserve the existing Content-Type
and Content-Disposition headers while ensuring repeated SVG requests can be
cached by the CDN or edge.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4379757a-93c1-4f96-ad74-8169a41c0f6f

📥 Commits

Reviewing files that changed from the base of the PR and between 488c11c and e38a9cf.

📒 Files selected for processing (5)
  • apps/web/app/api/qr/route.tsx
  • apps/web/lib/openapi/qr/index.ts
  • apps/web/lib/qr/api.tsx
  • apps/web/lib/qr/index.tsx
  • apps/web/lib/zod/schemas/qr.ts

Comment thread apps/web/lib/qr/api.tsx
@pepeladeira

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Full review. Any breaking changes, and is this backward compatible?

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@pepeladeira I’ll perform a full review of #4184, specifically checking for breaking API/SDK behavior and whether existing PNG consumers remain backward compatible.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/lib/qr/api.tsx`:
- Around line 71-85: Escape or otherwise safely encode bgColor and fgColor
before interpolating them into the fill attributes generated by the SVG
construction in the QR rendering function. Keep the existing color behavior
while ensuring crafted query parameters cannot break out of SVG attribute
context; retain schema-level validation as the primary protection.

In `@apps/web/lib/zod/schemas/qr.ts`:
- Around line 35-40: Constrain both optional color fields, bgColor and fgColor,
in the QR schema to documented hexadecimal color values using a regex validator.
Apply the same validation to each z.string() definition before values reach
getQRAsSVG, while preserving their optional behavior and existing
defaults/descriptions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1bf828c0-7482-42e6-840c-558414c2e0d6

📥 Commits

Reviewing files that changed from the base of the PR and between a4e5832 and b7f4fde.

📒 Files selected for processing (5)
  • apps/web/app/api/qr/route.tsx
  • apps/web/lib/openapi/qr/index.ts
  • apps/web/lib/qr/api.tsx
  • apps/web/lib/qr/index.tsx
  • apps/web/lib/zod/schemas/qr.ts

Comment thread apps/web/lib/qr/api.tsx
Comment on lines 71 to +85
// Drawing strategy: instead of a rect per module, we're going to create a
// single path for the dark modules and layer that on top of a light rect,
// for a total of 2 DOM nodes. We pay a bit more in string concat but that's
// way faster than DOM ops.
// For level 1, 441 nodes -> 2
// For level 40, 31329 -> 2
// single path for the dark modules. Background is only painted when bgColor
// is set — otherwise the SVG stays transparent.
const fgPath = generatePath(cells, margin);
const bgPath = bgColor
? `<path fill="${bgColor}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
: "";

return (
<svg
height={size}
width={size}
viewBox={`0 0 ${numCells} ${numCells}`}
xmlns="http://www.w3.org/2000/svg"
{...otherProps}
>
<path
fill={bgColor}
d={`M0,0 h${numCells}v${numCells}H0z`}
shapeRendering="crispEdges"
/>
<path fill={fgColor} d={fgPath} shapeRendering="crispEdges" />
{image}
</svg>
);
return [
`<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
bgPath,
`<path fill="${fgColor}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
image,
"</svg>",
].join("");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Unescaped bgColor/fgColor interpolated into SVG attributes — XSS via crafted query params.

bgColor/fgColor are inserted directly into fill="${...}" attributes of a hand-built SVG string, then returned with Content-Type: image/svg+xml (see apps/web/app/api/qr/route.tsx lines 58-67) from a public, unauthenticated GET endpoint. A value containing "> breaks out of the attribute context and injects arbitrary SVG markup (e.g. <script>), which executes when the SVG is opened directly or embedded via <object>/<iframe>. This is a new attack surface: the PNG path is safe because it's JSX-rendered and rasterized, but this raw string-based SVG output returns attacker-controlled markup verbatim.

Fixing the input at the schema layer (see apps/web/lib/zod/schemas/qr.ts comment) is the primary fix; escaping here as well provides defense-in-depth.

🛡️ Proposed defense-in-depth fix
+const escapeSvgAttr = (value: string) =>
+  value
+    .replace(/&/g, "&amp;")
+    .replace(/"/g, "&quot;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;");
+
   const fgPath = generatePath(cells, margin);
   const bgPath = bgColor
-    ? `<path fill="${bgColor}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
+    ? `<path fill="${escapeSvgAttr(bgColor)}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
     : "";

   return [
     `<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
     bgPath,
-    `<path fill="${fgColor}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
+    `<path fill="${escapeSvgAttr(fgColor)}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
     image,
     "</svg>",
   ].join("");
📝 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
// Drawing strategy: instead of a rect per module, we're going to create a
// single path for the dark modules and layer that on top of a light rect,
// for a total of 2 DOM nodes. We pay a bit more in string concat but that's
// way faster than DOM ops.
// For level 1, 441 nodes -> 2
// For level 40, 31329 -> 2
// single path for the dark modules. Background is only painted when bgColor
// is set — otherwise the SVG stays transparent.
const fgPath = generatePath(cells, margin);
const bgPath = bgColor
? `<path fill="${bgColor}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
: "";
return (
<svg
height={size}
width={size}
viewBox={`0 0 ${numCells} ${numCells}`}
xmlns="http://www.w3.org/2000/svg"
{...otherProps}
>
<path
fill={bgColor}
d={`M0,0 h${numCells}v${numCells}H0z`}
shapeRendering="crispEdges"
/>
<path fill={fgColor} d={fgPath} shapeRendering="crispEdges" />
{image}
</svg>
);
return [
`<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
bgPath,
`<path fill="${fgColor}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
image,
"</svg>",
].join("");
// Drawing strategy: instead of a rect per module, we're going to create a
// single path for the dark modules. Background is only painted when bgColor
// is set — otherwise the SVG stays transparent.
const escapeSvgAttr = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const fgPath = generatePath(cells, margin);
const bgPath = bgColor
? `<path fill="${escapeSvgAttr(bgColor)}" d="M0,0 h${numCells}v${numCells}H0z" shape-rendering="crispEdges"></path>`
: "";
return [
`<svg xmlns="http://www.w3.org/2000/svg" height="${size}" width="${size}" viewBox="0 0 ${numCells} ${numCells}">`,
bgPath,
`<path fill="${escapeSvgAttr(fgColor)}" d="${fgPath}" shape-rendering="crispEdges"></path>`,
image,
"</svg>",
].join("");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/qr/api.tsx` around lines 71 - 85, Escape or otherwise safely
encode bgColor and fgColor before interpolating them into the fill attributes
generated by the SVG construction in the QR rendering function. Keep the
existing color behavior while ensuring crafted query parameters cannot break out
of SVG attribute context; retain schema-level validation as the primary
protection.

Comment on lines 35 to 40
bgColor: z
.string()
.optional()
.default(DEFAULT_BGCOLOR)
.describe(
"The background color of the QR code in hex format. Defaults to `#ffffff` if not provided.",
"The background color of the QR code in hex format. Defaults to `#ffffff` for `png`. When omitted for `svg`, the background is transparent.",
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

No format validation on bgColor/fgColor enables SVG attribute-injection.

Both bgColor (here) and fgColor (lines 28-34) accept arbitrary strings. apps/web/lib/qr/api.tsx's new getQRAsSVG interpolates these values unescaped directly into SVG attribute values (fill="${bgColor}") and returns the result with Content-Type: image/svg+xml from a public, unauthenticated endpoint. A value like "><script>...</script><path fill=" breaks out of the attribute and injects arbitrary SVG/script markup. Unlike the PNG path (JSX-rendered → rasterized, immune to this), the SVG string path returns attacker-controlled markup verbatim — this is a well-known SVG XSS vector when served as image/svg+xml (script execution on direct navigation or <object>/<iframe> embedding).

Constrain both fields to the documented "hex format" via a regex so malformed/malicious values are rejected before reaching the renderer.

🛡️ Proposed fix
+const HEX_COLOR_REGEX = /^#?(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
+
   fgColor: z
     .string()
+    .regex(HEX_COLOR_REGEX, "fgColor must be a valid hex color (e.g. `#000000`).")
     .optional()
     .default(DEFAULT_FGCOLOR)
     .describe(
       "The foreground color of the QR code in hex format. Defaults to `#000000` if not provided.",
     ),
   bgColor: z
     .string()
+    .regex(HEX_COLOR_REGEX, "bgColor must be a valid hex color (e.g. `#ffffff`).")
     .optional()
     .describe(
       "The background color of the QR code in hex format. Defaults to `#ffffff` for `png`. When omitted for `svg`, the background is transparent.",
     ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/zod/schemas/qr.ts` around lines 35 - 40, Constrain both optional
color fields, bgColor and fgColor, in the QR schema to documented hexadecimal
color values using a regex validator. Apply the same validation to each
z.string() definition before values reach getQRAsSVG, while preserving their
optional behavior and existing defaults/descriptions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant