33import hashlib
44import json
55import logging
6+ from contextlib import suppress
67from typing import cast
78
89from starlette .requests import Request
2223from feedback .protocol .github .oauth import OAuthError
2324from feedback .service .discussions import DiscussionError
2425from feedback .service .oauth_state import GrantError , StateError
26+ from feedback .service .resources import ResourceError , validate_resource_id
2527from feedback .service .runtime import FeedbackRuntime
28+ from feedback .service .votes import VoteError
2629
2730oauth_logger = logging .getLogger ("feedback.oauth" )
2831github_logger = logging .getLogger ("feedback.github" )
@@ -39,9 +42,11 @@ async def reactions(request: Request) -> Response:
3942 assert origin is not None
4043 return preflight (origin , method = "GET" , headers = "If-None-Match" )
4144 keys = resource_keys (request .query_params , site .max_batch_size )
42- now = int (container .clock ())
4345 cached = container .databases [site .id ].reactions (keys )
44- container .schedule_refresh (site , cached )
46+ with suppress (GitHubError ):
47+ await container .refresh_stale (site , cached )
48+ cached = container .databases [site .id ].reactions (keys )
49+ now = int (container .clock ())
4550 items : dict [str , dict [str , str | int | bool | None ]] = {}
4651 for key in keys :
4752 item = cached .get (key )
@@ -60,7 +65,10 @@ async def reactions(request: Request) -> Response:
6065 body = json .dumps (payload , separators = ("," , ":" ), sort_keys = True ).encode ()
6166 etag = f'"{ hashlib .sha256 (body ).hexdigest ()} "'
6267 headers = {
63- "Cache-Control" : "public, max-age=30, stale-while-revalidate=300" ,
68+ # Revalidate with the service so a successful vote is visible after a
69+ # reload. GitHub traffic is still bounded by the server-side freshness
70+ # window and coalesced refresh task.
71+ "Cache-Control" : "no-cache" ,
6472 "ETag" : etag ,
6573 "Vary" : "Origin" ,
6674 }
@@ -183,6 +191,52 @@ async def ensure_discussion(request: Request) -> Response:
183191 )
184192
185193
194+ async def submit_vote (request : Request ) -> Response :
195+ container , site , origin = site_context (request , require_origin = True )
196+ assert origin is not None
197+ if container .votes is None :
198+ raise ApiError ("service_unavailable" , "Voting is unavailable." , 503 )
199+ if request .method == "OPTIONS" :
200+ return preflight (origin , method = "POST" , headers = "Authorization, Content-Type" )
201+ body = await json_strings (request , frozenset ({"key" , "vote" }))
202+ try :
203+ resource_id = validate_resource_id (body ["key" ])
204+ except ResourceError :
205+ raise ApiError ("invalid_vote" , "Vote parameters are invalid." , 400 ) from None
206+ if body ["vote" ] not in {"up" , "down" }:
207+ raise ApiError ("invalid_vote" , "Vote parameters are invalid." , 400 )
208+ cached = container .databases [site .id ].reactions ([resource_id ])
209+ with suppress (GitHubError ):
210+ await container .refresh_stale (site , cached )
211+ try :
212+ result = await container .votes .vote (
213+ container .databases [site .id ],
214+ resource_id = resource_id ,
215+ requested = body ["vote" ],
216+ token = bearer_token (request ),
217+ )
218+ except VoteError as exc :
219+ raise ApiError ("discussion_not_found" , str (exc ), 404 ) from exc
220+ except GitHubError as exc :
221+ github_logger .warning (
222+ "GitHub vote failed: code=%s status=%s github_request_id=%s site=%s origin=%s" ,
223+ exc .code ,
224+ exc .status ,
225+ exc .request_id ,
226+ site .id ,
227+ origin ,
228+ )
229+ status = 401 if exc .status == 401 else 502
230+ raise ApiError ("github_vote_failed" , "GitHub rejected the vote." , status ) from exc
231+ return cors (
232+ JSONResponse (
233+ {"v" : 1 , "up" : result .up , "down" : result .down , "viewer" : result .viewer },
234+ headers = {"Cache-Control" : "no-store" },
235+ ),
236+ origin ,
237+ )
238+
239+
186240async def api_error (request : Request , error : Exception ) -> Response :
187241 assert isinstance (error , ApiError )
188242 response = error_response (error )
@@ -195,6 +249,20 @@ def services(request: Request) -> FeedbackRuntime:
195249 return cast (FeedbackRuntime , request .app .state .services )
196250
197251
252+ def bearer_token (request : Request ) -> str :
253+ authorization = request .headers .get ("authorization" , "" )
254+ scheme , separator , token = authorization .partition (" " )
255+ if (
256+ separator != " "
257+ or scheme .lower () != "bearer"
258+ or not token .startswith ("ghu_" )
259+ or not 8 <= len (token ) <= 512
260+ or any (character .isspace () for character in token )
261+ ):
262+ raise ApiError ("invalid_token" , "A GitHub user token is required." , 401 )
263+ return token
264+
265+
198266def site_context (
199267 request : Request , * , require_origin : bool = False
200268) -> tuple [FeedbackRuntime , SiteConfig , str | None ]:
0 commit comments