Skip to content

Commit cfaf3f4

Browse files
Add proxy hub, link preview improvements, and fetching toast
- Add ProxyHub service for routing fetches through a connected proxy agent - Route OGS and image downloads through proxy when connected - Add SSRF-safe safeFetchImage with private-IP blocking and DNS rebinding protection - Backfill link previews on startup under advisory lock (idempotent) - Add refetch-link-previews endpoint that wipes and re-runs backfill in-process - Emit link_preview_fetching socket event so clients show a brief toast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 924abb4 commit cfaf3f4

16 files changed

Lines changed: 429 additions & 39 deletions

File tree

client/src/components/LinkPreviewCard.jsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ const Card = styled.a`
2525

2626
const CardImage = styled.div`
2727
width: 100%;
28-
/* Basis of 130px but allowed to shrink to 0 (never grows), so the image is the
29-
part that gives when previews need to fit a bounded area. */
3028
flex: 0 1 130px;
3129
min-height: 0;
3230
background-image: url(${props => props.$src});
3331
background-size: cover;
3432
background-position: center;
3533
background-color: ${props => props.theme === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)'};
34+
35+
@media (max-width: 600px) {
36+
flex-basis: 75px;
37+
}
3638
`;
3739

3840
const CardBody = styled.div`

client/src/components/NoteCard.jsx

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,20 @@ const TagText = styled.span`
527527
min-width: 0;
528528
`;
529529

530+
const TagOverflow = styled.div`
531+
display: inline-flex;
532+
align-items: center;
533+
padding: 2px 7px;
534+
border-radius: 4px;
535+
font-size: 11px;
536+
font-weight: 500;
537+
background-color: ${props => props.theme === 'dark' ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.08)'};
538+
color: var(--text-color);
539+
opacity: 0.85;
540+
cursor: default;
541+
user-select: none;
542+
`;
543+
530544
// Holds the stack of link preview cards. A flex column with a bounded height: when
531545
// several (image-heavy) previews would overflow, the cards themselves shrink to fit
532546
// (their images compress) rather than the card spilling or the note text getting
@@ -1305,30 +1319,44 @@ const NoteCard = memo(function NoteCard({ note, searchQuery, layoutView, onPicke
13051319
</UrlLink>
13061320
))}
13071321

1308-
{/* Display all tags, sorted with folders first */}
1309-
{[...noteTags]
1310-
.sort((a, b) => {
1311-
// Convert to boolean to handle undefined
1322+
{/* Display tags: all folders, then regular tags capped at 4 */}
1323+
{(() => {
1324+
const sorted = [...noteTags].sort((a, b) => {
13121325
const aIsFolder = !!a.is_folder;
13131326
const bIsFolder = !!b.is_folder;
13141327
if (aIsFolder && !bIsFolder) return -1;
13151328
if (!aIsFolder && bIsFolder) return 1;
13161329
return a.name.localeCompare(b.name);
1317-
})
1318-
.map(tag => (
1319-
<Tag
1320-
key={tag.id}
1321-
theme={isDarkTheme ? 'dark' : 'light'}
1322-
$isFolder={tag.is_folder}
1323-
title={`${tag.name}${tag.is_folder ? ' (folder)' : ''}`}
1324-
onClick={(e) => handleTagClick(e, tag)}
1325-
name={tag.name}
1326-
>
1327-
{tag.visible === false && <Icon name="eye-slash" size={10} style={{ marginRight: 4, flexShrink: 0, opacity: 0.6 }} />}
1328-
{tag.is_folder && <Icon name="folder" size={11} style={{ marginRight: 4, flexShrink: 0 }} />}
1329-
<TagText>{tag.name.length > 12 ? `${tag.name.substring(0, 12)}...` : tag.name}</TagText>
1330-
</Tag>
1331-
))}
1330+
});
1331+
const folderTags = sorted.filter(t => t.is_folder);
1332+
const regularTags = sorted.filter(t => !t.is_folder);
1333+
const visibleRegular = regularTags.slice(0, 3);
1334+
const overflow = regularTags.length - visibleRegular.length;
1335+
const visible = [...folderTags, ...visibleRegular];
1336+
return (
1337+
<>
1338+
{visible.map(tag => (
1339+
<Tag
1340+
key={tag.id}
1341+
theme={isDarkTheme ? 'dark' : 'light'}
1342+
$isFolder={tag.is_folder}
1343+
title={`${tag.name}${tag.is_folder ? ' (folder)' : ''}`}
1344+
onClick={(e) => handleTagClick(e, tag)}
1345+
name={tag.name}
1346+
>
1347+
{tag.visible === false && <Icon name="eye-slash" size={10} style={{ marginRight: 4, flexShrink: 0, opacity: 0.6 }} />}
1348+
{tag.is_folder && <Icon name="folder" size={11} style={{ marginRight: 4, flexShrink: 0 }} />}
1349+
<TagText>{tag.name.length > 12 ? `${tag.name.substring(0, 12)}...` : tag.name}</TagText>
1350+
</Tag>
1351+
))}
1352+
{overflow > 0 && (
1353+
<TagOverflow theme={isDarkTheme ? 'dark' : 'light'}>
1354+
+{overflow}
1355+
</TagOverflow>
1356+
)}
1357+
</>
1358+
);
1359+
})()}
13321360
</TagsContainer>
13331361
)}
13341362

