Skip to content

Commit bbc56a3

Browse files
πŸ“¦ new: add custom background image support with Pexels integration (#35)
* πŸ“¦ new: add custom background image support with Pexels integration * πŸ”§ update: fix biome formatting for contentType assignment * πŸ”’ security: harden image fetch and fix review findings * πŸ”’ security: block redirects, reject non-image responses, fix fallback * πŸ“– docs: document PEXELS_API_KEY in .env.example * πŸ”’ security: block localhost hostnames and fix IPv6 prefix false positives --------- Co-authored-by: Waren Gonzaga <opensource@warengonzaga.com> Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 0ed226c commit bbc56a3

9 files changed

Lines changed: 342 additions & 4 deletions

File tree

β€Ž.env.exampleβ€Ž

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,8 @@ ENABLE_STATS=false
1010
# Redis Configuration (only required if ENABLE_STATS=true)
1111
# Example: redis://default:password@host:port or redis://localhost:6379
1212
REDIS_URL=
13+
14+
# Pexels Integration (optional - enables the in-UI image search widget)
15+
# Get a free API key at https://www.pexels.com/api/
16+
# When unset, the /api/pexels/search endpoint returns 503 and the search is hidden
17+
PEXELS_API_KEY=

β€ŽREADME.mdβ€Ž

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ When you deploy your own copy, you're directly supporting this project! πŸ’–
2323
- πŸ“₯ **SVG & PNG Download** - Download banners as SVG or PNG directly from the UI
2424
- ⚑ **Lightning Fast** - Built with Hono framework for optimal performance
2525
- πŸ”’ **Secure** - Input sanitization and validation
26+
- πŸ–ΌοΈ **Background Images** - Use any HTTPS image URL as banner background via `bgimg` parameter
27+
- πŸ“Έ **Pexels Integration** - Search and select background images from Pexels directly in the UI
2628
- πŸš€ **Edge-Ready** - Deploy to modern platforms like Railway
2729

2830
## πŸš€ Quick Start
@@ -86,6 +88,18 @@ https://ghrb.waren.build/banner?header=Transparent&bg=00000000&color=ffffff
8688
https://ghrb.waren.build/banner?header=Semi-Transparent&bg=ffffff80&color=000000
8789
```
8890

91+
**Custom Background Image**
92+
93+
```text
94+
https://ghrb.waren.build/banner?header=My+Project&bgimg=https://images.pexels.com/photos/1261728/pexels-photo-1261728.jpeg&color=ffffff
95+
```
96+
97+
> Use `bgimg` with any HTTPS image URL. The image is fetched server-side, embedded as base64 in the SVG, and scaled to cover the banner area. If the image fails to load, the banner falls back to the default gradient background. Max image size: 10 MB.
98+
99+
**Pexels Integration**
100+
101+
The UI includes a built-in Pexels image search. To enable it, set the `PEXELS_API_KEY` environment variable with your [Pexels API key](https://www.pexels.com/api/). Search results display landscape-oriented thumbnails that can be selected with a single click.
102+
89103
## 🌟 Who Uses This
90104

91105
Projects and organizations using GitHub Repo Banner:
@@ -176,6 +190,7 @@ Generate a custom SVG banner.
176190
| `subheadercolor` | string | No | Same as `color` | Subheader text color |
177191
| `headerfont` | string | No | - | Google Fonts family name for header (e.g., "Roboto") |
178192
| `subheaderfont` | string | No | - | Google Fonts family name for subheader (e.g., "Playfair Display") |
193+
| `bgimg` | string | No | - | HTTPS image URL for background (overrides `bg` when set, max 10 MB) |
179194
| `support` | boolean | No | `false` | Show support watermark |
180195
| `watermarkpos` | string | No | `bottom-right` | Watermark position: `top-left`, `top-right`, `bottom-left`, `bottom-right` |
181196

@@ -187,6 +202,7 @@ Generate a custom SVG banner.
187202
| Solid | `HEX` | `ffffff` (single color) |
188203
| Transparent | `00000000` | Fully transparent |
189204
| With Opacity | `RRGGBBAA` | `ffffff80` (50% opacity) |
205+
| Image URL | `bgimg=https://...` | HTTPS URL to any image (fetched and embedded as base64) |
190206

191207
#### Response
192208

β€Žsrc/banner/svg-template.tsβ€Ž

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,73 @@ function buildGradientDef(bg: BackgroundPreset): string {
2525
return `<linearGradient id="bg-gradient" x1="0" y1="0" x2="1" y2="0">${stops}</linearGradient>`;
2626
}
2727

28+
/**
29+
* Fetch an image and return it as a base64 data URI for SVG embedding.
30+
*/
31+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
32+
const ALLOWED_IMAGE_TYPES = [
33+
'image/jpeg',
34+
'image/png',
35+
'image/gif',
36+
'image/webp',
37+
'image/avif',
38+
];
39+
40+
async function fetchImageAsBase64(url: string): Promise<string | null> {
41+
try {
42+
const response = await fetch(url, {
43+
headers: {
44+
'User-Agent':
45+
'Mozilla/5.0 (compatible; GitHubRepoBanner/1.0; +https://ghrb.waren.build)',
46+
},
47+
signal: AbortSignal.timeout(10_000),
48+
redirect: 'error',
49+
});
50+
if (!response.ok) return null;
51+
52+
const declaredLength = response.headers.get('content-length');
53+
if (declaredLength && parseInt(declaredLength, 10) > MAX_IMAGE_BYTES)
54+
return null;
55+
56+
const rawContentType = response.headers.get('content-type') || 'image/jpeg';
57+
const contentType = ALLOWED_IMAGE_TYPES.find((t) =>
58+
rawContentType.startsWith(t),
59+
);
60+
if (!contentType) return null;
61+
62+
const body = response.body;
63+
if (!body) return null;
64+
65+
const chunks: Uint8Array[] = [];
66+
let totalBytes = 0;
67+
const reader = body.getReader();
68+
for (;;) {
69+
const { done, value } = await reader.read();
70+
if (done) break;
71+
totalBytes += value.byteLength;
72+
if (totalBytes > MAX_IMAGE_BYTES) {
73+
reader.cancel();
74+
return null;
75+
}
76+
chunks.push(value);
77+
}
78+
79+
if (totalBytes === 0) return null;
80+
81+
const merged = new Uint8Array(totalBytes);
82+
let offset = 0;
83+
for (const chunk of chunks) {
84+
merged.set(chunk, offset);
85+
offset += chunk.byteLength;
86+
}
87+
88+
const base64 = Buffer.from(merged).toString('base64');
89+
return `data:${contentType};base64,${base64}`;
90+
} catch {
91+
return null;
92+
}
93+
}
94+
2895
function buildBackground(bg: BackgroundPreset): string {
2996
if (bg.type === 'transparent') {
3097
return `<rect width="${WIDTH}" height="${HEIGHT}" fill="none" />`;
@@ -273,7 +340,19 @@ export async function buildBannerSVG(options: BannerOptions): Promise<string> {
273340
}
274341

275342
const defs = buildGradientDef(background);
276-
const bgRect = buildBackground(background);
343+
let bgRect: string;
344+
345+
if (background.type === 'image' && background.imageUrl) {
346+
const dataUri = await fetchImageAsBase64(background.imageUrl);
347+
if (dataUri) {
348+
bgRect = `<image href="${dataUri}" x="0" y="0" width="${WIDTH}" height="${HEIGHT}" preserveAspectRatio="xMidYMid slice" />`;
349+
} else {
350+
bgRect = `<rect width="${WIDTH}" height="${HEIGHT}" fill="#1a1a1a" />`;
351+
}
352+
} else {
353+
bgRect = buildBackground(background);
354+
}
355+
277356
const watermark = showWatermark ? buildWatermark(watermarkPosition) : '';
278357

279358
// Determine font families to use - Google Font if specified, otherwise default

β€Žsrc/banner/types.tsβ€Ž

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
export interface BackgroundPreset {
22
id: string;
33
name: string;
4-
type: 'gradient' | 'solid' | 'transparent';
4+
type: 'gradient' | 'solid' | 'transparent' | 'image';
55
stops?: Array<{ offset: string; color: string }>;
66
color?: string;
7+
imageUrl?: string;
78
defaultTextColor: string;
89
}
910

β€Žsrc/index.tsβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { LogEngine, LogMode } from '@wgtechlabs/log-engine';
55
import { Hono } from 'hono';
66
import { initRedis, isStatsEnabled } from './config/redis.js';
77
import bannerRoute from './routes/banner.js';
8+
import pexelsRoute from './routes/pexels.js';
89
import statsRoute from './routes/stats.js';
910
import uiRoute from './routes/ui.js';
1011

@@ -45,6 +46,7 @@ app.get('/health', (c) => {
4546

4647
app.route('/', uiRoute);
4748
app.route('/', bannerRoute);
49+
app.route('/', pexelsRoute);
4850
app.route('/', statsRoute);
4951

5052
const port = parseInt(process.env.PORT || '3000', 10);

β€Žsrc/routes/banner.tsβ€Ž

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { BackgroundPreset } from '../banner/types.js';
55
import { getRedis, isStatsEnabled } from '../config/redis.js';
66
import {
77
isValidHexColor,
8+
isValidImageUrl,
89
sanitizeFontName,
910
sanitizeHeader,
1011
} from '../utils/sanitize.js';
@@ -47,6 +48,7 @@ bannerRoute.get('/banner', async (c) => {
4748
const rawHeader = c.req.query('header') || 'Hello World';
4849
const rawSubheader = c.req.query('subheader') || '';
4950
const bgParam = c.req.query('bg') || '1a1a1a-4a4a4a'; // Default gradient
51+
const bgImgParam = c.req.query('bgimg') || '';
5052
const colorParam = c.req.query('color') || '';
5153
const subheaderColorParam = c.req.query('subheadercolor') || '';
5254
const supportParam = c.req.query('support') || '';
@@ -57,10 +59,18 @@ bannerRoute.get('/banner', async (c) => {
5759
const header = sanitizeHeader(rawHeader, 50);
5860
const subheader = rawSubheader ? sanitizeHeader(rawSubheader, 60) : undefined;
5961

60-
// Parse bg parameter: gradient (hex-hex) or solid (hex)
62+
// Parse background: image URL takes priority over color
6163
let background: BackgroundPreset;
6264

63-
if (bgParam.includes('-')) {
65+
if (bgImgParam && isValidImageUrl(bgImgParam)) {
66+
background = {
67+
id: 'image',
68+
name: 'Image',
69+
type: 'image' as const,
70+
imageUrl: bgImgParam,
71+
defaultTextColor: '#ffffff',
72+
};
73+
} else if (bgParam.includes('-')) {
6474
// Gradient: two hex codes separated by hyphen
6575
const [startHex, endHex] = bgParam.split('-');
6676
if (isValidHexColor(startHex) && isValidHexColor(endHex)) {

β€Žsrc/routes/pexels.tsβ€Ž

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Hono } from 'hono';
2+
3+
const pexelsRoute = new Hono();
4+
5+
const PEXELS_API_URL = 'https://api.pexels.com/v1';
6+
7+
pexelsRoute.get('/api/pexels/search', async (c) => {
8+
const apiKey = process.env.PEXELS_API_KEY;
9+
if (!apiKey) {
10+
return c.json({ error: 'Pexels API not configured' }, 503);
11+
}
12+
13+
const query = (c.req.query('q') || 'nature').slice(0, 100);
14+
const page = Math.max(
15+
1,
16+
parseInt(c.req.query('page') || '1', 10) || 1,
17+
).toString();
18+
const perPage = '9';
19+
const orientation = 'landscape';
20+
21+
try {
22+
const url = `${PEXELS_API_URL}/search?query=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&orientation=${orientation}`;
23+
const response = await fetch(url, {
24+
headers: { Authorization: apiKey },
25+
});
26+
27+
if (!response.ok) {
28+
return c.json({ error: 'Pexels API error' }, 502);
29+
}
30+
31+
const data = (await response.json()) as {
32+
photos: Array<{
33+
id: number;
34+
alt: string;
35+
photographer: string;
36+
src: { landscape: string; medium: string };
37+
}>;
38+
total_results: number;
39+
page: number;
40+
};
41+
42+
const photos = data.photos.map((p) => ({
43+
id: p.id,
44+
alt: p.alt,
45+
photographer: p.photographer,
46+
url: p.src.landscape,
47+
thumb: p.src.medium,
48+
}));
49+
50+
return c.json({
51+
photos,
52+
total: data.total_results,
53+
page: data.page,
54+
});
55+
} catch {
56+
return c.json({ error: 'Failed to fetch from Pexels' }, 500);
57+
}
58+
});
59+
60+
export default pexelsRoute;

0 commit comments

Comments
Β (0)