diff --git a/backend/alembic/versions/0110b0fe23e2_add_selected_repo_to_user_model.py b/backend/alembic/versions/0110b0fe23e2_add_selected_repo_to_user_model.py new file mode 100644 index 0000000..c645e87 --- /dev/null +++ b/backend/alembic/versions/0110b0fe23e2_add_selected_repo_to_user_model.py @@ -0,0 +1,38 @@ +"""add selected_repo to User model + +Revision ID: 0110b0fe23e2 +Revises: dd6498244230 +Create Date: 2025-04-11 10:02:54.906969 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0110b0fe23e2' +down_revision: Union[str, None] = 'dd6498244230' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'github_id', + existing_type=sa.INTEGER(), + type_=sa.String(), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'github_id', + existing_type=sa.String(), + type_=sa.INTEGER(), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/3941b5ea8743_add_selected_repo_to_user_model.py b/backend/alembic/versions/3941b5ea8743_add_selected_repo_to_user_model.py new file mode 100644 index 0000000..e33c762 --- /dev/null +++ b/backend/alembic/versions/3941b5ea8743_add_selected_repo_to_user_model.py @@ -0,0 +1,32 @@ +"""add selected_repo to User model + +Revision ID: 3941b5ea8743 +Revises: 0110b0fe23e2 +Create Date: 2025-04-11 10:32:09.102609 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3941b5ea8743' +down_revision: Union[str, None] = '0110b0fe23e2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/alembic/versions/a37610476ce3_add_selected_repo_to_users.py b/backend/alembic/versions/a37610476ce3_add_selected_repo_to_users.py new file mode 100644 index 0000000..9b6be45 --- /dev/null +++ b/backend/alembic/versions/a37610476ce3_add_selected_repo_to_users.py @@ -0,0 +1,32 @@ +"""add selected_repo to users + +Revision ID: a37610476ce3 +Revises: 3941b5ea8743 +Create Date: 2025-04-11 17:07:29.751848 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a37610476ce3' +down_revision: Union[str, None] = '3941b5ea8743' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('selected_repo', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'selected_repo') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/fffe4c516230_make_last_push_and_last_login_nullable.py b/backend/alembic/versions/fffe4c516230_make_last_push_and_last_login_nullable.py new file mode 100644 index 0000000..39f3448 --- /dev/null +++ b/backend/alembic/versions/fffe4c516230_make_last_push_and_last_login_nullable.py @@ -0,0 +1,32 @@ +"""make last_push and last_login nullable + +Revision ID: fffe4c516230 +Revises: a37610476ce3 +Create Date: 2025-04-11 18:32:12.060038 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fffe4c516230' +down_revision: Union[str, None] = 'a37610476ce3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/auth.py b/backend/auth.py index 202c9de..0f1bba5 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -3,6 +3,7 @@ from fastapi import Header, HTTPException import os from dotenv import load_dotenv +import traceback # Load environment variables def get_database_url(): @@ -31,7 +32,9 @@ def create_jwt_token(data: dict): to_encode = data.copy() expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + jwt_token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + print(f"[auth.py] Created JWT token: {jwt_token[:20]}...") + return jwt_token def create_access_token(data: dict): """Alias for create_jwt_token for backward compatibility""" @@ -39,6 +42,7 @@ def create_access_token(data: dict): def verify_token(token: str): """Verify and decode a JWT token""" + try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) if "exp" not in payload: @@ -51,21 +55,26 @@ def verify_token(token: str): return None except Exception as e: print(f"Token verification error: {str(e)}") + return None async def get_current_user(authorization: str = Header(...)): """Get current user from authorization header""" try: + token = authorization.replace("Bearer ", "") payload = verify_token(token) if not payload: + raise HTTPException( status_code=401, detail="Invalid or expired token" ) + return payload except Exception as e: raise HTTPException( status_code=401, detail=f"Authorization failed: {str(e)}" - ) \ No newline at end of file + ) + diff --git a/backend/github_oauth.py b/backend/github_oauth.py index f0ef4df..6e58735 100644 --- a/backend/github_oauth.py +++ b/backend/github_oauth.py @@ -1,16 +1,23 @@ import os import httpx -from fastapi import Request +import logging +from fastapi import Request, HTTPException + +logger = logging.getLogger(__name__) GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") if not GITHUB_CLIENT_ID or not GITHUB_CLIENT_SECRET: + logger.error("GitHub OAuth credentials missing!") + logger.error(f"GITHUB_CLIENT_ID: {'present' if GITHUB_CLIENT_ID else 'missing'}") + logger.error(f"GITHUB_CLIENT_SECRET: {'present' if GITHUB_CLIENT_SECRET else 'missing'}") raise ValueError("❌ GitHub OAuth credentials not found") print(f"πŸ”‘ GitHub OAuth Configuration: Client ID: {GITHUB_CLIENT_ID[:5]}...") async def exchange_code_for_token(code: str): + """Exchange GitHub OAuth code for token data""" try: async with httpx.AsyncClient() as client: @@ -26,13 +33,14 @@ async def exchange_code_for_token(code: str): headers={ "Accept": "application/json" }, + data={ "client_id": GITHUB_CLIENT_ID, "client_secret": GITHUB_CLIENT_SECRET, "code": code } ) - + print(f"βœ… GitHub token exchange status: {response.status_code}") print(f"Response headers: {dict(response.headers)}") @@ -132,4 +140,4 @@ async def get_user_info(access_token: str): except Exception as e: print(f"❌ Error getting user info: {str(e)}") - raise + raise \ No newline at end of file diff --git a/backend/github_push.py b/backend/github_push.py index d01366b..1957f5a 100644 --- a/backend/github_push.py +++ b/backend/github_push.py @@ -2,21 +2,163 @@ from datetime import datetime import base64 from fastapi import HTTPException +import logging +import traceback GITHUB_API_URL = "https://api.github.com" async def repo_exists(access_token: str, repo: str): - url = f"{GITHUB_API_URL}/repos/{repo}" - headers = {"Authorization": f"Bearer {access_token}"} - async with httpx.AsyncClient() as client: - res = await client.get(url, headers=headers) - return res.status_code == 200 + """Check if a repository exists and is accessible to the user""" + print(f"[github_push.py] Checking if repository exists: {repo}") + print(f"[github_push.py] Access token (first 10 chars): {access_token[:10]}...") + + if not repo or '/' not in repo: + print(f"[github_push.py] Invalid repository format: {repo}") + return False + + # Split the repository into owner and name + try: + owner, name = repo.split('/') + if not owner or not name: + print(f"[github_push.py] Invalid repository format (missing owner or name): {repo}") + return False + except ValueError: + print(f"[github_push.py] Invalid repository format (couldn't split): {repo}") + return False + + # First, verify user authentication + # Try both token formats (GitHub supports 'token' prefix and 'Bearer' prefix) + headers1 = { + "Authorization": f"token {access_token}", # GitHub's preferred format + "Accept": "application/vnd.github+json", + "User-Agent": "LIT1337-App/1.0", + "X-GitHub-Api-Version": "2022-11-28" + } + + headers2 = { + "Authorization": f"Bearer {access_token}", # OAuth standard + "Accept": "application/vnd.github+json", + "User-Agent": "LIT1337-App/1.0", + "X-GitHub-Api-Version": "2022-11-28" + } + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + # First try the token prefix format + print(f"[github_push.py] First attempt with 'token' prefix") + user_url = f"{GITHUB_API_URL}/user" + user_res = await client.get(user_url, headers=headers1) + + if user_res.status_code != 200: + print(f"[github_push.py] First attempt failed with status {user_res.status_code}, trying Bearer format") + # Try Bearer format if token format fails + user_res = await client.get(user_url, headers=headers2) + + if user_res.status_code != 200: + print(f"[github_push.py] Both authentication attempts failed: {user_res.status_code} - {user_res.text}") + if user_res.status_code == 401: + raise HTTPException(status_code=401, detail="GitHub authentication failed - token might be invalid or expired") + return False + else: + # Bearer format worked, use these headers + print(f"[github_push.py] Bearer format worked. Using Bearer prefix for future requests.") + headers = headers2 + else: + # Token format worked, use these headers + print(f"[github_push.py] Token format worked. Using token prefix for future requests.") + headers = headers1 + + # Get authenticated username to verify repo access permissions + username = user_res.json().get("login") + if not username: + print("[github_push.py] Could not get authenticated username") + return False + + print(f"[github_push.py] Authenticated as: {username}") + + # Try to get the repository directly + repo_url = f"{GITHUB_API_URL}/repos/{repo}" + print(f"[github_push.py] Checking repository URL: {repo_url}") + + repo_res = await client.get(repo_url, headers=headers) + + # Log detailed info for debugging + print(f"[github_push.py] Repo check status: {repo_res.status_code}") + if repo_res.status_code != 200: + print(f"[github_push.py] Repo check failed: {repo_res.status_code}") + try: + error_json = repo_res.json() + print(f"[github_push.py] Error details: {error_json}") + except: + print(f"[github_push.py] Error text: {repo_res.text}") + + # Repository exists and is accessible if status code is 200 + if repo_res.status_code == 200: + print(f"[github_push.py] Repository {repo} exists and is accessible") + return True + + # Handle specific error cases + if repo_res.status_code == 404: + print(f"[github_push.py] Repository not found: {repo}") + + # Try checking if owner exists + user_profile_url = f"{GITHUB_API_URL}/users/{owner}" + user_profile_res = await client.get(user_profile_url, headers=headers) + + if user_profile_res.status_code != 200: + print(f"[github_push.py] GitHub user '{owner}' may not exist") + return False + + # If owner exists, check if repo might be private + if owner != username: + print(f"[github_push.py] Repository owner {owner} is not the authenticated user {username}") + + # List public repos for this owner + user_repos_url = f"{GITHUB_API_URL}/users/{owner}/repos?per_page=100" + user_repos_res = await client.get(user_repos_url, headers=headers) + + if user_repos_res.status_code == 200: + repos = user_repos_res.json() + repo_names = [r.get("name") for r in repos if r.get("name")] + + if repo_names and name not in repo_names: + print(f"[github_push.py] Repository '{name}' not found in public repos of user '{owner}'") + print(f"[github_push.py] Available repos: {repo_names[:5]}") + else: + print(f"[github_push.py] Repository exists but may be private") + + # Try to fetch the exact repository info directly + specific_repo_url = f"{GITHUB_API_URL}/repos/{owner}/{name}" + specific_repo_res = await client.get(specific_repo_url, headers=headers) + + if specific_repo_res.status_code == 200: + print(f"[github_push.py] Direct repository check succeeded!") + return True + else: + print(f"[github_push.py] Direct repository check failed: {specific_repo_res.status_code}") + + return False + + else: + print(f"[github_push.py] GitHub API error: {repo_res.status_code} - {repo_res.text}") + return False + + except httpx.RequestError as e: + print(f"[github_push.py] Request error checking repository: {str(e)}") + return False + except Exception as e: + print(f"[github_push.py] Unexpected error checking repository: {str(e)}") + print(traceback.format_exc()) + return False async def create_repo(access_token: str, repo_name: str): + """Create a new repository for the authenticated user""" + print(f"Creating new repository: {repo_name}") url = f"{GITHUB_API_URL}/user/repos" headers = { "Authorization": f"Bearer {access_token}", - "Accept": "application/vnd.github+json" + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" } json = { "name": repo_name, @@ -24,17 +166,37 @@ async def create_repo(access_token: str, repo_name: str): "private": False, "auto_init": True } - async with httpx.AsyncClient() as client: - res = await client.post(url, headers=headers, json=json) - return res.status_code == 201 + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + res = await client.post(url, headers=headers, json=json) + + if res.status_code == 201: + print(f"Repository created successfully: {repo_name}") + return True + else: + error_message = f"Failed to create repository: {res.status_code}" + try: + error_json = res.json() + if "message" in error_json: + error_message = f"GitHub error: {error_json['message']}" + except Exception: + error_message = f"GitHub returned status {res.status_code}: {res.text}" + + print(error_message) + return False + except Exception as e: + print(f"Error creating repository: {str(e)}") + return False async def get_existing_file_sha(access_token: str, repo: str, path: str): url = f"{GITHUB_API_URL}/repos/{repo}/contents/{path}" headers = { "Authorization": f"Bearer {access_token}", - "Accept": "application/vnd.github+json" + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=10.0) as client: res = await client.get(url, headers=headers) if res.status_code == 200: data = res.json() @@ -45,58 +207,236 @@ async def get_existing_file_content(access_token: str, repo: str, path: str): url = f"{GITHUB_API_URL}/repos/{repo}/contents/{path}" headers = { "Authorization": f"Bearer {access_token}", - "Accept": "application/vnd.github+json" + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" } - async with httpx.AsyncClient() as client: - res = await client.get(url, headers=headers) - if res.status_code == 200: - data = res.json() - return base64.b64decode(data.get("content")).decode(), data.get("sha") + try: + async with httpx.AsyncClient(timeout=10.0) as client: + res = await client.get(url, headers=headers) + if res.status_code == 200: + data = res.json() + return base64.b64decode(data.get("content")).decode('utf-8'), data.get("sha") + return None, None + except Exception as e: + print(f"Error getting file content: {str(e)}") return None, None async def push_code_to_github(access_token: str, repo: str, filename: str, content: str): try: - # ν•œ 번의 API 호좜둜 처리 - url = f"{GITHUB_API_URL}/repos/{repo}/contents/{filename}" - headers = { - "Authorization": f"Bearer {access_token}", - "Accept": "application/vnd.github+json" + # Log request info (without sensitive data) + print(f"[github_push.py] Pushing to GitHub repo: {repo}, file: {filename}, content length: {len(content)}") + print(f"[github_push.py] Access token (first 10 chars): {access_token[:10]}...") + + # Try both token formats (GitHub supports 'token' prefix and 'Bearer' prefix) + headers1 = { + "Authorization": f"token {access_token}", # GitHub's preferred format + "Accept": "application/vnd.github+json", + "User-Agent": "LIT1337-App/1.0", + "X-GitHub-Api-Version": "2022-11-28" + } + + headers2 = { + "Authorization": f"Bearer {access_token}", # OAuth standard + "Accept": "application/vnd.github+json", + "User-Agent": "LIT1337-App/1.0", + "X-GitHub-Api-Version": "2022-11-28" } - async with httpx.AsyncClient() as client: - # 파일 쑴재 여뢀와 λ‚΄μš©μ„ ν•œ λ²ˆμ— 확인 - existing_file = await client.get(url, headers=headers) + # Ensure repo format is correct (username/repo) + if '/' not in repo: + error_msg = f"Invalid repository format: {repo}. Should be 'username/repo'" + print(f"[github_push.py] {error_msg}") + raise HTTPException(status_code=400, detail=error_msg) - if existing_file.status_code == 200: - existing_data = existing_file.json() - existing_content = base64.b64decode(existing_data["content"]).decode() + # Split the repository into owner and name + try: + owner, name = repo.split('/') + if not owner or not name: + error_msg = f"Invalid repository format (missing owner or name): {repo}" + print(f"[github_push.py] {error_msg}") + raise HTTPException(status_code=400, detail=error_msg) + except ValueError: + error_msg = f"Invalid repository format (couldn't split): {repo}" + print(f"[github_push.py] {error_msg}") + raise HTTPException(status_code=400, detail=error_msg) + + # Verify user authentication and determine which header format works + try: + async with httpx.AsyncClient(timeout=15.0) as client: + # First try the token prefix format + print(f"[github_push.py] Auth test - first attempt with 'token' prefix") + user_url = f"{GITHUB_API_URL}/user" + user_res = await client.get(user_url, headers=headers1) - if existing_content.strip() == content.strip(): - return 200, {"message": "No change"} + if user_res.status_code != 200: + print(f"[github_push.py] Auth test - first attempt failed with status {user_res.status_code}, trying Bearer format") + # Try Bearer format if token format fails + user_res = await client.get(user_url, headers=headers2) - payload = { - "message": f"Update LeetCode solution: {filename}", - "content": base64.b64encode(content.encode()).decode(), - "sha": existing_data["sha"] - } - else: - payload = { - "message": f"Add LeetCode solution: {filename}", - "content": base64.b64encode(content.encode()).decode() - } + if user_res.status_code != 200: + print(f"[github_push.py] Auth test - both authentication attempts failed: {user_res.status_code} - {user_res.text}") + if user_res.status_code == 401: + raise HTTPException(status_code=401, detail="GitHub authentication failed - token might be invalid or expired") + raise HTTPException(status_code=user_res.status_code, detail=f"GitHub API error: {user_res.text}") + else: + # Bearer format worked, use these headers + print(f"[github_push.py] Auth test - Bearer format worked. Using Bearer prefix for future requests.") + headers = headers2 + else: + # Token format worked, use these headers + print(f"[github_push.py] Auth test - Token format worked. Using token prefix for future requests.") + headers = headers1 + + # Authentication succeeded, we now have a working headers object + print(f"[github_push.py] Authentication successful") + + # Get authenticated username + username = user_res.json().get("login") + print(f"[github_push.py] Authenticated as: {username}") + + # Extra validation check for the repository before proceeding + repo_exists_check = await repo_exists(access_token, repo) + if not repo_exists_check: + error_msg = f"Repository '{repo}' not found or not accessible. Please check if it exists and you have permissions." + print(f"[github_push.py] {error_msg}") + # Return 404 directly to ensure proper error propagation + raise HTTPException(status_code=404, detail=error_msg) + + # URL for GitHub API + url = f"{GITHUB_API_URL}/repos/{repo}/contents/{filename}" + print(f"[github_push.py] GitHub API URL: {url}") - response = await client.put(url, headers=headers, json=payload) - return response.status_code, response.json() + # Encode content properly + try: + encoded_content = base64.b64encode(content.encode('utf-8')).decode('utf-8') + except Exception as e: + print(f"[github_push.py] Content encoding error: {str(e)}") + raise HTTPException(status_code=400, detail=f"Failed to encode content: {str(e)}") + + # Check if file exists first + print(f"[github_push.py] Checking if file exists: {url}") + client.timeout = 30.0 # 30 seconds timeout + existing_file = await client.get(url, headers=headers) + + # Handle status codes explicitly + if existing_file.status_code == 200: + # File exists, get its content and SHA + existing_data = existing_file.json() + + try: + existing_content = base64.b64decode(existing_data["content"]).decode('utf-8') + + if existing_content.strip() == content.strip(): + print("[github_push.py] File content unchanged") + return 200, {"message": "No change"} + except Exception as e: + print(f"[github_push.py] Error decoding existing content: {str(e)}") + + payload = { + "message": f"Update LeetCode solution: {filename}", + "content": encoded_content, + "sha": existing_data["sha"] + } + print(f"[github_push.py] Updating existing file {filename} in {repo}") + + elif existing_file.status_code == 404: + # File doesn't exist, create new file + # This is an expected case - create a new file + payload = { + "message": f"Add LeetCode solution: {filename}", + "content": encoded_content + } + print(f"[github_push.py] Creating new file {filename} in {repo}") + else: + # For other unexpected status codes, return the error + error_text = existing_file.text + print(f"[github_push.py] Unexpected status checking file: {existing_file.status_code} - {error_text}") + + # Parse response as JSON if possible + try: + error_json = existing_file.json() + error_detail = error_json.get("message", error_text) + except: + error_detail = error_text + + # Return the actual status code directly + raise HTTPException( + status_code=existing_file.status_code, + detail=f"GitHub API error: {error_detail}" + ) + + # Push to GitHub + print(f"[github_push.py] Sending PUT request to GitHub API") + response = await client.put(url, headers=headers, json=payload) + + print(f"[github_push.py] GitHub API response: {response.status_code}") + if response.status_code in [200, 201]: + result = response.json() + print(f"[github_push.py] Successfully pushed file!") + return response.status_code, result + else: + error_text = response.text + print(f"[github_push.py] GitHub API error: {response.status_code} - {error_text}") + + # Parse response as JSON if possible + try: + error_json = response.json() + # Preserve the actual status code + return response.status_code, error_json + except: + return response.status_code, {"message": f"GitHub API error: {error_text}"} + + except httpx.HTTPStatusError as e: + print(f"[github_push.py] HTTP error: {e.response.status_code} - {e.response.text}") + # Special handling for 404 (file or repo not found) + if e.response.status_code == 404: + raise HTTPException(status_code=404, detail=f"Resource not found: {e.response.text}") + else: + # For other HTTP errors, preserve the status code + raise HTTPException(status_code=e.response.status_code, detail=e.response.text) + + except HTTPException: + raise + + except Exception as e: + print(f"[github_push.py] Unexpected error: {str(e)}") + traceback_str = traceback.format_exc() + print(traceback_str) + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + except httpx.TimeoutException: + print(f"[github_push.py] Timeout error when contacting GitHub API") + raise HTTPException(status_code=504, detail="GitHub API request timed out") + except httpx.RequestError as e: - raise HTTPException(status_code=500, detail=f"GitHub API error: {str(e)}") + print(f"[github_push.py] GitHub API request error: {str(e)}") + raise HTTPException(status_code=500, detail=f"GitHub API request error: {str(e)}") + + except HTTPException: + # Re-raise HTTP exceptions directly to preserve status codes + raise + + except Exception as e: + traceback_str = traceback.format_exc() + print(f"[github_push.py] Unexpected error: {str(e)}") + print(traceback_str) + + # Check for 404 patterns in the error message + error_message = str(e).lower() + if "not found" in error_message or "404" in error_message or "not accessible" in error_message: + raise HTTPException(status_code=404, detail=f"Repository '{repo}' or file not found: {str(e)}") + else: + raise HTTPException(status_code=500, detail=f"Failed to push code: {str(e)}") async def check_and_push_code(access_token: str, repo: str, filename: str, content: str): existing_content, sha = await get_existing_file_content(access_token, repo, filename) status, result = await push_code_to_github(access_token, repo, filename, content) - if status != 200: - raise Exception(f"Failed to push code: {result.get('error', 'Unknown error')}") + if status not in [200, 201]: + error_msg = result.get('message', f"Failed to push code: {result}") + print(f"Error in check_and_push_code: {error_msg}") + raise Exception(error_msg) return False, "Code pushed successfully" diff --git a/backend/main.py b/backend/main.py index 3db76ff..dcf3bf3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,6 @@ from init_db import init_db from dotenv import load_dotenv from fastapi.middleware.cors import CORSMiddleware -from routers import user, stats, auth, push, solution from fastapi import FastAPI import os @@ -13,7 +12,9 @@ "https://leetcode.com", "https://leetcode.cn", "http://localhost:3000", - "http://localhost:5173" + "http://localhost:5173", + "chrome-extension://*", # Allow all Chrome extensions + "https://lit1337-dev.up.railway.app" ] if os.getenv("ADDITIONAL_ORIGINS"): @@ -21,20 +22,16 @@ app.add_middleware( CORSMiddleware, - allow_origins=["https://leetcode.com"], + allow_origins=ALLOWED_ORIGINS, allow_credentials=True, - allow_methods=["POST", "GET", "OPTIONS"], - allow_headers=[ - "Content-Type", - "Authorization", - "Accept", - "Origin", - "X-Requested-With" - ], - expose_headers=["*"], - max_age=3600, + allow_methods=["*"], # Allow all methods + allow_headers=["*"], # Allow all headers including Authorization + expose_headers=["*"] ) +# Import routers after FastAPI app is created +from routers import user, stats, auth, push, solution + app.include_router(user.user_router) app.include_router(stats.stats_router) app.include_router(auth.auth_router) @@ -46,7 +43,10 @@ async def startup_event(): print("🟑 [startup] Running startup event...") - +@app.get("/") +async def root(): + return {"status": "ok", "message": "LIT1337 API is running"} + @app.get("/ping") async def ping(): return {"message": "pong"} diff --git a/backend/models.py b/backend/models.py index 6a60ac8..1fda892 100644 --- a/backend/models.py +++ b/backend/models.py @@ -22,6 +22,7 @@ class User(Base): github_id = Column(String, unique=True, index=True) username = Column(String, unique=True, index=True) access_token = Column(String) + selected_repo = Column(String, nullable=True) # Store the selected repository last_push = Column(DateTime(timezone=True)) last_login = Column(DateTime(timezone=True)) solutions = relationship("Solution", back_populates="user") diff --git a/backend/requirements.txt b/backend/requirements.txt index 9aec44c..5800034 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -17,6 +17,7 @@ idna==3.10 Mako==1.3.9 MarkupSafe==3.0.2 psycopg2==2.9.10 +psycopg2-binary pyasn1==0.4.8 pydantic==2.11.1 pydantic_core==2.33.0 diff --git a/backend/routers/auth.py b/backend/routers/auth.py index e3ea091..b51b6a4 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, HTTPException, Header, Request, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select + from models import User, PushLog, Problem, Solution from database import SessionLocal, get_db from github_oauth import exchange_code_for_token, get_user_info @@ -10,9 +11,12 @@ from datetime import datetime, timedelta import json + auth_router = APIRouter() +logger = logging.getLogger(__name__) @auth_router.get("/login/github/callback") + async def github_callback(code: str, db: AsyncSession = Depends(get_db)): try: print(f"πŸš€ Processing GitHub callback with code: {code[:10]}...") @@ -109,6 +113,7 @@ async def github_callback(code: str, db: AsyncSession = Depends(get_db)): print(f"❌ Exception args: {getattr(e, 'args', [])}") raise HTTPException(status_code=500, detail=str(e)) + async def get_current_user(authorization: str = Header(...)): token = authorization.replace("Bearer ", "") payload = verify_token(token) diff --git a/backend/routers/push.py b/backend/routers/push.py index 4a911b3..3cbd67f 100644 --- a/backend/routers/push.py +++ b/backend/routers/push.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request, Body +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from models import User, PushLog, Problem, Solution @@ -10,12 +12,30 @@ import base64 import httpx from datetime import datetime +import traceback +import logging -push_router = APIRouter() +# Add prefix to the router +push_router = APIRouter(prefix="", tags=["push"]) -@push_router.post("/push-code") -async def push_code(data: dict, user=Depends(get_current_user), db: AsyncSession = Depends(get_db)): +# Define a model for the request body +class PushCodeRequest(BaseModel): + filename: str = Field(..., description="Filename for the code file") + code: str = Field(..., description="Code to be pushed") + selected_repo: str = Field(..., description="Repository to push to (username/repo format)") + +# Define a model for the save repository request +class SaveRepositoryRequest(BaseModel): + repository: str = Field(..., description="Repository to save (username/repo format)") + +@push_router.post("/save-repository") +async def save_repository( + data: SaveRepositoryRequest = Body(...), + user=Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): try: + if not data.get("filename") or not data.get("code") or not data.get("selected_repo"): raise HTTPException(status_code=400, detail="Missing required fields") @@ -24,13 +44,35 @@ async def push_code(data: dict, user=Depends(get_current_user), db: AsyncSession language = filename.split(".")[-1] selected_repo = data.get("selected_repo") + + # Log operation + print(f"[push.py] Saving repository '{repository}' for user") + + # Validate repository format + repo_parts = repository.split("/") + if len(repo_parts) != 2 or not repo_parts[0] or not repo_parts[1]: + print(f"[push.py] Invalid repository format: {repository}") + raise HTTPException( + status_code=400, + detail=f"Invalid repository format: {repository}. Should be 'username/repo'" + ) + + # Get GitHub ID from token github_id = user.get("github_id") + if not github_id: + print(f"[push.py] GitHub ID not found in token: {user}") + raise HTTPException(status_code=401, detail="GitHub ID not found in token") + + # Find user in database result = await db.execute(select(User).where(User.github_id == github_id)) user_obj = result.scalar_one_or_none() if not user_obj: - raise HTTPException(status_code=404, detail="User not found") - + print(f"[push.py] User not found in database for GitHub ID: {github_id}") + raise HTTPException(status_code=404, detail="User not found in database") + + # Verify access token access_token = user_obj.access_token + user_info = await get_user_info(access_token) github_username = user_info.get("login") @@ -43,53 +85,128 @@ async def push_code(data: dict, user=Depends(get_current_user), db: AsyncSession # push to selected repository status, result = await push_code_to_github(access_token, selected_repo, filename, code) - - # 201, 200 OK - if status not in [200, 201]: - raise HTTPException(status_code=status, detail=result.get("message", "Failed to push code")) - - if result.get("message") == "No change": - return {"message": "No change"} - # Extract slug and calculate points - slug = filename.split("_", 1)[-1].rsplit(".", 1)[0].replace("_", "-").lower() - # Get difficulty info - difficulty_info = await get_problem_difficulty(slug) - difficulty = difficulty_info.get("difficulty") if difficulty_info else None - point_map = {"Easy": 3, "Medium": 6, "Hard": 12} - point = point_map.get(difficulty, 0) - - # Insert into Problem table if not exists - existing_problem = await db.execute(select(Problem).where(Problem.slug == slug)) - if not existing_problem.scalar_one_or_none(): - db.add(Problem(slug=slug, difficulty=difficulty, point=point)) - await db.commit() - - # Insert into PushLog - db.add(PushLog(user_id=user_obj.id, filename=filename, language=language)) + # Verify repository exists + try: + repo_check = await repo_exists(access_token, repository) + if not repo_check: + repo_owner, repo_name = repo_parts + print(f"[push.py] Repository not found: {repository}") + + # Get GitHub username + user_info = await get_user_info(access_token) + github_username = user_info.get("login") + + if repo_owner == github_username: + # User is the owner, try to create the repo + print(f"[push.py] User owns this repo. Attempting to create: {repo_name}") + repo_created = await create_repo(access_token, repo_name) + + if repo_created: + print(f"[push.py] Successfully created repository {repo_name}") + else: + print(f"[push.py] Failed to create repository: {repo_name}") + raise HTTPException( + status_code=404, + detail=f"Repository '{repository}' does not exist and could not be created." + ) + else: + # User is not the owner + print(f"[push.py] User is not the owner of {repository}") + raise HTTPException( + status_code=404, + detail=f"Repository '{repository}' not found or not accessible." + ) + except HTTPException as e: + raise + except Exception as e: + print(f"[push.py] Error verifying repository: {str(e)}") + raise HTTPException( + status_code=404, + detail=f"Repository verification error: {str(e)}" + ) + + # Update user's selected repository + user_obj.selected_repo = repository await db.commit() - - # Check if the solution was accepted - if result.get("message") != "No change": - # Insert into Solution table - db.add(Solution(user_id=user_obj.id, problem_slug=slug, language=language, code=code)) - await db.commit() - + return { - "message": "uploaded to github!", - "difficulty": difficulty, - "point": point, - "pushed_at": datetime.now().isoformat() + "message": "Repository saved successfully", + "repository": repository } - + + except HTTPException as he: + print(f"HTTP exception: {he.status_code} - {he.detail}") + raise except Exception as e: - print(f"Error in push_code: {str(e)}") + print(f"Unhandled error in save_repository: {str(e)}") await db.rollback() - # 201=ok - if "201" in str(e): - return { - "message": "uploaded to github!", - "pushed_at": datetime.now().isoformat() - } + raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") + +@push_router.post("/push-code") +async def push_code( + request: Request, + data: PushCodeRequest = Body(...), + user=Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + try: + # Log the incoming request for debugging + print(f"[push.py] Received push request from user: {user}") + print(f"[push.py] Request data: {data}") + + # Get user from database + github_id = user.get("github_id") + if not github_id: + raise HTTPException(status_code=401, detail="GitHub ID not found in token") + + result = await db.execute(select(User).where(User.github_id == github_id)) + user_obj = result.scalar_one_or_none() + + if not user_obj: + raise HTTPException(status_code=404, detail="User not found in database") + + # Get access token + access_token = user_obj.access_token + if not access_token: + raise HTTPException(status_code=401, detail="GitHub access token not found") + + # Use selected_repo from request or user's saved repo + selected_repo = data.selected_repo or user_obj.selected_repo + if not selected_repo: + raise HTTPException(status_code=400, detail="No repository selected") + + # Push code to GitHub + status, result = await push_code_to_github( + access_token=access_token, + repo=selected_repo, + filename=data.filename, + content=data.code + ) + + if status in [200, 201]: + # Update last push time + user_obj.last_push = datetime.utcnow() + await db.commit() + + # Create push log + push_log = PushLog( + user_id=user_obj.id, + filename=data.filename, + language=data.filename.split('.')[-1] + ) + db.add(push_log) + await db.commit() + + return {"message": "Code pushed successfully", "repository": selected_repo} + else: + raise HTTPException(status_code=status, detail=result.get("message", "Failed to push code to GitHub")) + + except HTTPException as he: + print(f"[push.py] HTTP Exception: {he.status_code} - {he.detail}") + raise he + except Exception as e: + print(f"[push.py] Unexpected error: {str(e)}") + logging.error(traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routers/user.py b/backend/routers/user.py index 28c4a62..9cd65f0 100644 --- a/backend/routers/user.py +++ b/backend/routers/user.py @@ -1,12 +1,34 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, update from models import User, PushLog, Problem, Solution from database import get_db from auth import get_current_user from datetime import datetime, timedelta +from pydantic import BaseModel -user_router = APIRouter() +user_router = APIRouter(prefix="", tags=["user"]) + +class RepositoryUpdate(BaseModel): + repository: str + +@user_router.post("/save-repository") +async def save_repository( + repo_data: RepositoryUpdate, + user=Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + github_id = user.get("github_id") + + # Update the user's selected repository + await db.execute( + update(User) + .where(User.github_id == github_id) + .values(selected_repo=repo_data.repository) + ) + await db.commit() + + return {"message": "Repository updated successfully", "repository": repo_data.repository} @user_router.get("/me") async def read_me(user=Depends(get_current_user), db: AsyncSession = Depends(get_db)): @@ -21,7 +43,8 @@ async def read_me(user=Depends(get_current_user), db: AsyncSession = Depends(get return { "username": user_obj.username, "last_login": user_obj.last_login.isoformat() if user_obj.last_login else None, - "last_push": user_obj.last_push.isoformat() if user_obj.last_push else None + "last_push": user_obj.last_push.isoformat() if user_obj.last_push else None, + "selected_repo": user_obj.selected_repo } diff --git a/pusher/background.js b/pusher/background.js index b7a820d..5b8080c 100644 --- a/pusher/background.js +++ b/pusher/background.js @@ -3,6 +3,7 @@ const API_URL = "https://lit1337-dev.up.railway.app"; const clientId = "Ov23lidbbczriEkuebBd"; const REDIRECT_URL = `https://${chrome.runtime.id}.chromiumapp.org/`; + console.log("Background script loaded. Redirect URL:", REDIRECT_URL); console.log("API URL:", API_URL); @@ -222,6 +223,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { // Don't just open a tab - use the proper OAuth flow redirectToGitHubAuth(); } + }); // Handle OAuth redirect - DISABLED because we now use chrome.identity.launchWebAuthFlow diff --git a/pusher/config.js b/pusher/config.js index a132843..ee5cdce 100644 --- a/pusher/config.js +++ b/pusher/config.js @@ -5,4 +5,7 @@ const API_BASE_URL = "https://lit1337-dev.up.railway.app"; // for local server -// const API_BASE_URL = "http://localhost:8000"; \ No newline at end of file +// const API_BASE_URL = "http://localhost:8000"; + +// GitHub OAuth Client ID +const GITHUB_CLIENT_ID = "Ov23lidbbczriEkuebBd"; \ No newline at end of file diff --git a/pusher/content.js b/pusher/content.js index 665fdac..b0cd9c2 100644 --- a/pusher/content.js +++ b/pusher/content.js @@ -341,6 +341,7 @@ async function pushCodeToGitHub(pushBtn) { try { console.log(`Pushing to repository: ${selectedRepo}`); + // λ°±μ—”λ“œκ°€ κΈ°λŒ€ν•˜λŠ” ν˜•μ‹μ˜ μš”μ²­ λ³Έλ¬Έ ꡬ성 const requestBody = { filename, @@ -349,12 +350,14 @@ async function pushCodeToGitHub(pushBtn) { }; // ν•„μˆ˜ ν•„λ“œ 체크 + if (!filename || !code || !selectedRepo) { pushBtn.innerText = "❌ Invalid Data"; console.error("Missing required fields for push", { filename, codeLength: code?.length, selectedRepo }); return; } + // μš”μ²­ 둜그 console.log("Request to:", `${API_BASE_URL}/push-code`); console.log("Request body:", { ...requestBody, code: code.length > 50 ? `${code.substring(0, 50)}...` : code }); @@ -370,9 +373,11 @@ async function pushCodeToGitHub(pushBtn) { headers: { "Content-Type": "application/json", "Authorization": authHeader, + "Accept": "application/json" }, mode: 'cors', + cache: 'no-cache', // μΊμ‹œ 문제 λ°©μ§€ body: JSON.stringify(requestBody) }); @@ -411,6 +416,7 @@ async function pushCodeToGitHub(pushBtn) { console.log(`[Push] Last push: ${pushedAt}`); }); pushBtn.innerText = "βœ… Push"; + } } catch (err) { console.error("Push error:", err); diff --git a/pusher/popup.html b/pusher/popup.html index 35d8b0e..196e1b9 100644 --- a/pusher/popup.html +++ b/pusher/popup.html @@ -194,14 +194,17 @@

