Skip to content

Commit 4283b23

Browse files
authored
Merge pull request #45 from injoon5/claude/liking-process-inspection-1dtmY
Harden client IP resolution and drop dead LikeButton ref
2 parents 2668ae6 + b982e33 commit 4283b23

5 files changed

Lines changed: 102 additions & 39 deletions

File tree

src/lib/LikeButton.svelte

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,25 @@
77
import Heart from '@lucide/svelte/icons/heart';
88
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
99
10-
// Pages are prerendered, so the layout's ipHash is frozen at build time and
11-
// never matches a real visitor — the liked state would always read false.
12-
// Fetch the visitor's real hash at runtime and re-subscribe with it.
13-
let clientIpHash = $state($page.data.ipHash ?? '');
10+
// Pages are prerendered, so any ipHash baked in at build time never matches a
11+
// real visitor. We fetch the visitor's real hash at runtime; until that
12+
// resolves we must NOT trust the `liked` flag — the query would report
13+
// `liked: false` for a page the visitor has actually liked, and clicking the
14+
// button would then silently remove that like (the count drops).
15+
let clientIpHash = $state('');
16+
let hashReal = $state(false);
17+
let hashAttempted = $state(false);
1418
1519
const query = useQuery(
1620
api.likes.get,
1721
() => ({
1822
url: $page.url.pathname,
1923
ipHash: clientIpHash
2024
}),
21-
// The runtime ipHash re-subscription (see onMount) swaps the query args
22-
// on every visit. Keep the prior result so the count doesn't flash back
23-
// to the loading skeleton each load.
25+
// Keep the prior result so the count doesn't flash to the skeleton when
26+
// the ipHash re-subscription fires. `isStale` (below) tells us when the
27+
// retained data belongs to different args so we never treat data from a
28+
// previous page — or the pre-resolution hash — as authoritative.
2429
{ keepPreviousData: true }
2530
);
2631
@@ -29,10 +34,15 @@
2934
const res = await fetch('/api/ip-hash');
3035
if (res.ok) {
3136
const data = await res.json();
32-
if (data.ipHash) clientIpHash = data.ipHash;
37+
if (data.ipHash) {
38+
clientIpHash = data.ipHash;
39+
hashReal = true;
40+
}
3341
}
3442
} catch {
35-
// keep the fallback hash
43+
// keep the empty hash; liked state stays unknown
44+
} finally {
45+
hashAttempted = true;
3646
}
3747
});
3848
@@ -41,11 +51,34 @@
4151
let likeErrorTimer = null;
4252
let particles = $state([]);
4353
let particleId = 0;
44-
let buttonEl;
4554
46-
const likeCount = $derived(query.data?.count ?? 0);
47-
const isLiked = $derived(query.data?.liked ?? false);
48-
const loading = $derived(query.isLoading);
55+
// Pathname of the most recent fresh (non-stale) result, so we can tell
56+
// whether retained stale data still applies to the current page (an ipHash
57+
// swap on the same URL) or belongs to a post we navigated away from.
58+
let dataUrl = $state(null);
59+
$effect(() => {
60+
if (query.data && !query.isStale) dataUrl = $page.url.pathname;
61+
});
62+
63+
const path = $derived($page.url.pathname);
64+
// The count is independent of the visitor's hash, so retained same-URL data
65+
// is still correct; only data carried over from another URL must be hidden.
66+
const countApplies = $derived(!query.isStale || dataUrl === path);
67+
// `liked` is per-visitor: trust it only once the real hash is in use and the
68+
// subscription holds fresh data for the current page.
69+
const likedReady = $derived(hashReal && !query.isStale && !!query.data && dataUrl === path);
70+
71+
const likeCount = $derived(countApplies ? (query.data?.count ?? 0) : 0);
72+
const isLiked = $derived(likedReady ? query.data.liked : false);
73+
const showCount = $derived(countApplies && !!query.data);
74+
// Allow clicks once liked is trustworthy, or once we've attempted the hash
75+
// fetch and it failed (so a broken /api/ip-hash doesn't disable the button
76+
// forever — the toggle itself is computed from the real IP server-side).
77+
const interactive = $derived(
78+
countApplies &&
79+
(likedReady || (hashAttempted && !hashReal && !query.isStale && !!query.data))
80+
);
81+
const busy = $derived(!interactive || toggling);
4982
5083
function showLikeError(message) {
5184
likeError = message;
@@ -101,7 +134,7 @@
101134
</script>
102135
103136
<div class="flex items-center justify-between">
104-
{#if loading}
137+
{#if !showCount}
105138
<span class="shimmer mr-2 inline-block h-6 w-16 rounded"></span>
106139
{:else}
107140
<span
@@ -125,16 +158,15 @@
125158
{/each}
126159
127160
<button
128-
bind:this={buttonEl}
129161
onclick={toggleLike}
130-
disabled={loading || toggling}
162+
disabled={busy}
131163
aria-pressed={isLiked}
132164
class="inline-flex items-center justify-center gap-1.5 rounded-full border px-4 py-2 text-sm font-medium transition-[background-color,border-color,color,transform] duration-200 ease-out active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-60
133165
{isLiked
134166
? 'border-rose-300/70 bg-rose-50 text-rose-600 hover:bg-rose-100 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300 dark:hover:bg-rose-950/60'
135167
: 'border-neutral-200 bg-transparent text-neutral-700 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-600 dark:border-neutral-800 dark:text-neutral-300 dark:hover:border-rose-900/50 dark:hover:bg-rose-950/30 dark:hover:text-rose-300'}"
136168
>
137-
{#if toggling}
169+
{#if busy}
138170
<LoaderCircle size="14" strokeWidth="2" class="animate-spin" aria-hidden="true" />
139171
{:else}
140172
<Heart

src/lib/comments/CommentNode.svelte

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
setActiveForm,
1717
votingIds,
1818
votingAnim,
19+
canVote = true,
20+
voteKnown = true,
1921
onVote,
2022
onHaptic
2123
} = $props();
@@ -42,6 +44,10 @@
4244
4345
const isDeleted = $derived(comment.text === '[deleted]');
4446
const isVoting = $derived(votingIds.has(comment.id));
47+
// Only reflect the visitor's vote once it's trustworthy (see CommentsSection);
48+
// otherwise show neutral arrows so a stale/unknown state can't be mis-toggled.
49+
const myVote = $derived(voteKnown ? comment.myVote : null);
50+
const voteDisabled = $derived(isVoting || !canVote);
4551
const replyCharsLeft = $derived(MAX_LENGTH - replyText.length);
4652
const showReplyCharsLeft = $derived(replyText.length > MAX_LENGTH - CHAR_THRESHOLD);
4753
const replyDisabled = $derived(
@@ -199,12 +205,12 @@
199205
200206
<button
201207
onclick={() => handleVote('up')}
202-
disabled={isVoting}
208+
disabled={voteDisabled}
203209
aria-label="Upvote"
204-
aria-pressed={comment.myVote === 'up'}
210+
aria-pressed={myVote === 'up'}
205211
class="rounded-full p-1.5 transition-[background-color,color,transform] duration-150 ease-out active:scale-90 disabled:cursor-not-allowed disabled:opacity-60
206212
{votingAnim.id === comment.id && votingAnim.side === 'up' ? 'vote-pop' : ''}
207-
{comment.myVote === 'up'
213+
{myVote === 'up'
208214
? 'text-emerald-600 dark:text-emerald-400'
209215
: 'text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100'}"
210216
>
@@ -217,12 +223,12 @@
217223
218224
<button
219225
onclick={() => handleVote('down')}
220-
disabled={isVoting}
226+
disabled={voteDisabled}
221227
aria-label="Downvote"
222-
aria-pressed={comment.myVote === 'down'}
228+
aria-pressed={myVote === 'down'}
223229
class="rounded-full p-1.5 transition-[background-color,color,transform] duration-150 ease-out active:scale-90 disabled:cursor-not-allowed disabled:opacity-60
224230
{votingAnim.id === comment.id && votingAnim.side === 'down' ? 'vote-pop' : ''}
225-
{comment.myVote === 'down'
231+
{myVote === 'down'
226232
? 'text-rose-600 dark:text-rose-400'
227233
: 'text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100'}"
228234
>
@@ -418,6 +424,8 @@
418424
{setActiveForm}
419425
{votingIds}
420426
{votingAnim}
427+
{canVote}
428+
{voteKnown}
421429
{onVote}
422430
{onHaptic}
423431
/>

src/lib/comments/CommentsSection.svelte

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,29 @@
4848
}
4949
const fallbackHandle = makeHandle();
5050
51-
// Pages are prerendered, so the layout's ipHash is frozen at build time and
52-
// never matches a real visitor — per-user vote state would always read wrong.
53-
// Fetch the visitor's real hash at runtime and re-subscribe with it.
54-
let clientIpHash = $state($page.data.ipHash ?? '');
51+
// Pages are prerendered, so any ipHash baked in at build time never matches a
52+
// real visitor. We fetch the visitor's real hash at runtime; until that
53+
// resolves we must NOT trust each comment's `myVote` — it would read null for
54+
// a comment the visitor has actually voted on, and clicking the arrow would
55+
// then silently remove that vote (the score drops).
56+
let clientIpHash = $state('');
57+
let hashReal = $state(false);
58+
let hashAttempted = $state(false);
5559
5660
onMount(async () => {
5761
try {
5862
const res = await fetch('/api/ip-hash');
5963
if (res.ok) {
6064
const data = await res.json();
61-
if (data.ipHash) clientIpHash = data.ipHash;
65+
if (data.ipHash) {
66+
clientIpHash = data.ipHash;
67+
hashReal = true;
68+
}
6269
}
6370
} catch {
64-
// keep the fallback hash
71+
// keep the empty hash; vote state stays unknown
72+
} finally {
73+
hashAttempted = true;
6574
}
6675
});
6776
@@ -97,6 +106,7 @@
97106
}
98107
99108
async function vote(commentId, voteType) {
109+
if (!canVote) return;
100110
if (votingIds.has(commentId)) return;
101111
102112
if (votingAnimTimer) clearTimeout(votingAnimTimer);
@@ -160,6 +170,16 @@
160170
161171
const commentTree = $derived(buildTree(query.data ?? []));
162172
173+
// `myVote` is per-visitor: trust the highlight only once the real hash is in
174+
// use and the subscription holds fresh (non-stale) data. Allow clicking once
175+
// trusted, or once a failed /api/ip-hash leaves us with fresh data and no
176+
// real hash (the vote itself is computed from the real IP server-side, so a
177+
// broken hash fetch must not lock voting forever).
178+
const voteKnown = $derived(hashReal && !query.isStale && !!query.data);
179+
const canVote = $derived(
180+
voteKnown || (hashAttempted && !hashReal && !query.isStale && !!query.data)
181+
);
182+
163183
// Reset transient form state on path change
164184
let currentPath = $state($page.url.pathname);
165185
$effect(() => {
@@ -291,6 +311,8 @@
291311
{setActiveForm}
292312
{votingIds}
293313
{votingAnim}
314+
{canVote}
315+
{voteKnown}
294316
onVote={vote}
295317
onHaptic={haptic}
296318
/>

src/lib/server/ip.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { createHash } from 'crypto';
22

33
export function getClientIp(request: Request): string {
4+
// Prefer x-real-ip: on Vercel (and most reverse proxies) it is set by the
5+
// platform from the TCP connection and is not overridable by the caller.
6+
// The leftmost x-forwarded-for entry is attacker-controlled, so trusting it
7+
// first lets a caller forge a fresh identity per request — inflating like
8+
// counts and evading per-IP bans and rate limits.
9+
const realIp = request.headers.get('x-real-ip');
10+
if (realIp) return realIp.trim();
11+
412
const forwarded = request.headers.get('x-forwarded-for');
5-
if (forwarded) {
6-
return forwarded.split(',')[0].trim();
7-
}
8-
return request.headers.get('x-real-ip') ?? '127.0.0.1';
13+
if (forwarded) return forwarded.split(',')[0].trim();
14+
15+
return '127.0.0.1';
916
}
1017

1118
export function hashIp(ip: string): string {

src/routes/+layout.server.ts

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)