Skip to content

Commit a0fe281

Browse files
committed
feat: Add core FastAPI backend for AI post generation and LinkedIn posting, with new frontend pages for authentication, onboarding, and user settings.
1 parent 6e49420 commit a0fe281

File tree

5 files changed

+598
-49
lines changed

5 files changed

+598
-49
lines changed

README.md

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,73 @@ The settings page (`/settings`) shows connection status only—no credential inp
596596
| `/api/connection-status/{user_id}` | GET | Returns boolean connection states only |
597597
| `/api/disconnect-linkedin` | POST | Deletes stored OAuth token |
598598

599-
### API Documentation
599+
### Data Isolation & Accuracy Guarantees
600+
601+
PostBot ensures each user's data is **strictly isolated** and posts are generated from **their own activity only**.
602+
603+
```
604+
┌─────────────────────────────────────────────────────────────────────┐
605+
│ DATA FLOW ISOLATION │
606+
├─────────────────────────────────────────────────────────────────────┤
607+
│ │
608+
│ User A User B │
609+
│ │ │ │
610+
│ ▼ ▼ │
611+
│ ┌─────────────┐ ┌─────────────┐ │
612+
│ │ GitHub A │ │ GitHub B │ │
613+
│ │ username + │ │ username + │ │
614+
│ │ token │ │ token │ │
615+
│ └──────┬──────┘ └──────┬──────┘ │
616+
│ │ │ │
617+
│ ▼ ▼ │
618+
│ ┌─────────────────────────────────────────────────────────────┐ │
619+
│ │ SHARED INFRASTRUCTURE │ │
620+
│ │ ┌─────────────────────────────────────────────────────────┐│ │
621+
│ │ │ AI Service (App-level Groq key) ││ │
622+
│ │ │ Receives: User-specific activity data ONLY ││ │
623+
│ │ │ Never sees: Other users' data ││ │
624+
│ │ └─────────────────────────────────────────────────────────┘│ │
625+
│ │ ┌─────────────────────────────────────────────────────────┐│ │
626+
│ │ │ Image Service (App-level Unsplash key) ││ │
627+
│ │ │ Driven by: Post content context ││ │
628+
│ │ │ No user secrets: Query based on post text only ││ │
629+
│ │ └─────────────────────────────────────────────────────────┘│ │
630+
│ └─────────────────────────────────────────────────────────────┘ │
631+
│ │ │ │
632+
│ ▼ ▼ │
633+
│ ┌─────────────┐ ┌─────────────┐ │
634+
│ │ LinkedIn A │ │ LinkedIn B │ │
635+
│ │ OAuth token │ │ OAuth token │ │
636+
│ └─────────────┘ └─────────────┘ │
637+
│ │
638+
└─────────────────────────────────────────────────────────────────────┘
639+
```
640+
641+
**GitHub Activity:**
642+
| Scope | How It's Enforced |
643+
|-------|-------------------|
644+
| Username | Passed per-request, never shared |
645+
| Token (if OAuth) | Retrieved from `token_store` by `user_id` |
646+
| API Calls | Scoped to that user's repos/events only |
647+
648+
**AI Generation:**
649+
| Aspect | Implementation |
650+
|--------|----------------|
651+
| API Key | App-level `GROQ_API_KEY` (or user-provided) |
652+
| Input Data | User-specific activity data only |
653+
| Context | Never sees other users' activities |
654+
655+
**Unsplash Images:**
656+
| Aspect | Implementation |
657+
|--------|----------------|
658+
| API Key | App-level `UNSPLASH_ACCESS_KEY` |
659+
| Query | Derived from post content keywords |
660+
| User Secrets | Not used – purely content-driven |
661+
662+
**Why This Matters:**
663+
1.**No cross-user data leakage** – Each API call includes only that user's identifiers
664+
2.**Posts are authentic** – Generated from real user activity, not synthetic data
665+
3.**Shared services are stateless** – AI and image services don't retain user context
600666

601667
The FastAPI backend provides OpenAPI documentation at stable URLs:
602668

backend/app.py

