Skip to content

Commit 3924ff9

Browse files
committed
Image Camo extension
1 parent b8703f6 commit 3924ff9

File tree

7 files changed

+1083
-0
lines changed

7 files changed

+1083
-0
lines changed

xExtension-ImageCamo/LICENSE

Lines changed: 674 additions & 0 deletions
Large diffs are not rendered by default.

xExtension-ImageCamo/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Image Camo Proxy Extension for FreshRSS
2+
3+
This extension allows FreshRSS to proxy images through a Camo server (eg. [go-camo](https://github.com/cactus/go-camo)), providing secure image delivery without mixed content warnings on HTTPS sites and without running an open proxy.
4+
5+
## Features
6+
7+
- Proxy HTTP and/or HTTPS images through go-camo
8+
- Support for both Base64 and Hex URL encoding (go-camo compatible)
9+
- Configurable scheme handling for protocol-relative URLs
10+
- Preserves original URLs in data attributes for FreshRSS compatibility
11+
- Supports responsive images with srcset attributes
12+
13+
## Requirements
14+
15+
- A running camo server instance (eg. go-camo)
16+
- HMAC key configured in camo server
17+
18+
## Configuration
19+
20+
1. **Camo Proxy URL**: The base URL of your camo server (e.g., `https://your-camo-instance.example.com`)
21+
2. **HMAC Key**: The shared secret key used to sign URLs (must match your camo server configuration)
22+
3. **URL Encoding**: Choose between Base64 (recommended, shorter URLs) or Hex encoding
23+
4. **Proxy HTTP images**: Enable/disable proxying of HTTP images
24+
5. **Proxy HTTPS images**: Enable/disable proxying of HTTPS images (usually disabled for performance)
25+
6. **Proxy protocol-relative URLs**: How to handle URLs starting with `//`
26+
7. **Include http*:// in URL**: Whether to include the protocol scheme in the proxied URL
27+
28+
## How it works
29+
30+
1. The extension intercepts image URLs in RSS feed content
31+
2. For each image URL that matches the configured criteria:
32+
- Generates an HMAC-SHA1 signature using the configured key
33+
- Encodes both the signature and URL (Base64 or Hex)
34+
- Constructs a go-camo compatible URL: `{camo-url}/{signature}/{encoded-url}`
35+
3. The original URLs are preserved in data attributes
36+
37+
## Security Considerations
38+
39+
- Keep your HMAC key secret and secure
40+
- Use a strong, random HMAC key
41+
42+
## Based on
43+
44+
This extension is based on the [xExtension-ImageProxy](https://github.com/FreshRSS/Extensions/tree/master/xExtension-ImageProxy) extension but specifically designed for go-camo compatibility.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
declare(strict_types=1);
3+
/** @var ImageCamoExtension $this */
4+
?>
5+
<div class="gocamo-security-notice">
6+
<span class="icon">⚠️</span>
7+
<strong><?= _t('ext.imagecamo.security_notice_title') ?>:</strong>
8+
<?= _t('ext.imagecamo.security_notice_text') ?>
9+
</div>
10+
11+
<form action="<?= _url('extension', 'configure', 'e', urlencode($this->getName())) ?>" method="post">
12+
<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
13+
14+
<div class="form-group">
15+
<label class="group-name" for="camo_proxy_url"><?= _t('ext.imagecamo.proxy_url') ?></label>
16+
<div class="group-controls">
17+
<input type="url" name="camo_proxy_url" id="camo_proxy_url"
18+
value="<?= htmlspecialchars(FreshRSS_Context::userConf()->attributeString('camo_proxy_url') ?? '', ENT_COMPAT, 'UTF-8') ?>"
19+
placeholder="https://your-camo-instance.example.com" required>
20+
<div class="gocamo-help"><?= _t('ext.imagecamo.proxy_url_help') ?></div>
21+
</div>
22+
</div>
23+
24+
<div class="form-group">
25+
<label class="group-name" for="camo_hmac_key"><?= _t('ext.imagecamo.hmac_key') ?></label>
26+
<div class="group-controls">
27+
<input type="password" name="camo_hmac_key" id="camo_hmac_key"
28+
value="<?= htmlspecialchars(FreshRSS_Context::userConf()->attributeString('camo_hmac_key') ?? '', ENT_COMPAT, 'UTF-8') ?>"
29+
placeholder="Your HMAC key for the camo server" required>
30+
<div class="gocamo-help"><?= _t('ext.imagecamo.hmac_key_help') ?></div>
31+
</div>
32+
</div>
33+
34+
<div class="form-group">
35+
<label class="group-name" for="camo_encoding"><?= _t('ext.imagecamo.encoding') ?></label>
36+
<div class="group-controls">
37+
<select name="camo_encoding" id="camo_encoding">
38+
<option value="base64" <?= (FreshRSS_Context::userConf()->attributeString('camo_encoding') === 'base64') ? 'selected' : '' ?>>Base64 (<?= _t('ext.imagecamo.encoding_base64_desc') ?>)</option>
39+
<option value="hex" <?= (FreshRSS_Context::userConf()->attributeString('camo_encoding') === 'hex') ? 'selected' : '' ?>>Hex (<?= _t('ext.imagecamo.encoding_hex_desc') ?>)</option>
40+
</select>
41+
<div class="gocamo-help"><?= _t('ext.imagecamo.encoding_help') ?></div>
42+
</div>
43+
</div>
44+
45+
<div class="form-group">
46+
<label class="group-name" for="camo_scheme_http"><?= _t('ext.imagecamo.scheme_http') ?></label>
47+
<div class="group-controls">
48+
<input type="checkbox" name="camo_scheme_http" id="camo_scheme_http" value="1"
49+
<?= FreshRSS_Context::userConf()->attributeBool('camo_scheme_http') ? 'checked' : '' ?>>
50+
</div>
51+
</div>
52+
53+
<div class="form-group">
54+
<label class="group-name" for="camo_scheme_https"><?= _t('ext.imagecamo.scheme_https'); ?></label>
55+
<div class="group-controls">
56+
<input type="checkbox" name="camo_scheme_https" id="camo_scheme_https" value="1"
57+
<?= FreshRSS_Context::userConf()->attributeBool('camo_scheme_https') ? 'checked' : '' ?>>
58+
</div>
59+
</div>
60+
61+
<div class="form-group">
62+
<label class="group-name" for="camo_scheme_default"><?= _t('ext.imagecamo.scheme_default'); ?></label>
63+
<div class="group-controls">
64+
<select name="camo_scheme_default" id="camo_scheme_default">
65+
<option value="<?= htmlspecialchars(FreshRSS_Context::userConf()->attributeString('camo_scheme_default') ?? '', ENT_COMPAT, 'UTF-8') ?>" selected="selected"><?=
66+
htmlspecialchars(FreshRSS_Context::userConf()->attributeString('camo_scheme_default') ?? '', ENT_COMPAT, 'UTF-8') ?></option>
67+
<option value="-">-</option>
68+
<option value="auto">auto</option>
69+
<option value="http">http</option>
70+
<option value="https">https</option>
71+
</select>
72+
</div>
73+
</div>
74+
75+
<div class="form-group">
76+
<label class="group-name" for="camo_scheme_include"><?= _t('ext.imagecamo.scheme_include'); ?></label>
77+
<div class="group-controls">
78+
<input type="checkbox" name="camo_scheme_include" id="camo_scheme_include" value="1"
79+
<?= FreshRSS_Context::userConf()->attributeBool('camo_scheme_include') ? 'checked' : '' ?>>
80+
</div>
81+
</div>
82+
83+
<div class="form-group form-actions">
84+
<div class="group-controls">
85+
<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
86+
<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
87+
</div>
88+
</div>
89+
</form>

xExtension-ImageCamo/extension.php

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
final class ImageCamoExtension extends Minz_Extension {
6+
// Defaults
7+
private const DEFAULT_CAMO_URL = 'https://your-camo-instance.example.com';
8+
private const DEFAULT_HMAC_KEY = '';
9+
private const DEFAULT_SCHEME_HTTP = true;
10+
private const DEFAULT_SCHEME_HTTPS = false;
11+
private const DEFAULT_SCHEME_DEFAULT = 'auto';
12+
private const DEFAULT_SCHEME_INCLUDE = false;
13+
private const DEFAULT_ENCODING = 'base64'; // base64 or hex
14+
15+
/**
16+
* @throws FreshRSS_Context_Exception
17+
*/
18+
#[\Override]
19+
public function init(): void {
20+
if (!FreshRSS_Context::hasSystemConf()) {
21+
throw new FreshRSS_Context_Exception('System configuration not initialised!');
22+
}
23+
$this->registerHook('entry_before_display', [self::class, 'setImageProxyHook']);
24+
25+
// Initialize defaults if not set
26+
$save = false;
27+
if (FreshRSS_Context::userConf()->attributeString('camo_proxy_url') == null) {
28+
FreshRSS_Context::userConf()->_attribute('camo_proxy_url', self::DEFAULT_CAMO_URL);
29+
$save = true;
30+
}
31+
if (FreshRSS_Context::userConf()->attributeString('camo_hmac_key') == null) {
32+
FreshRSS_Context::userConf()->_attribute('camo_hmac_key', self::DEFAULT_HMAC_KEY);
33+
$save = true;
34+
}
35+
if (FreshRSS_Context::userConf()->attributeBool('camo_scheme_http') === null) {
36+
FreshRSS_Context::userConf()->_attribute('camo_scheme_http', self::DEFAULT_SCHEME_HTTP);
37+
$save = true;
38+
}
39+
if (FreshRSS_Context::userConf()->attributeBool('camo_scheme_https') === null) {
40+
FreshRSS_Context::userConf()->_attribute('camo_scheme_https', self::DEFAULT_SCHEME_HTTPS);
41+
$save = true;
42+
}
43+
if (FreshRSS_Context::userConf()->attributeString('camo_scheme_default') === null) {
44+
FreshRSS_Context::userConf()->_attribute('camo_scheme_default', self::DEFAULT_SCHEME_DEFAULT);
45+
$save = true;
46+
}
47+
if (FreshRSS_Context::userConf()->attributeBool('camo_scheme_include') === null) {
48+
FreshRSS_Context::userConf()->_attribute('camo_scheme_include', self::DEFAULT_SCHEME_INCLUDE);
49+
$save = true;
50+
}
51+
if (FreshRSS_Context::userConf()->attributeString('camo_encoding') === null) {
52+
FreshRSS_Context::userConf()->_attribute('camo_encoding', self::DEFAULT_ENCODING);
53+
$save = true;
54+
}
55+
if ($save) {
56+
FreshRSS_Context::userConf()->save();
57+
}
58+
}
59+
60+
/**
61+
* @throws FreshRSS_Context_Exception
62+
*/
63+
#[\Override]
64+
public function handleConfigureAction(): void {
65+
$this->registerTranslates();
66+
67+
if (Minz_Request::isPost()) {
68+
FreshRSS_Context::userConf()->_attribute('camo_proxy_url', Minz_Request::paramString('camo_proxy_url', plaintext: true) ?: self::DEFAULT_CAMO_URL);
69+
FreshRSS_Context::userConf()->_attribute('camo_hmac_key', Minz_Request::paramString('camo_hmac_key', plaintext: true) ?: self::DEFAULT_HMAC_KEY);
70+
FreshRSS_Context::userConf()->_attribute('camo_scheme_http', Minz_Request::paramBoolean('camo_scheme_http'));
71+
FreshRSS_Context::userConf()->_attribute('camo_scheme_https', Minz_Request::paramBoolean('camo_scheme_https'));
72+
FreshRSS_Context::userConf()->_attribute('camo_scheme_default', Minz_Request::paramString('camo_scheme_default', plaintext: true) ?: self::DEFAULT_SCHEME_DEFAULT);
73+
FreshRSS_Context::userConf()->_attribute('camo_scheme_include', Minz_Request::paramBoolean('camo_scheme_include'));
74+
FreshRSS_Context::userConf()->_attribute('camo_encoding', Minz_Request::paramString('camo_encoding', plaintext: true) ?: self::DEFAULT_ENCODING);
75+
FreshRSS_Context::userConf()->save();
76+
}
77+
}
78+
79+
/**
80+
* Generate camo signed URL
81+
* @throws FreshRSS_Context_Exception
82+
*/
83+
public static function getImageCamoUri(string $url): string {
84+
$parsed_url = parse_url($url);
85+
$scheme = $parsed_url['scheme'] ?? '';
86+
87+
// Check if we should proxy this scheme
88+
if ($scheme === 'http') {
89+
if (!FreshRSS_Context::userConf()->attributeBool('camo_scheme_http')) {
90+
return $url;
91+
}
92+
} elseif ($scheme === 'https') {
93+
if (!FreshRSS_Context::userConf()->attributeBool('camo_scheme_https')) {
94+
return $url;
95+
}
96+
} elseif ($scheme === '') {
97+
// Handle protocol-relative URLs
98+
$schemeDefault = FreshRSS_Context::userConf()->attributeString('camo_scheme_default');
99+
if ($schemeDefault === 'auto') {
100+
$autoScheme = ((is_string($_SERVER['HTTPS'] ?? null) && strtolower($_SERVER['HTTPS']) !== 'off') ? 'https:' : 'http:');
101+
if (FreshRSS_Context::userConf()->attributeBool('camo_scheme_include')) {
102+
$url = $autoScheme . $url;
103+
}
104+
} elseif (str_starts_with($schemeDefault ?? '', 'http')) {
105+
if (FreshRSS_Context::userConf()->attributeBool('camo_scheme_include')) {
106+
$url = $schemeDefault . ':' . $url;
107+
}
108+
} else {
109+
// Do not proxy unschemed URLs
110+
return $url;
111+
}
112+
} else {
113+
// Unknown/unsupported scheme
114+
return $url;
115+
}
116+
117+
$hmacKey = FreshRSS_Context::userConf()->attributeString('camo_hmac_key');
118+
$camoUrl = FreshRSS_Context::userConf()->attributeString('camo_proxy_url');
119+
$encoding = FreshRSS_Context::userConf()->attributeString('camo_encoding') ?: 'base64';
120+
121+
if (empty($hmacKey) || empty($camoUrl)) {
122+
return $url; // Return original URL if configuration is incomplete
123+
}
124+
125+
// Generate HMAC signature and encode URL according to camo format
126+
if ($encoding === 'hex') {
127+
return self::generateHexCamoUrl($hmacKey, $camoUrl, $url);
128+
} else {
129+
return self::generateBase64CamoUrl($hmacKey, $camoUrl, $url);
130+
}
131+
}
132+
133+
/**
134+
* Generate Base64 encoded camo URL
135+
*/
136+
private static function generateBase64CamoUrl(string $hmacKey, string $camoUrl, string $imageUrl): string {
137+
// Generate HMAC-SHA1
138+
$hmac = hash_hmac('sha1', $imageUrl, $hmacKey, true);
139+
140+
// Base64 encode without padding (camo style)
141+
$b64Hmac = rtrim(strtr(base64_encode($hmac), '+/', '-_'), '=');
142+
$b64Url = rtrim(strtr(base64_encode($imageUrl), '+/', '-_'), '=');
143+
144+
return rtrim($camoUrl, '/') . '/' . $b64Hmac . '/' . $b64Url;
145+
}
146+
147+
/**
148+
* Generate Hex encoded camo URL
149+
*/
150+
private static function generateHexCamoUrl(string $hmacKey, string $camoUrl, string $imageUrl): string {
151+
// Generate HMAC-SHA1 in hex
152+
$hexHmac = hash_hmac('sha1', $imageUrl, $hmacKey);
153+
$hexUrl = bin2hex($imageUrl);
154+
155+
return rtrim($camoUrl, '/') . '/' . $hexHmac . '/' . $hexUrl;
156+
}
157+
158+
/**
159+
* @param array<string> $matches
160+
* @throws FreshRSS_Context_Exception
161+
*/
162+
public static function getSrcSetUris(array $matches): string {
163+
return str_replace($matches[1], self::getImageCamoUri($matches[1]), $matches[0]);
164+
}
165+
166+
/**
167+
* @throws FreshRSS_Context_Exception
168+
*/
169+
public static function swapUris(string $content): string {
170+
if ($content === '') {
171+
return $content;
172+
}
173+
174+
$doc = new DOMDocument();
175+
libxml_use_internal_errors(true); // prevent tag soup errors from showing
176+
$content = mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8');
177+
if (!is_string($content)) {
178+
return '';
179+
}
180+
$doc->loadHTML($content);
181+
$imgs = $doc->getElementsByTagName('img');
182+
foreach ($imgs as $img) {
183+
if (!($img instanceof DOMElement)) {
184+
continue;
185+
}
186+
if ($img->hasAttribute('src')) {
187+
$src = $img->getAttribute('src');
188+
$newSrc = self::getImageCamoUri($src);
189+
/*
190+
Due to the URL change, FreshRSS is not aware of already rendered enclosures.
191+
Adding data-xextension-imagecamo-original-src / srcset ensures that original URLs are present in the content for the renderer check FreshRSS_Entry->containsLink.
192+
*/
193+
$img->setAttribute('data-xextension-imagecamo-original-src', $src);
194+
$img->setAttribute('src', $newSrc);
195+
}
196+
if ($img->hasAttribute('srcset')) {
197+
$srcSet = $img->getAttribute('srcset');
198+
$newSrcSet = preg_replace_callback('/(?:([^\s,]+)(\s*(?:\s+\d+[wx])(?:,\s*)?))/', fn (array $matches) => self::getSrcSetUris($matches), $srcSet);
199+
if ($newSrcSet != null) {
200+
$img->setAttribute('data-xextension-imagecamo-original-srcset', $srcSet);
201+
$img->setAttribute('srcset', $newSrcSet);
202+
}
203+
}
204+
}
205+
206+
$body = $doc->getElementsByTagName('body')->item(0);
207+
208+
$output = $doc->saveHTML($body);
209+
if ($output === false) {
210+
return '';
211+
}
212+
213+
$output = preg_replace('/^<body>|<\/body>$/', '', $output) ?? '';
214+
215+
return $output;
216+
}
217+
218+
/**
219+
* @throws FreshRSS_Context_Exception
220+
*/
221+
public static function setImageProxyHook(FreshRSS_Entry $entry): FreshRSS_Entry {
222+
$entry->_content(
223+
self::swapUris($entry->content())
224+
);
225+
226+
return $entry;
227+
}
228+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
return array(
4+
'imagecamo' => array(
5+
'proxy_url' => 'Camo Proxy URL',
6+
'proxy_url_help' => 'The base URL of your camo server (e.g., https://camo.example.com)',
7+
'hmac_key' => 'HMAC Key',
8+
'hmac_key_help' => 'The shared secret key used to sign URLs (must match your camo server configuration)',
9+
'encoding' => 'URL Encoding',
10+
'encoding_help' => 'Choose the URL encoding format supported by your camo server',
11+
'encoding_base64_desc' => 'recommended, shorter URLs',
12+
'encoding_hex_desc' => 'longer URLs, case insensitive',
13+
'scheme_http' => 'Proxy HTTP images',
14+
'scheme_https' => 'Proxy HTTPS images',
15+
'scheme_default' => 'Proxy protocol-relative URLs',
16+
'scheme_include' => 'Include http*:// in URL',
17+
'security_notice_title' => 'Security Notice',
18+
'security_notice_text' => 'Keep your HMAC key secret and secure. Use a strong, random key that matches your camo server configuration.'
19+
),
20+
);

0 commit comments

Comments
 (0)