fix: add auth flow to review page (#679)

* fix: add auth flow to review page like admin

- make /review/:id HTML page public (keep data/submit protected)
- add auth input, localStorage token check/save
- send X-Moderation-Key header with submit request
- handle 401 by showing auth prompt again

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci: only check rust services that actually changed

use dorny/paths-filter to detect which service changed,
skip cargo check and docker build for unchanged services.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 845182f1 e5b2958e

Changed files
+118 -6
.github
workflows
moderation
+47 -3
.github/workflows/check-rust.yml
··· 11 11 contents: read 12 12 13 13 jobs: 14 + changes: 15 + name: detect changes 16 + runs-on: ubuntu-latest 17 + outputs: 18 + moderation: ${{ steps.filter.outputs.moderation }} 19 + transcoder: ${{ steps.filter.outputs.transcoder }} 20 + steps: 21 + - uses: actions/checkout@v4 22 + - uses: dorny/paths-filter@v3 23 + id: filter 24 + with: 25 + filters: | 26 + moderation: 27 + - 'moderation/**' 28 + - '.github/workflows/check-rust.yml' 29 + transcoder: 30 + - 'transcoder/**' 31 + - '.github/workflows/check-rust.yml' 32 + 14 33 check: 15 34 name: cargo check 16 35 runs-on: ubuntu-latest 17 36 timeout-minutes: 15 37 + needs: changes 18 38 19 39 strategy: 40 + fail-fast: false 20 41 matrix: 21 - service: [moderation, transcoder] 42 + include: 43 + - service: moderation 44 + changed: ${{ needs.changes.outputs.moderation }} 45 + - service: transcoder 46 + changed: ${{ needs.changes.outputs.transcoder }} 22 47 23 48 steps: 24 49 - uses: actions/checkout@v4 50 + if: matrix.changed == 'true' 25 51 26 52 - name: install rust toolchain 53 + if: matrix.changed == 'true' 27 54 uses: dtolnay/rust-toolchain@stable 28 55 29 56 - name: cache cargo 57 + if: matrix.changed == 'true' 30 58 uses: Swatinem/rust-cache@v2 31 59 with: 32 60 workspaces: ${{ matrix.service }} 33 61 34 62 - name: cargo check 63 + if: matrix.changed == 'true' 35 64 working-directory: ${{ matrix.service }} 36 65 run: cargo check --release 37 66 67 + - name: skip (no changes) 68 + if: matrix.changed != 'true' 69 + run: echo "skipping ${{ matrix.service }} - no changes" 70 + 38 71 docker-build: 39 72 name: docker build 40 73 runs-on: ubuntu-latest 41 74 timeout-minutes: 10 42 - needs: check 75 + needs: [changes, check] 43 76 44 77 strategy: 78 + fail-fast: false 45 79 matrix: 46 - service: [moderation, transcoder] 80 + include: 81 + - service: moderation 82 + changed: ${{ needs.changes.outputs.moderation }} 83 + - service: transcoder 84 + changed: ${{ needs.changes.outputs.transcoder }} 47 85 48 86 steps: 49 87 - uses: actions/checkout@v4 88 + if: matrix.changed == 'true' 50 89 51 90 - name: build docker image 91 + if: matrix.changed == 'true' 52 92 working-directory: ${{ matrix.service }} 53 93 run: docker build -t ${{ matrix.service }}:ci-test . 94 + 95 + - name: skip (no changes) 96 + if: matrix.changed != 'true' 97 + run: echo "skipping ${{ matrix.service }} - no changes"
+5 -1
moderation/src/auth.rs
··· 12 12 let path = req.uri().path(); 13 13 14 14 // Public endpoints - no auth required 15 - // Note: /admin serves HTML, auth is handled client-side for API calls 15 + // Note: /admin and /review/:id serve HTML, auth is handled client-side for API calls 16 16 // Static files must be public for admin UI CSS/JS to load 17 + let is_review_page = path.starts_with("/review/") 18 + && !path.ends_with("/data") 19 + && !path.ends_with("/submit"); 17 20 if path == "/" 18 21 || path == "/health" 19 22 || path == "/sensitive-images" 20 23 || path == "/admin" 24 + || is_review_page 21 25 || path.starts_with("/static/") 22 26 || path.starts_with("/xrpc/com.atproto.label.") 23 27 {
+66 -2
moderation/src/review.rs
··· 196 196 <div class="batch-info">{} pending {}</div> 197 197 </header> 198 198 199 - <form id="review-form" class="review-form"> 199 + <div class="auth-section" id="auth-section"> 200 + <input type="password" id="auth-token" placeholder="auth token" 201 + onkeyup="if(event.key==='Enter')authenticate()"> 202 + <button class="btn-submit" onclick="authenticate()">authenticate</button> 203 + </div> 204 + 205 + <form id="review-form" class="review-form" style="display: none;"> 200 206 <div class="flags-list"> 201 207 {} 202 208 </div> ··· 214 220 <script> 215 221 const form = document.getElementById('review-form'); 216 222 const submitBtn = document.getElementById('submit-btn'); 223 + const authSection = document.getElementById('auth-section'); 217 224 const batchId = '{}'; 218 225 226 + let currentToken = ''; 219 227 const decisions = {{}}; 220 228 229 + function authenticate() {{ 230 + const token = document.getElementById('auth-token').value; 231 + if (token && token !== '••••••••') {{ 232 + localStorage.setItem('mod_token', token); 233 + currentToken = token; 234 + showReviewForm(); 235 + }} 236 + }} 237 + 238 + function showReviewForm() {{ 239 + authSection.style.display = 'none'; 240 + form.style.display = 'block'; 241 + }} 242 + 243 + // Check for saved token on load 244 + const savedToken = localStorage.getItem('mod_token'); 245 + if (savedToken) {{ 246 + currentToken = savedToken; 247 + document.getElementById('auth-token').value = '••••••••'; 248 + showReviewForm(); 249 + }} 250 + 221 251 function updateSubmitBtn() {{ 222 252 const count = Object.keys(decisions).length; 223 253 submitBtn.disabled = count === 0; ··· 242 272 try {{ 243 273 const response = await fetch(`/review/${{batchId}}/submit`, {{ 244 274 method: 'POST', 245 - headers: {{ 'Content-Type': 'application/json' }}, 275 + headers: {{ 276 + 'Content-Type': 'application/json', 277 + 'X-Moderation-Key': currentToken 278 + }}, 246 279 body: JSON.stringify({{ 247 280 decisions: Object.entries(decisions).map(([uri, decision]) => ({{ uri, decision }})) 248 281 }}) 249 282 }}); 283 + 284 + if (response.status === 401) {{ 285 + localStorage.removeItem('mod_token'); 286 + currentToken = ''; 287 + authSection.style.display = 'flex'; 288 + form.style.display = 'none'; 289 + document.getElementById('auth-token').value = ''; 290 + alert('invalid token'); 291 + return; 292 + }} 250 293 251 294 if (response.ok) {{ 252 295 const result = await response.json(); ··· 393 436 h1 { font-size: 1.25rem; font-weight: 600; color: #fff; } 394 437 .batch-info { font-size: 0.875rem; color: #888; } 395 438 .status-badge { font-size: 0.7rem; background: #1a3a1a; color: #6d9; padding: 2px 6px; border-radius: 4px; margin-left: 8px; } 439 + 440 + .auth-section { 441 + display: flex; 442 + gap: 10px; 443 + margin-bottom: 20px; 444 + align-items: center; 445 + } 446 + .auth-section input[type="password"] { 447 + flex: 1; 448 + padding: 10px 12px; 449 + background: #1a1a1a; 450 + border: 1px solid #333; 451 + border-radius: 6px; 452 + color: #fff; 453 + font-size: 0.875rem; 454 + } 455 + .auth-section input:focus { 456 + outline: none; 457 + border-color: #4a9eff; 458 + } 459 + 396 460 .flags-list { display: flex; flex-direction: column; gap: 12px; } 397 461 398 462 .review-card {