Skip to content

Commit 3fafa73

Browse files
committed
feat: support plane view.
1 parent ed03912 commit 3fafa73

7 files changed

Lines changed: 197 additions & 17 deletions

File tree

backend/app/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ class Settings(BaseSettings):
2323
# Repo on ghcr.io for the image-tag lookup. Matches docker-compose.yml.
2424
UPDATE_CHECK_IMAGE: str = "pttcodingman/justwiki/backend"
2525

26+
# Repeat views by the same user within this window don't re-increment
27+
# a page's view_count. Keeps counts meaningful across refreshes / tab
28+
# switches without permanently storing per-user reading history.
29+
VIEW_DEDUP_MINUTES: int = 30
30+
2631
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
2732

2833

backend/app/database.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,16 @@
210210
CREATE INDEX IF NOT EXISTS idx_page_acl_page ON page_acl(page_id);
211211
CREATE INDEX IF NOT EXISTS idx_page_acl_principal ON page_acl(principal_type, principal_id);
212212
213+
-- Short-lived dedup keys for view_count. Rows are hashes, not user ids,
214+
-- so a plain DB dump can't be used to reconstruct a reading history.
215+
CREATE TABLE IF NOT EXISTS view_dedup (
216+
dedup_key TEXT PRIMARY KEY,
217+
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
218+
last_viewed_at INTEGER NOT NULL
219+
);
220+
221+
CREATE INDEX IF NOT EXISTS idx_view_dedup_last ON view_dedup(last_viewed_at);
222+
213223
"""
214224

215225
WELCOME_PAGE_CONTENT_EN = r"""# Welcome to JustWiki

backend/app/routers/pages.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import hashlib
12
import json
3+
import random
24
import re
5+
import time
36
import unicodedata
47
from fastapi import APIRouter, HTTPException, Depends, Query
8+
from app.config import settings
59
from app.schemas import PageCreate, PageUpdate, PageResponse, PageListResponse, PageMoveRequest
610
from app.auth import get_current_user
711
from app.database import get_db
@@ -15,6 +19,46 @@
1519
router = APIRouter(prefix="/api/pages", tags=["pages"])
1620

1721