client/src/components/SettingsModal.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ const DEFAULT_SETTINGS = {
144144
FOXIT_ENABLED: false,
145145
FOXIT_SNOOPER_URL: '',
146146
FOXIT_SNOOPER_TOKEN: '',
147+
PROXY_ENABLED: 'false',
148+
PROXY_TOKEN: '',
147149
CACHE_MAX_SIZE: '1000',
148150
PREFETCH_BATCH_SIZE: '10',
149151
BATCH_DELAY_MS: '300',
@@ -335,7 +337,7 @@ const SettingsModal = ({ onClose }) => {
335337
case 'advanced':
336338
return <AdvancedTab settings={settings} onChange={handleChange} onCacheSettingChange={handleCacheSettingChange} />;
337339
case 'maintenance':
338-
return <MaintenanceTab settings={settings} onChange={handleChange} commit={commit} />;
340+
return <MaintenanceTab settings={settings} onChange={handleChange} commit={commit} isDarkTheme={isDarkTheme} />;
339341
case 'integrations':
340342
return <IntegrationsTab settings={settings} onChange={handleChange} commit={commit} isDarkTheme={isDarkTheme} />;
341343
case 'help':

client/src/components/settings/IntegrationsTab.jsx

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,50 @@ import CopyableField from './CopyableField';
1313

1414
const IntegrationsTab = ({ settings, onChange, commit, isDarkTheme }) => {
1515
const foxitEnabled = settings.FOXIT_ENABLED === true || settings.FOXIT_ENABLED === 'true';
16+
const proxyEnabled = settings.PROXY_ENABLED === true || settings.PROXY_ENABLED === 'true';
1617
const { showToast } = useToast();
1718

1819
const [jinaLoading, setJinaLoading] = useState(false);
1920
const [tmdbTesting, setTmdbTesting] = useState(false);
2021
const [foxitTokenCopied, setFoxitTokenCopied] = useState(false);
22+
const [proxyTokenCopied, setProxyTokenCopied] = useState(false);
23+
const [proxyTesting, setProxyTesting] = useState(false);
24+
25+
const serverOrigin = typeof window !== 'undefined' ? window.location.origin : '';
26+
27+
const generateProxyToken = useCallback(() => {
28+
const bytes = new Uint8Array(24);
29+
window.crypto.getRandomValues(bytes);
30+
const token = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
31+
commit({ ...settings, PROXY_TOKEN: token });
32+
if (navigator.clipboard) {
33+
navigator.clipboard.writeText(token).then(
34+
() => { setProxyTokenCopied(true); setTimeout(() => setProxyTokenCopied(false), 2500); },
35+
() => {}
36+
);
37+
}
38+
}, [settings, commit]);
39+
40+
const clearProxyToken = useCallback(() => {
41+
setProxyTokenCopied(false);
42+
commit({ ...settings, PROXY_TOKEN: '' });
43+
}, [settings, commit]);
44+
45+
const testProxyConnection = useCallback(async () => {
46+
setProxyTesting(true);
47+
try {
48+
const data = await notesApi.testProxyConnection();
49+
if (data.connected) {
50+
showToast('Proxy agent is connected', { variant: 'success' });
51+
} else {
52+
showToast('No proxy agent connected', { variant: 'error' });
53+
}
54+
} catch (e) {
55+
showToast('Could not reach server', { variant: 'error' });
56+
} finally {
57+
setProxyTesting(false);
58+
}
59+
}, [showToast]);
2160

2261
// Fill the Foxit snooper token with a fresh random secret and copy it, since
2362
// the same value has to be pasted into the snooper on the other machine.
@@ -44,8 +83,8 @@ const IntegrationsTab = ({ settings, onChange, commit, isDarkTheme }) => {
4483
const [extLoading, setExtLoading] = useState(false);
4584
const [extToken, setExtToken] = useState(null);
4685
const [extError, setExtError] = useState(null);
47-
// The address the extension should point at — this app's own origin.
48-
const serverOrigin = typeof window !== 'undefined' ? window.location.origin : '';
86+
// The address the extension (and proxy agent) should point at — this app's own origin.
87+
// serverOrigin is already declared above.
4988

5089
const generateExtensionToken = useCallback(async () => {
5190
setExtLoading(true);
@@ -100,6 +139,99 @@ const IntegrationsTab = ({ settings, onChange, commit, isDarkTheme }) => {
100139

101140
return (
102141
<>
142+
<SectionContainer>
143+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
144+
<SectionTitle style={{ margin: 0 }}>Proxy</SectionTitle>
145+
<button
146+
onClick={testProxyConnection}
147+
disabled={proxyTesting}
148+
style={{
149+
background: 'none',
150+
border: '1px solid var(--border-color)',
151+
borderRadius: '6px',
152+
padding: '4px 10px',
153+
fontSize: '12px',
154+
cursor: proxyTesting ? 'not-allowed' : 'pointer',
155+
color: 'var(--text-secondary-color)',
156+
opacity: proxyTesting ? 0.6 : 1,
157+
}}
158+
>
159+
{proxyTesting ? 'Testing…' : 'Test connection'}
160+
</button>
161+
</div>
162+
<p style={{ fontSize: '14px', color: 'var(--text-secondary-color)' }}>
163+
Route URL fetches (link previews, article extraction) through a proxy instead of the server.
164+
Useful when the server's IP is blocked by certain sites &mdash; YouTube thumbnails, geo-restricted content, etc.
165+
The proxy connects outbound; no port forwarding needed.
166+
</p>
167+
<FormGroup style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
168+
<Label style={{ marginBottom: 0 }}>Enable proxy</Label>
169+
<Switch
170+
id="proxy-enabled-toggle"
171+
checked={proxyEnabled}
172+
onChange={() => commit({ ...settings, PROXY_ENABLED: !proxyEnabled })}
173+
/>
174+
</FormGroup>
175+
{proxyEnabled && (
176+
<>
177+
<FormGroup>
178+
<CopyableField
179+
label="Server URL"
180+
value={serverOrigin}
181+
isDark={isDarkTheme}
182+
copyTitle="Copy server URL"
183+
/>
184+
</FormGroup>
185+
<FormGroup>
186+
{settings.PROXY_TOKEN ? (
187+
<>
188+
<CopyableField
189+
label="Proxy token"
190+
value={settings.PROXY_TOKEN}
191+
isDark={isDarkTheme}
192+
copyTitle="Copy token"
193+
/>
194+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
195+
<button
196+
onClick={generateProxyToken}
197+
style={{ background: 'none', border: '1px solid var(--border-color)', borderRadius: '6px', padding: '4px 10px', fontSize: '12px', color: 'var(--text-secondary-color)', cursor: 'pointer' }}
198+
>
199+
Regenerate
200+
</button>
201+
<button
202+
onClick={clearProxyToken}
203+
style={{ background: 'none', border: '1px solid var(--border-color)', borderRadius: '6px', padding: '4px 10px', fontSize: '12px', color: 'var(--text-secondary-color)', cursor: 'pointer' }}
204+
>
205+
Clear
206+
</button>
207+
<span style={{ fontSize: '12px', color: 'var(--text-secondary-color)' }}>
208+
{proxyTokenCopied ? 'Copied!' : 'Keep this secret.'}
209+
</span>
210+
</div>
211+
</>
212+
) : (
213+
<>
214+
<Label>Proxy token</Label>
215+
<button
216+
onClick={generateProxyToken}
217+
style={{
218+
background: 'none',
219+
border: '1px solid var(--border-color)',
220+
borderRadius: '6px',
221+
padding: '8px 14px',
222+
fontSize: '13px',
223+
cursor: 'pointer',
224+
color: 'var(--text-secondary-color)',
225+
}}
226+
>
227+
Generate token
228+
</button>
229+
</>
230+
)}
231+
</FormGroup>
232+
</>
233+
)}
234+
</SectionContainer>
103235
<SectionContainer>
104236
<SectionTitle>Browser Extension (itsnotes clipper)</SectionTitle>
105237
<p style={{ fontSize: '14px', color: 'var(--text-secondary-color)' }}>

client/src/components/settings/MaintenanceTab.jsx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
22
import styled from 'styled-components';
33
import Switch from '../Switch';
44
import Icon from '../Icons';
5+
import { notesApi } from '../../services/api';
6+
import { useToast } from '../../contexts/ToastContext';
57
import {
68
SectionContainer,
79
SectionTitle,
810
FormGroup,
911
Label,
1012
Input,
13+
Button,
1114
} from './styles';
1215

1316
const Description = styled.p`
@@ -25,6 +28,15 @@ const Card = styled.div`
2528
gap: 14px;
2629
`;
2730

31+
const PlainCard = styled.div`
32+
background-color: ${props => props.$isDark ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.02)'};
33+
border-radius: 8px;
34+
padding: 16px;
35+
display: flex;
36+
flex-direction: column;
37+
gap: 14px;
38+
`;
39+
2840
const CardHeader = styled.div`
2941
display: flex;
3042
align-items: center;
@@ -61,11 +73,23 @@ const DaysSuffix = styled.span`
6173
color: var(--text-color);
6274
`;
6375

64-
const MaintenanceTab = ({ settings, onChange, commit }) => {
65-
// Trash auto-delete is opt-out: anything other than the string 'false' is on.
76+
const MaintenanceTab = ({ settings, onChange, commit, isDarkTheme }) => {
6677
const trashEnabled = settings.TRASH_CLEANUP_ENABLED !== 'false';
67-
// Auto-archive is opt-in: off unless explicitly 'true'.
6878
const archiveEnabled = settings.AUTO_ARCHIVE_ENABLED === 'true';
79+
const { showToast } = useToast();
80+
const [refetching, setRefetching] = useState(false);
81+
82+
const handleRefetchLinkPreviews = async () => {
83+
setRefetching(true);
84+
try {
85+
await notesApi.refetchLinkPreviews();
86+
showToast('Refetch started — previews will update as they come in', { variant: 'success' });
87+
} catch (e) {
88+
showToast('Failed to start refetch', { variant: 'error' });
89+
} finally {
90+
setRefetching(false);
91+
}
92+
};
6993

7094
return (
7195
<SectionContainer>
@@ -152,6 +176,24 @@ const MaintenanceTab = ({ settings, onChange, commit }) => {
152176
</FormGroup>
153177
)}
154178
</Card>
179+
<PlainCard $isDark={isDarkTheme}>
180+
<CardHeader>
181+
<div>
182+
<CardTitle><Icon name="link" size={18} />Refetch link previews</CardTitle>
183+
</div>
184+
<Button
185+
$color="danger"
186+
$size="small"
187+
onClick={handleRefetchLinkPreviews}
188+
disabled={refetching}
189+
>
190+
{refetching ? 'Starting…' : 'Refetch all'}
191+
</Button>
192+
</CardHeader>
193+
<Description>
194+
Clears all cached link previews and re-fetches them from scratch. Previews update in the background as they come in.
195+
</Description>
196+
</PlainCard>
155197
</SectionContainer>
156198
);
157199
};

client/src/contexts/NotesContext.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2427,11 +2427,15 @@ export const NotesProvider = ({ children }) => {
24272427
'bulk_tags_updated': handleBulkTagsUpdated, // Add the new handler
24282428
'note_operation': (data) => console.log('[SOCKET] Note Operation:', data),
24292429
'mirror_imported': handleMirrorImported,
2430+
'link_preview_fetching': ({ count = 1 }) => {
2431+
const msg = count > 1 ? `Fetching ${count} link previews…` : 'Fetching link preview…';
2432+
showToast(msg, { duration: 'short' });
2433+
},
24302434
};
24312435
}, [handleSocketNoteCreated, handleSocketNoteUpdated, handleSocketNoteDeleted,
24322436
handleSocketNotesBulkUpdated, handleSocketNotesBulkDeleted,
24332437
handleSocketObjectUpdated, handleSocketNoteTagChange, handleSocketNoteImageChange,
2434-
handleSocketTagUpdated, handleBulkTagsUpdated, handleMirrorImported]);
2438+
handleSocketTagUpdated, handleBulkTagsUpdated, handleMirrorImported, showToast]);
24352439

24362440
// Set up effect to handle pending tag changes after getNoteTags is defined
24372441
useEffect(() => {

0 commit comments

Comments
 (0)