Sync reading position from Moon Reader app to Bookhive atproto records
atproto bookhive ereader moonreader
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make dashboard public and surface reviews

The dashboard and cover proxy no longer require basic auth; the data
they expose is already public on bookhive.buzz. WebDAV verbs on every
path (including PROPFIND on /) stay gated.

Reviews stored on finished books now render under the book-meta line
on both the current-year and archive lists.

+46 -4
+12
src/waggle/adapters/web/templates/dashboard.html
··· 195 195 } 196 196 .book-meta { font-size: 0.75rem; color: var(--text-muted); } 197 197 .book-stars { color: var(--gruvbox-yellow); } 198 + .book-review { 199 + font-size: 0.8rem; color: var(--text-muted); 200 + font-style: italic; margin-top: 0.25rem; 201 + line-height: 1.4; 202 + display: block; 203 + } 198 204 199 205 /* --- Archive <details> --- */ 200 206 details.archive { ··· 317 323 {% if book.stars %} · <span class="book-stars">{{ book.stars | stars }}</span>{% endif %} 318 324 {% if book.finished_at %} · {{ format_date(book.finished_at) }}{% endif %} 319 325 </span> 326 + {% if book.review %} 327 + <span class="book-review">{{ book.review }}</span> 328 + {% endif %} 320 329 </div> 321 330 </{{ tag }}> 322 331 </li> ··· 346 355 {% if book.stars %} · <span class="book-stars">{{ book.stars | stars }}</span>{% endif %} 347 356 {% if book.finished_at %} · {{ format_date(book.finished_at) }}{% endif %} 348 357 </span> 358 + {% if book.review %} 359 + <span class="book-review">{{ book.review }}</span> 360 + {% endif %} 349 361 </div> 350 362 </{{ tag }}> 351 363 </li>
+3
src/waggle/adapters/web/view.py
··· 29 29 created_at: str | None 30 30 updated_at: str | None 31 31 stars: int | None # 1-10 per lexicon; half-star rendering handled at template time 32 + review: str | None 32 33 moon_filename: str | None 33 34 bookhive_url: str | None # https://bookhive.buzz/books/<hiveId> when we have one 34 35 ··· 82 83 label, slug = STATUS_LABELS.get(status_raw, (status_raw or "Unknown", "unknown")) 83 84 percent = bp.get("percent") 84 85 stars = v.get("stars") 86 + review = v.get("review") 85 87 hive_id = _hive_id(v) 86 88 return BookView( 87 89 title=v.get("title", "Untitled"), ··· 95 97 created_at=v.get("createdAt"), 96 98 updated_at=bp.get("updatedAt"), 97 99 stars=int(stars) if isinstance(stars, (int, float)) else None, 100 + review=review.strip() if isinstance(review, str) and review.strip() else None, 98 101 moon_filename=moon.get("file"), 99 102 bookhive_url=f"https://bookhive.buzz/books/{hive_id}" if hive_id else None, 100 103 )
+14 -1
src/waggle/main.py
··· 39 39 40 40 app = FastAPI(lifespan=lifespan, openapi_url=None, docs_url=None, redoc_url=None) 41 41 42 + def _public(request: Request) -> bool: 43 + """Routes that bypass basic auth. 44 + 45 + The read-only web dashboard (`GET /`) and cover proxy (`GET /blob/*`) 46 + are intentionally public — the data they expose is already public on 47 + bookhive.buzz. Everything else (WebDAV on any verb, including 48 + PROPFIND/GET/PUT on `/` and `/Books/*`) stays gated. 49 + """ 50 + path = request.url.path 51 + if path == "/healthz": 52 + return True 53 + return request.method == "GET" and (path == "/" or path.startswith("/blob/")) 54 + 42 55 class BasicAuthMiddleware(BaseHTTPMiddleware): 43 56 async def dispatch(self, request: Request, call_next): 44 - if request.url.path == "/healthz": 57 + if _public(request): 45 58 return await call_next(request) 46 59 if not auth.check(request, cfg.dav_user, cfg.dav_password): 47 60 return auth.challenge()
+17 -3
tests/test_web_e2e.py
··· 87 87 return {"uri": f"at://{DID}/buzz.bookhive.book/{rkey}", "cid": "cid", "value": value} 88 88 89 89 90 - def test_dashboard_requires_auth(client): 91 - assert client.get("/").status_code == 401 90 + @respx.mock 91 + def test_dashboard_is_public(client): 92 + """Web dashboard is intentionally unauthenticated — data is already public on bookhive.buzz.""" 93 + _mock_session(respx.mock) 94 + _mock_profile(respx.mock) 95 + respx.get("https://pds.example/xrpc/com.atproto.repo.listRecords").mock( 96 + return_value=httpx.Response(200, json=_records([_book("rk1", "Book")])), 97 + ) 98 + assert client.get("/").status_code == 200 99 + 100 + 101 + def test_webdav_on_root_still_requires_auth(client): 102 + """Bypassing auth on GET / must not leak PROPFIND / etc. to the world.""" 103 + assert client.request("PROPFIND", "/").status_code == 401 104 + assert client.request("OPTIONS", "/").status_code == 401 92 105 93 106 94 107 @respx.mock ··· 142 155 ), 143 156 ) 144 157 145 - r = client.get("/blob/bafycover", auth=AUTH) 158 + # No AUTH — cover proxy is public, same as the dashboard. 159 + r = client.get("/blob/bafycover") 146 160 assert r.status_code == 200 147 161 assert r.content.startswith(b"\xff\xd8\xff") 148 162 assert r.headers["content-type"] == "image/jpeg"