Lines changed: 173 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,161 @@ def linkedin_callback(code: str = None, state: str = None, redirect_uri: str = N
406406
return {"error": str(e), "status": "failed"}
407407

408408

409-
# User settings endpoints
409+
# GitHub OAuth configuration
410+
GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '')
411+
GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '')
412+
413+
414+
@app.get('/auth/github/start')
415+
def github_oauth_start(redirect_uri: str, user_id: str):
416+
"""
417+
Start GitHub OAuth flow.
418+
419+
Redirects user to GitHub's authorization page.
420+
Requested scopes: read:user, repo (for private repo access)
421+
422+
Args:
423+
redirect_uri: Where to redirect after auth
424+
user_id: Clerk user ID (stored in state for callback)
425+
"""
426+
if not GITHUB_CLIENT_ID:
427+
return {"error": "GitHub OAuth not configured"}
428+
429+
state = f"{user_id}:{uuid4().hex}"
430+
431+
# Request read:user and repo scope for private activity access
432+
scopes = "read:user,repo"
433+
434+
auth_url = (
435+
f"https://github.com/login/oauth/authorize"
436+
f"?client_id={GITHUB_CLIENT_ID}"
437+
f"&redirect_uri={redirect_uri}"
438+
f"&scope={scopes}"
439+
f"&state={state}"
440+
)
441+
442+
return RedirectResponse(auth_url)
443+
444+
445+
@app.get('/auth/github/callback')
446+
def github_oauth_callback(code: str = None, state: str = None, redirect_uri: str = None):
447+
"""
448+
Handle GitHub OAuth callback.
449+
450+
Exchanges authorization code for access token and stores it encrypted.
451+
452+
Returns redirect with success/failure status.
453+
"""
454+
if not code:
455+
return {"error": "missing code", "status": "failed"}
456+
457+
# Extract user_id from state
458+
user_id = None
459+
if state and ':' in state:
460+
parts = state.split(':', 1)
461+
user_id = parts[0]
462+
463+
if not user_id:
464+
return {"error": "missing user_id in state", "status": "failed"}
465+
466+
try:
467+
import requests
468+
469+
# Exchange code for access token
470+
token_response = requests.post(
471+
'https://github.com/login/oauth/access_token',
472+
data={
473+
'client_id': GITHUB_CLIENT_ID,
474+
'client_secret': GITHUB_CLIENT_SECRET,
475+
'code': code,
476+
},
477+
headers={'Accept': 'application/json'},
478+
timeout=10
479+
)
480+
481+
token_data = token_response.json()
482+
483+
if 'error' in token_data:
484+
return {"error": token_data.get('error_description', 'OAuth failed'), "status": "failed"}
485+
486+
access_token = token_data.get('access_token')
487+
488+
if not access_token:
489+
return {"error": "No access token received", "status": "failed"}
490+
491+
# Get GitHub username from API
492+
user_response = requests.get(
493+
'https://api.github.com/user',
494+
headers={
495+
'Authorization': f'Bearer {access_token}',
496+
'Accept': 'application/vnd.github.v3+json'
497+
},
498+
timeout=10
499+
)
500+
501+
github_user = user_response.json()
502+
github_username = github_user.get('login', '')
503+
504+
# Store the token encrypted
505+
from services.token_store import save_github_token
506+
save_github_token(user_id, github_username, access_token)
507+
508+
# Also update user settings with username
509+
if save_user_settings and get_user_settings:
510+
settings = get_user_settings(user_id) or {}
511+
settings['github_username'] = github_username
512+
save_user_settings(user_id, settings)
513+
514+
return {
515+
"status": "success",
516+
"github_username": github_username,
517+
"github_connected": True
518+
}
519+
except Exception as e:
520+
import traceback
521+
print(f"GitHub OAuth Error: {e}")
522+
print(traceback.format_exc())
523+
return {"error": str(e), "status": "failed"}
524+
525+
526+
@app.post("/api/disconnect-github")
527+
def disconnect_github(request: DisconnectRequest):
528+
"""
529+
Disconnect a user's GitHub OAuth token.
530+
531+
Removes the stored GitHub PAT while keeping the username.
532+
533+
SECURITY:
534+
- User can only disconnect their own account
535+
- Only removes GitHub token, not LinkedIn
536+
"""
537+
try:
538+
from services.token_store import get_conn, init_db
539+
540+
init_db()
541+
conn = get_conn()
542+
cur = conn.cursor()
543+
544+
# Clear only the GitHub token, keep the rest
545+
cur.execute('''
546+
UPDATE accounts
547+
SET github_access_token = NULL
548+
WHERE user_id = ?
549+
''', (request.user_id,))
550+
551+
updated = cur.rowcount > 0
552+
conn.commit()
553+
conn.close()
554+
555+
if updated:
556+
return {"success": True, "message": "GitHub disconnected"}
557+
else:
558+
return {"success": False, "message": "No GitHub connection found"}
559+
except Exception as e:
560+
return {"success": False, "error": str(e)}
561+
562+
563+
410564
# SECURITY: Only safe fields are accepted from frontend
411565
class UserSettingsRequest(BaseModel):
412566
user_id: str
@@ -484,31 +638,47 @@ def get_connection_status_endpoint(user_id: str):
484638
485639
SECURITY: Returns ONLY boolean status and public identifiers.
486640
No tokens or credentials are ever returned.
641+
642+
Returns:
643+
- linkedin_connected: Has LinkedIn OAuth token
644+
- github_connected: Has GitHub username
645+
- github_oauth_connected: Has GitHub OAuth token (for private repos)
487646
"""
488647
try:
489648
# Import get_connection_status from token_store
490-
from services.token_store import get_connection_status
649+
from services.token_store import get_connection_status, get_token_by_user_id
491650

492651
status = get_connection_status(user_id)
493652

494-
# Also get github_username from settings
653+
# Get github_username from settings
495654
github_username = ''
496655
if get_user_settings:
497656
settings = get_user_settings(user_id)
498657
if settings:
499658
github_username = settings.get('github_username', '')
500659

660+
# Check if user has GitHub OAuth token (for private repos)
661+
github_oauth_connected = False
662+
try:
663+
token_data = get_token_by_user_id(user_id)
664+
if token_data and token_data.get('github_access_token'):
665+
github_oauth_connected = True
666+
except:
667+
pass
668+
501669
return {
502670
"linkedin_connected": status.get("linkedin_connected", False),
503671
"linkedin_urn": status.get("linkedin_urn", ""),
504672
"github_connected": bool(github_username),
505673
"github_username": github_username,
674+
"github_oauth_connected": github_oauth_connected,
506675
"token_expires_at": status.get("token_expires_at"),
507676
}
508677
except Exception as e:
509678
return {
510679
"linkedin_connected": False,
511680
"github_connected": False,
681+
"github_oauth_connected": False,
512682
"error": str(e)
513683
}
514684

0 commit comments

Comments
 (0)