LeetCode Pusher

GitHub Login + @@ -209,7 +212,7 @@

LeetCode Pusher

Loading... - + diff --git a/pusher/popup.js b/pusher/popup.js index 2ac26ad..beaa9e5 100644 --- a/pusher/popup.js +++ b/pusher/popup.js @@ -316,22 +316,12 @@ loginBtn.addEventListener("click", () => { chrome.storage.local.remove(["github_token", "token_type"], () => { console.log("Cleared existing GitHub token before login"); chrome.runtime.sendMessage({ action: "login" }); + }); -}); -// click logout button -logoutBtn.addEventListener("click", () => { - chrome.storage.local.clear(() => { - statusEl.innerText = "Logged out."; - loginBtn.style.display = "inline-block"; - logoutBtn.style.display = "none"; - githubBtn.style.display = "none"; - repoEl.innerText = ""; - lastPushEl.innerText = ""; - lastPushEl.style.display = "none"; - repoSelect.style.display = "none"; + }); -}); + // click github button githubBtn.addEventListener("click", () => { @@ -342,9 +332,10 @@ githubBtn.addEventListener("click", () => { } else { console.error("No repository selected."); statusEl.innerText = "Please select a repository first."; + } }); -}); + // Repository selection change handler repoSelect.addEventListener('change', (e) => { @@ -388,10 +379,40 @@ function updateUI(username, last_push, last_login, selected_repo) { lastPushEl.innerText = `Last push: ${pushDate.getFullYear()}-${(pushDate.getMonth() + 1).toString().padStart(2, '0')}-${pushDate.getDate().toString().padStart(2, '0')} ${pushDate.getHours().toString().padStart(2, '0')}:${pushDate.getMinutes().toString().padStart(2, '0')}`; } else { lastPushEl.style.display = "none"; + } - if (last_login) { - const loginDate = new Date(last_login); - lastLoginEl.innerText = `Last login: ${loginDate.getFullYear()}-${(loginDate.getMonth() + 1).toString().padStart(2, '0')}-${loginDate.getDate().toString().padStart(2, '0')} ${loginDate.getHours().toString().padStart(2, '0')}:${loginDate.getMinutes().toString().padStart(2, '0')}`; + // UI update function + function updateUI(username, last_push, last_login, selected_repo) { + statusEl.innerText = `Welcome, ${username}!`; + loginBtn.style.display = "none"; + logoutBtn.style.display = "inline-block"; + repoSelect.style.display = "block"; + + if (selected_repo) { + repoEl.innerText = `Connected repo: ${selected_repo}`; + repoEl.style.color = "#4caf50"; // Green color to indicate success + githubBtn.style.display = "inline-block"; + setRepoBtn.style.display = "inline-block"; + } else { + repoEl.innerText = "Please select a repository"; + githubBtn.style.display = "none"; + setRepoBtn.style.display = "none"; + } + + if (last_push) { + lastPushEl.style.display = "inline-block"; + const pushDate = new Date(last_push); + lastPushEl.innerText = `Last push: ${pushDate.getFullYear()}-${(pushDate.getMonth() + 1).toString().padStart(2, '0')}-${pushDate.getDate().toString().padStart(2, '0')} ${pushDate.getHours().toString().padStart(2, '0')}:${pushDate.getMinutes().toString().padStart(2, '0')}`; + } else { + lastPushEl.style.display = "none"; + } + + if (last_login) { + const loginDate = new Date(last_login); + lastLoginEl.innerText = `Last login: ${loginDate.getFullYear()}-${(loginDate.getMonth() + 1).toString().padStart(2, '0')}-${loginDate.getDate().toString().padStart(2, '0')} ${loginDate.getHours().toString().padStart(2, '0')}:${loginDate.getMinutes().toString().padStart(2, '0')}`; + } } + } +