22+
async def _should_count_view(db, user_id: int, page_id: int) -> bool:
23+
"""Record this view and return True if it should bump view_count.
24+
25+
Counts at most once per (user, page) per VIEW_DEDUP_MINUTES. Keyed on
26+
sha256(user|page|SECRET_KEY) so a plain DB dump doesn't expose reading
27+
history — note that an attacker with both the DB and SECRET_KEY can
28+
still brute-force the small user_id × page_id space.
29+
"""
30+
cooldown = settings.VIEW_DEDUP_MINUTES * 60
31+
now = int(time.time())
32+
cutoff = now - cooldown
33+
dedup_key = hashlib.sha256(
34+
f"u:{user_id}|{page_id}|{settings.SECRET_KEY}".encode()
35+
).hexdigest()
36+
37+
# Single-statement UPSERT so two concurrent views for the same key can't
38+
# both see no row and race on INSERT. The WHERE clause makes the UPDATE
39+
# branch a no-op while still inside the cooldown window, so rowcount
40+
# cleanly distinguishes "counted" (1) from "deduped" (0).
41+
cursor = await db.execute(
42+
"""
43+
INSERT INTO view_dedup (dedup_key, page_id, last_viewed_at)
44+
VALUES (?, ?, ?)
45+
ON CONFLICT(dedup_key) DO UPDATE SET last_viewed_at = excluded.last_viewed_at
46+
WHERE view_dedup.last_viewed_at < ?
47+
""",
48+
(dedup_key, page_id, now, cutoff),
49+
)
50+
if cursor.rowcount != 1:
51+
return False
52+
53+
# Opportunistic cleanup so the table doesn't grow unbounded. 1% of counted
54+
# views prune expired rows — amortises cleanup across traffic, no cron needed.
55+
if random.random() < 0.01:
56+
await db.execute(
57+
"DELETE FROM view_dedup WHERE last_viewed_at < ?", (cutoff,)
58+
)
59+
return True
60+
61+
1862
def _build_id_clause(ids: set[int], column: str = "id") -> tuple[str, list]:
1963
"""Produce a parameterized ``column IN (SELECT value FROM json_each(?))``
2064
clause plus params.
@@ -276,13 +320,15 @@ async def get_page(slug: str, user=Depends(get_current_user)):
276320
# the existence of restricted pages.
277321
raise HTTPException(status_code=404, detail="Page not found")
278322

279-
# Increment view count (does not bump the content version)
280-
await db.execute(
281-
"UPDATE pages SET view_count = view_count + 1 WHERE slug = ?", (slug,)
282-
)
323+
# Increment view count (does not bump the content version), deduplicated
324+
# so refreshes / tab-switches by the same user don't inflate the count.
325+
if await _should_count_view(db, user["id"], page["id"]):
326+
await db.execute(
327+
"UPDATE pages SET view_count = view_count + 1 WHERE id = ?", (page["id"],)
328+
)
329+
page["view_count"] += 1
283330
await db.commit()
284331

285-
page["view_count"] += 1
286332
page["effective_permission"] = permission
287333
return page
288334

backend/tests/test_optimistic_lock.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,5 @@ async def test_get_page_does_not_bump_version(auth_client):
103103
res = await auth_client.get("/api/pages/lock-view")
104104
assert res.status_code == 200
105105
assert res.json()["version"] == 1
106-
assert res.json()["view_count"] == 3
106+
# View dedup collapses the 3 refreshes by the same user into one count.
107+
assert res.json()["view_count"] == 1

backend/tests/test_public_page.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -237,21 +237,17 @@ async def test_view_count_unchanged_by_public_endpoint(auth_client, client):
237237
})
238238
await auth_client.put("/api/pages/public-vc", json={"is_public": True})
239239

240-
# Reset view count to 0 (it may have been incremented by the PUT flow? no — only GET bumps)
241-
# Hit the public endpoint several times
240+
# Snapshot the current view_count via one authed GET (dedup makes
241+
# subsequent same-user GETs inert inside the cooldown window).
242+
before = (await auth_client.get("/api/pages/public-vc")).json()["view_count"]
243+
244+
# Public reads must not touch view_count.
242245
for _ in range(5):
243246
res = await client.get("/api/public/pages/public-vc")
244247
assert res.status_code == 200
245248

246-
# Look at the raw page row (auth_client GETs would bump it further, so query DB)
247-
# Use a dedicated authed GET that we know bumps by 1 and reason about the delta.
248-
res = await auth_client.get("/api/pages/public-vc")
249-
first = res.json()["view_count"]
250-
res = await auth_client.get("/api/pages/public-vc")
251-
second = res.json()["view_count"]
252-
# Public reads should not have bumped view_count, so two sequential authed GETs
253-
# must differ by exactly 1.
254-
assert second - first == 1
249+
after = (await auth_client.get("/api/pages/public-vc")).json()["view_count"]
250+
assert after == before
255251

256252

257253
@pytest.mark.asyncio
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import pytest
2+
3+
from app.database import get_db
4+
5+
6+
@pytest.mark.asyncio
7+
async def test_same_user_repeat_does_not_increment(auth_client):
8+
await auth_client.post("/api/pages", json={
9+
"title": "View Dedup Same User",
10+
"content_md": "x",
11+
"slug": "view-dedup-same",
12+
})
13+
14+
first = await auth_client.get("/api/pages/view-dedup-same")
15+
assert first.status_code == 200
16+
count_after_first = first.json()["view_count"]
17+
18+
second = await auth_client.get("/api/pages/view-dedup-same")
19+
assert second.status_code == 200
20+
assert second.json()["view_count"] == count_after_first
21+
22+
third = await auth_client.get("/api/pages/view-dedup-same")
23+
assert third.json()["view_count"] == count_after_first
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_different_users_each_count(auth_client, admin_client):
28+
await auth_client.post("/api/pages", json={
29+
"title": "View Dedup Two Users",
30+
"content_md": "x",
31+
"slug": "view-dedup-two-users",
32+
})
33+
34+
r1 = await auth_client.get("/api/pages/view-dedup-two-users")
35+
first_count = r1.json()["view_count"]
36+
37+
r2 = await admin_client.get("/api/pages/view-dedup-two-users")
38+
assert r2.json()["view_count"] == first_count + 1
39+
40+
# Each user's refresh still dedups against their own slot.
41+
r1b = await auth_client.get("/api/pages/view-dedup-two-users")
42+
assert r1b.json()["view_count"] == first_count + 1
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_expired_dedup_counts_again(auth_client):
47+
await auth_client.post("/api/pages", json={
48+
"title": "View Dedup Expired",
49+
"content_md": "x",
50+
"slug": "view-dedup-expired",
51+
})
52+
53+
r1 = await auth_client.get("/api/pages/view-dedup-expired")
54+
count_after_first = r1.json()["view_count"]
55+
56+
r2 = await auth_client.get("/api/pages/view-dedup-expired")
57+
assert r2.json()["view_count"] == count_after_first
58+
59+
# Backdate every dedup row for this page past the cooldown.
60+
db = await get_db()
61+
page_rows = await db.execute_fetchall(
62+
"SELECT id FROM pages WHERE slug = ?", ("view-dedup-expired",)
63+
)
64+
page_id = page_rows[0]["id"]
65+
await db.execute(
66+
"UPDATE view_dedup SET last_viewed_at = 0 WHERE page_id = ?", (page_id,)
67+
)
68+
await db.commit()
69+
70+
r3 = await auth_client.get("/api/pages/view-dedup-expired")
71+
assert r3.json()["view_count"] == count_after_first + 1
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_dedup_row_is_hashed(auth_client):
76+
"""Row stores a sha256 hex digest, not a raw (user, page) pair."""
77+
await auth_client.post("/api/pages", json={
78+
"title": "View Dedup Hash",
79+
"content_md": "x",
80+
"slug": "view-dedup-hash",
81+
})
82+
await auth_client.get("/api/pages/view-dedup-hash")
83+
84+
db = await get_db()
85+
page_rows = await db.execute_fetchall(
86+
"SELECT id FROM pages WHERE slug = ?", ("view-dedup-hash",)
87+
)
88+
page_id = page_rows[0]["id"]
89+
rows = await db.execute_fetchall(
90+
"SELECT dedup_key FROM view_dedup WHERE page_id = ?", (page_id,)
91+
)
92+
assert rows, "expected a dedup row after first view"
93+
key = rows[0]["dedup_key"]
94+
assert len(key) == 64
95+
assert all(c in "0123456789abcdef" for c in key)
96+
# The structured plaintext form must never hit disk.
97+
assert "|" not in key and "u:" not in key

frontend/src/pages/GraphView.jsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,31 @@ export default function GraphView() {
5353
return () => ro.disconnect()
5454
}, [graphData])
5555

56+
// Bump 3D zoom sensitivity. TrackballControls ships with zoomSpeed=1.2
57+
// which feels sluggish on typical trackpads. The graph ref is populated
58+
// after the Suspense-lazy component mounts, so retry briefly until the
59+
// controls handle is available.
60+
useEffect(() => {
61+
if (mode !== '3d' || !graphData) return
62+
let cancelled = false
63+
const apply = () => {
64+
if (cancelled) return
65+
const g = graphRef.current
66+
if (g && typeof g.controls === 'function') {
67+
const c = g.controls()
68+
if (c) {
69+
c.zoomSpeed = 4
70+
return
71+
}
72+
}
73+
setTimeout(apply, 50)
74+
}
75+
apply()
76+
return () => {
77+
cancelled = true
78+
}
79+
}, [mode, graphData])
80+
5681
// Pre-compute link counts so nodes with more connections can be rendered
5782
// larger. react-force-graph mutates nodes/links (replaces string IDs with
5883
// object refs), so we derive a stable degree count first.

0 commit comments

Comments
 (0)