@@ -131,6 +131,7 @@ async def validate_cli_header(
131131 if user_info is None :
132132 raise HTTPException (status_code = 401 , detail = "Invalid or unauthorized X-Popcorn-Cli-Id" )
133133
134+ user_info ["id_type" ] = "cli"
134135 return user_info
135136
136137
@@ -178,6 +179,38 @@ async def validate_user_header(
178179 return user_info
179180
180181
182+ async def optional_user_header (
183+ x_web_auth_id : Optional [str ] = Header (None , alias = "X-Web-Auth-Id" ),
184+ x_popcorn_cli_id : Optional [str ] = Header (None , alias = "X-Popcorn-Cli-Id" ),
185+ db_context : LeaderboardDB = Depends (get_db ),
186+ ) -> Optional [Any ]:
187+ """Like validate_user_header but returns None instead of raising when no auth header is present."""
188+ token = x_web_auth_id or x_popcorn_cli_id
189+ if not token :
190+ return None
191+
192+ if x_web_auth_id :
193+ id_type = IdentityType .WEB
194+ else :
195+ id_type = IdentityType .CLI
196+
197+ try :
198+ with db_context as db :
199+ user_info = db .validate_identity (token , id_type )
200+ except Exception as e :
201+ raise HTTPException (
202+ status_code = 500 ,
203+ detail = f"Database error during validation: { e } " ,
204+ ) from e
205+
206+ if not user_info :
207+ raise HTTPException (
208+ status_code = 401 ,
209+ detail = "Invalid or unauthorized auth header" ,
210+ )
211+ return user_info
212+
213+
181214def require_admin (
182215 authorization : Optional [str ] = Header (None , alias = "Authorization" ),
183216) -> None :
@@ -188,6 +221,16 @@ def require_admin(
188221 raise HTTPException (status_code = 401 , detail = "Invalid admin token" )
189222
190223
224+ def enforce_leaderboard_access (db , leaderboard_name : str , user_info : Optional [dict ]) -> None :
225+ """Raise 401/403 if the leaderboard is closed and the user lacks access."""
226+ lb = db .get_leaderboard (leaderboard_name )
227+ if lb .get ("visibility" ) == "closed" :
228+ if user_info is None :
229+ raise HTTPException (status_code = 401 , detail = "Authentication required for closed leaderboard" )
230+ if not db .check_leaderboard_access (leaderboard_name , user_info ["user_id" ]):
231+ raise HTTPException (status_code = 403 , detail = "You do not have access to this leaderboard" )
232+
233+
191234@app .get ("/auth/init" )
192235async def auth_init (provider : str , db_context = Depends (get_db )) -> dict :
193236 if provider not in ["discord" , "github" ]:
@@ -576,13 +619,18 @@ async def create_dev_leaderboard(
576619 except Exception :
577620 pass # Leaderboard doesn't exist, that's fine
578621
622+ visibility = payload .get ("visibility" , "public" )
623+ if visibility not in ("public" , "closed" ):
624+ raise HTTPException (status_code = 400 , detail = "visibility must be 'public' or 'closed'" )
625+
579626 db .create_leaderboard (
580627 name = leaderboard_name ,
581628 deadline = deadline_value ,
582629 definition = definition ,
583630 creator_id = 0 ,
584631 forum_id = - 1 ,
585632 gpu_types = definition .gpus ,
633+ visibility = visibility ,
586634 )
587635 return {"status" : "ok" , "leaderboard" : leaderboard_name }
588636
@@ -652,6 +700,9 @@ async def admin_update_problems(
652700 problem_set = payload .get ("problem_set" )
653701 branch = payload .get ("branch" , "main" )
654702 force = payload .get ("force" , False )
703+ visibility = payload .get ("visibility" , "public" )
704+ if visibility not in ("public" , "closed" ):
705+ raise HTTPException (status_code = 400 , detail = "visibility must be 'public' or 'closed'" )
655706
656707 try :
657708 result = sync_problems (
@@ -662,6 +713,7 @@ async def admin_update_problems(
662713 force = force ,
663714 creator_id = 0 , # API-created
664715 forum_id = - 1 , # No Discord forum
716+ visibility = visibility ,
665717 )
666718 except ValueError as e :
667719 raise HTTPException (status_code = 400 , detail = str (e )) from e
@@ -740,21 +792,19 @@ async def get_leaderboards(db_context=Depends(get_db)):
740792
741793
742794@app .get ("/gpus/{leaderboard_name}" )
743- async def get_gpus (leaderboard_name : str , db_context = Depends (get_db )) -> list [str ]:
744- """An endpoint that returns all GPU types that are available for a given leaderboard and runner.
745-
746- Args:
747- leaderboard_name (str): The name of the leaderboard to get the GPU types for.
748- runner_name (str): The name of the runner to get the GPU types for.
749-
750- Returns:
751- list[str]: A list of GPU types that are available for the given leaderboard and runner.
752- """
795+ async def get_gpus (
796+ leaderboard_name : str ,
797+ user_info : Annotated [Optional [Any ], Depends (optional_user_header )] = None ,
798+ db_context = Depends (get_db ),
799+ ) -> list [str ]:
800+ """An endpoint that returns all GPU types that are available for a given leaderboard and runner."""
753801 await simple_rate_limit ()
754802 try :
755803 with db_context as db :
804+ enforce_leaderboard_access (db , leaderboard_name , user_info )
756805 return db .get_leaderboard_gpu_types (leaderboard_name )
757-
806+ except HTTPException :
807+ raise
758808 except Exception as e :
759809 raise HTTPException (status_code = 500 , detail = f"Error fetching GPU data: { e } " ) from e
760810
@@ -765,29 +815,39 @@ async def get_submissions(
765815 gpu_name : str ,
766816 limit : int = None ,
767817 offset : int = 0 ,
818+ user_info : Annotated [Optional [Any ], Depends (optional_user_header )] = None ,
768819 db_context = Depends (get_db ),
769820) -> list [LeaderboardRankedEntry ]:
770821 await simple_rate_limit ()
771822 try :
772823 with db_context as db :
773- # Add validation for leaderboard and GPU? Might be redundant if DB handles it.
824+ enforce_leaderboard_access ( db , leaderboard_name , user_info )
774825 return db .get_leaderboard_submissions (
775826 leaderboard_name , gpu_name , limit = limit , offset = offset
776827 )
828+ except HTTPException :
829+ raise
777830 except Exception as e :
778831 raise HTTPException (status_code = 500 , detail = f"Error fetching submissions: { e } " ) from e
779832
780833
781834@app .get ("/submission_count/{leaderboard_name}/{gpu_name}" )
782835async def get_submission_count (
783- leaderboard_name : str , gpu_name : str , user_id : str = None , db_context = Depends (get_db )
836+ leaderboard_name : str ,
837+ gpu_name : str ,
838+ user_id : str = None ,
839+ user_info : Annotated [Optional [Any ], Depends (optional_user_header )] = None ,
840+ db_context = Depends (get_db ),
784841) -> dict :
785842 """Get the total count of submissions for pagination"""
786843 await simple_rate_limit ()
787844 try :
788845 with db_context as db :
846+ enforce_leaderboard_access (db , leaderboard_name , user_info )
789847 count = db .get_leaderboard_submission_count (leaderboard_name , gpu_name , user_id )
790848 return {"count" : count }
849+ except HTTPException :
850+ raise
791851 except Exception as e :
792852 raise HTTPException (status_code = 500 , detail = f"Error fetching submission count: { e } " ) from e
793853
@@ -912,3 +972,88 @@ async def delete_user_submission(
912972 raise
913973 except Exception as e :
914974 raise HTTPException (status_code = 500 , detail = f"Error deleting submission: { e } " ) from e
975+
976+
977+ @app .post ("/admin/invites" )
978+ async def admin_generate_invites (
979+ payload : dict ,
980+ _ : Annotated [None , Depends (require_admin )],
981+ db_context = Depends (get_db ),
982+ ) -> dict :
983+ """Generate invite codes covering one or more leaderboards.
984+
985+ Accepts either:
986+ {"leaderboards": ["lb1", "lb2"], "count": 10}
987+ {"leaderboard": "lb1", "count": 10} (single leaderboard shorthand)
988+ """
989+ count = payload .get ("count" )
990+ if not isinstance (count , int ) or count < 1 or count > 10000 :
991+ raise HTTPException (status_code = 400 , detail = "count must be an integer between 1 and 10000" )
992+ leaderboards = payload .get ("leaderboards" ) or []
993+ if not leaderboards :
994+ single = payload .get ("leaderboard" )
995+ if single :
996+ leaderboards = [single ]
997+ if not leaderboards or not isinstance (leaderboards , list ):
998+ raise HTTPException (status_code = 400 , detail = "Must provide 'leaderboards' list or 'leaderboard' string" )
999+ with db_context as db :
1000+ codes = db .generate_invite_codes (leaderboards , count )
1001+ return {"status" : "ok" , "leaderboards" : leaderboards , "codes" : codes }
1002+
1003+
1004+ @app .get ("/admin/leaderboards/{leaderboard_name}/invites" )
1005+ async def admin_list_invites (
1006+ leaderboard_name : str ,
1007+ _ : Annotated [None , Depends (require_admin )],
1008+ db_context = Depends (get_db ),
1009+ ) -> dict :
1010+ """List all invite codes for a leaderboard with claim status."""
1011+ with db_context as db :
1012+ invites = db .get_invite_codes (leaderboard_name )
1013+ return {"status" : "ok" , "leaderboard" : leaderboard_name , "invites" : invites }
1014+
1015+
1016+ @app .delete ("/admin/invites/{code}" )
1017+ async def admin_revoke_invite (
1018+ code : str ,
1019+ _ : Annotated [None , Depends (require_admin )],
1020+ db_context = Depends (get_db ),
1021+ ) -> dict :
1022+ """Revoke an invite code, removing it from the pool."""
1023+ with db_context as db :
1024+ result = db .revoke_invite_code (code )
1025+ return {"status" : "ok" , ** result }
1026+
1027+
1028+ @app .post ("/admin/leaderboards/{leaderboard_name}/visibility" )
1029+ async def admin_set_visibility (
1030+ leaderboard_name : str ,
1031+ payload : dict ,
1032+ _ : Annotated [None , Depends (require_admin )],
1033+ db_context = Depends (get_db ),
1034+ ) -> dict :
1035+ """Change the visibility of an existing leaderboard."""
1036+ visibility = payload .get ("visibility" )
1037+ if visibility not in ("public" , "closed" ):
1038+ raise HTTPException (status_code = 400 , detail = "visibility must be 'public' or 'closed'" )
1039+ with db_context as db :
1040+ db .set_leaderboard_visibility (leaderboard_name , visibility )
1041+ return {"status" : "ok" , "leaderboard" : leaderboard_name , "visibility" : visibility }
1042+
1043+
1044+ @app .post ("/user/join" )
1045+ async def user_join_leaderboard (
1046+ payload : dict ,
1047+ user_info : Annotated [dict , Depends (validate_cli_header )],
1048+ db_context = Depends (get_db ),
1049+ ) -> dict :
1050+ """Claim an invite code to join a closed leaderboard. CLI only."""
1051+ code = payload .get ("code" )
1052+ if not code :
1053+ raise HTTPException (status_code = 400 , detail = "Missing required field: code" )
1054+ try :
1055+ with db_context as db :
1056+ result = db .claim_invite_code (code , user_info ["user_id" ])
1057+ except KernelBotError as e :
1058+ raise HTTPException (status_code = 400 , detail = str (e )) from e
1059+ return {"status" : "ok" , "leaderboards" : result ["leaderboards" ]}
0 commit comments