Browse Source

add github login

pull/1/head
Stephanie Gredell 7 months ago
parent
commit
65f19be0b5
  1. 215
      main.go
  2. 281
      static/game.html

215
main.go

@ -2,6 +2,8 @@ package main @@ -2,6 +2,8 @@ package main
import (
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
@ -10,16 +12,38 @@ import ( @@ -10,16 +12,38 @@ import (
"strings"
"systemdesigngame/internals/level"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/joho/godotenv"
)
var jwtSecret []byte
var tmpl = template.Must(template.ParseGlob("static/*.html"))
type contextKey string
const (
userIDKey = contextKey("userID")
userLoginKey = contextKey("userLogin")
userAvatarKey = contextKey("userAvatar")
)
func main() {
err := godotenv.Load()
if err != nil {
panic("failed to load .env")
}
jwtSecret = []byte(os.Getenv("JWT_SECRET"))
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("/", index)
mux.HandleFunc("/game", game)
mux.HandleFunc("/play/", play)
mux.HandleFunc("/play/", requireAuth(play))
mux.HandleFunc("/login", loginHandler)
mux.HandleFunc("/callback", callbackHandler)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
@ -35,7 +59,9 @@ func main() { @@ -35,7 +59,9 @@ func main() {
defer stop()
go func() {
srv.ListenAndServe()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Println("Server failed: " + err.Error())
}
}()
<-ctx.Done()
@ -54,27 +80,16 @@ func index(w http.ResponseWriter, r *http.Request) { @@ -54,27 +80,16 @@ func index(w http.ResponseWriter, r *http.Request) {
Title: "Title",
}
tmpl.ExecuteTemplate(w, "index.html", data)
}
func game(w http.ResponseWriter, r *http.Request) {
var err error
levels, err := level.LoadLevels("data/levels.json")
if err != nil {
panic("failed to load levels: " + err.Error())
}
data := struct {
Levels []level.Level
}{
Levels: levels,
if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
http.Error(w, "Template Error", http.StatusInternalServerError)
}
tmpl.ExecuteTemplate(w, "game.html", data)
}
func play(w http.ResponseWriter, r *http.Request) {
levelName := r.URL.Path[len("/play/"):]
levelName, err := url.PathUnescape(levelName)
avatar := r.Context().Value(userAvatarKey).(string)
username := r.Context().Value(userLoginKey).(string)
if err != nil {
http.Error(w, "Invalid level name", http.StatusBadRequest)
return
@ -87,12 +102,168 @@ func play(w http.ResponseWriter, r *http.Request) { @@ -87,12 +102,168 @@ func play(w http.ResponseWriter, r *http.Request) {
}
allLevels := level.AllLevels()
data := struct {
Levels []level.Level
Level *level.Level
Levels []level.Level
Level *level.Level
Avatar string
Username string
}{
Levels: allLevels,
Level: lvl,
Levels: allLevels,
Level: lvl,
Avatar: avatar,
Username: username,
}
tmpl.ExecuteTemplate(w, "game.html", data)
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("auth_token")
if err == nil {
token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil && token.Valid {
http.Redirect(w, r, "/play", http.StatusFound)
return
}
}
clientId := os.Getenv("GITHUB_CLIENT_ID")
redirectURI := os.Getenv("GITHUB_CALLBACK")
scope := "read:user user:email"
url := fmt.Sprintf(
"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=%s",
clientId, url.QueryEscape(redirectURI),
url.QueryEscape(scope),
)
http.Redirect(w, r, url, http.StatusFound)
}
func callbackHandler(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "No code in query", http.StatusBadRequest)
return
}
data := url.Values{}
data.Set("client_id", os.Getenv("GITHUB_CLIENT_ID"))
data.Set("client_secret", os.Getenv("GITHUB_CLIENT_SECRET"))
data.Set("code", code)
req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode()))
if err != nil {
http.Error(w, "Failed to create token request", http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
}
json.NewDecoder(resp.Body).Decode(&tokenResp)
// Use token to get user info
userReq, _ := http.NewRequest("GET", "https://api.github.com/user", nil)
userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
userResp, err := http.DefaultClient.Do(userReq)
if err != nil {
http.Error(w, "Failed to fetch user info", http.StatusInternalServerError)
return
}
defer userResp.Body.Close()
var userInfo struct {
Login string `json:"login"`
ID int `json:"id"`
AvatarUrl string `json:"avatar_url"`
}
if err := json.NewDecoder(userResp.Body).Decode(&userInfo); err != nil {
http.Error(w, "Failed to parse user info", http.StatusInternalServerError)
return
}
// Generate JWT
fmt.Printf("generating jwt: %s, %s, %s\n", fmt.Sprintf("%d", userInfo.ID), userInfo.Login, userInfo.AvatarUrl)
jwtToken, err := generateJWT(fmt.Sprintf("%d", userInfo.ID), userInfo.Login, userInfo.AvatarUrl)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
// Set JWT in cookie
http.SetCookie(w, &http.Cookie{
Name: "auth_token",
Value: jwtToken,
Path: "/",
HttpOnly: true,
Secure: true, // Set to true in production (HTTPS)
})
http.Redirect(w, r, "/play", http.StatusFound)
}
func generateJWT(userID string, login string, avatarUrl string) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"login": login,
"avatar": avatarUrl,
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("auth_token")
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
userID, ok1 := claims["sub"].(string)
login, ok2 := claims["login"].(string)
avatar, _ := claims["avatar"].(string)
if !ok1 || !ok2 {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userIDKey, userID)
ctx = context.WithValue(ctx, userLoginKey, login)
ctx = context.WithValue(ctx, userAvatarKey, avatar)
next(w, r.WithContext(ctx))
}
}

281
static/game.html

@ -17,23 +17,18 @@ @@ -17,23 +17,18 @@
--color-bg-hover: #2a2a2a;
--color-bg-accent: #005f87;
--color-bg-tab-active: #1a3d2a;
--color-border: #444;
--color-border-accent: #00ff88;
--color-border-panel: #30363d;
--color-text-primary: #ccc;
--color-text-muted: #888;
--color-text-accent: #00ff88;
--color-text-white: #fff;
--color-text-dark: #333;
--color-button: #238636;
--color-button-disabled: #555;
--color-connection: #333;
--color-connection-selected: #007bff;
--color-tooltip-bg: #333;
--color-tooltip-text: #fff;
@ -41,15 +36,13 @@ @@ -41,15 +36,13 @@
--radius-small: 4px;
--radius-medium: 6px;
--radius-large: 8px;
--font-family-mono: 'JetBrains Mono', monospace;
--font-family-code: 'Fira Code', monospace;
--component-padding: 8px;
--component-gap: 12px;
}
/* === RESET === */
/* === RESET & BASE STYLES === */
* {
box-sizing: border-box;
}
@ -75,10 +68,17 @@ @@ -75,10 +68,17 @@
width: 100%;
background: none;
padding: 12px 24px;
font-size: 24px;
font-weight: bold;
color: var(--color-text-accent);
border-bottom: 1px solid var(--color-text-dark);
display: flex;
align-items: center;
justify-content: space-between;
}
.header-text {
font-size: 24px;
margin: 0;
}
#main-content {
@ -99,6 +99,17 @@ @@ -99,6 +99,17 @@
gap: var(--component-gap);
}
.sidebar-title {
color: #8b949e;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 15px;
padding-bottom: 8px;
padding-left: 8px;
border-bottom: 1px solid #303638;
}
/* === COMPONENT ICONS === */
.component-icon,
#arrow-tool {
@ -199,6 +210,7 @@ @@ -199,6 +210,7 @@
stroke-width: 2;
}
/* === TOOLBAR === */
#canvas-toolbar {
position: absolute;
top: 12px;
@ -234,6 +246,7 @@ @@ -234,6 +246,7 @@
color: var(--color-text-white);
border-color: var(--color-button);
}
/* === PANELS === */
#info-panel {
position: absolute;
@ -270,14 +283,19 @@ @@ -270,14 +283,19 @@
color: var(--color-text-primary);
}
#node-props-save {
margin-top: 8px;
padding: 10px;
background-color: var(--color-button);
color: var(--color-text-white);
border: none;
border-radius: var(--radius-small);
cursor: pointer;
#node-props-panel .form-group {
margin-bottom: 10px;
}
#node-props-panel label {
display: block;
font-weight: bold;
margin-bottom: 4px;
}
#node-props-panel select {
width: 100%;
padding: 4px;
font-size: 14px;
}
@ -294,6 +312,24 @@ @@ -294,6 +312,24 @@
font-size: 13px;
}
.panel-title {
font-weight: bold;
color: var(--color-text-white);
font-size: 15px;
margin-bottom: 0.5rem;
}
.panel-metric {
margin-bottom: 0.4rem;
}
.panel-metric .label {
display: inline-block;
width: 140px;
color: var(--color-text-muted);
}
/* === INPUTS & BUTTONS === */
input[type="text"],
input[type="number"] {
padding: 6px;
@ -304,9 +340,9 @@ @@ -304,9 +340,9 @@
font-family: var(--font-family-code);
}
/* === BUTTONS === */
#node-props-save,
#run-button {
margin-top: auto;
margin-top: 8px;
padding: 10px;
background-color: var(--color-button);
color: var(--color-text-white);
@ -322,6 +358,32 @@ @@ -322,6 +358,32 @@
cursor: not-allowed;
}
#github-login-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background-color: #fff;
color: #000000;
text-decoration: none;
border-radius: var(--radius-medium);
font-weight: 500;
font-family: var(--font-family-mono);
font-size: 12px;
border: 1px solid #2ea043;
transition: background-color 0.2s ease;
float: right;
}
#github-login-btn:hover {
background-color: #ccc;
}
#github-login-btn img {
width: 18px;
height: 18px;
}
/* === TABS === */
.tabs {
display: flex;
@ -387,14 +449,9 @@ @@ -387,14 +449,9 @@
padding: 0;
}
.challenge-name {
font-weight: 500;
margin-bottom: 5px;
}
.challenge-item {
padding: 10px;
margin: 5px 0;
margin: 5px 0;
background: #21262d;
border-radius: 6px;
cursor: pointer;
@ -403,60 +460,38 @@ @@ -403,60 +460,38 @@
list-style: none;
}
.challenge-difficulty {
font-size: 0.8rem;
color: #0b949e;
}
.challenge-difficulty.easy {
color: #3fb950;
}
.challenge-difficulty.medium {
color: #d29922;
}
.challenge-difficulty.hard {
color: #f85149
}
.challenge-item:hover {
background: #30363d;
}
.challenge-item.active {
background: #1a3d2a;
border-left-color: #00ff88;
}
/* === PANEL METRICS === */
.panel-title {
font-weight: bold;
color: var(--color-text-white);
font-size: 15px;
margin-bottom: 0.5rem;
.challenge-name {
font-weight: 500;
margin-bottom: 5px;
}
.panel-metric {
margin-bottom: 0.4rem;
.challenge-difficulty {
font-size: 0.8rem;
color: #0b949e;
}
.panel-metric .label {
display: inline-block;
width: 140px;
color: var(--color-text-muted);
.challenge-difficulty.easy {
color: #3fb950;
}
.sidebar-title {
color: #8b949e;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 15px;
padding-bottom: 8px;
padding-left: 8px;
border-bottom: 1px solid #303638;
.challenge-difficulty.medium {
color: #d29922;
}
.challenge-difficulty.hard {
color: #f85149;
}
/* === REQUIREMENTS === */
.requirements-section {
background: #161b22;
border: 1px solid #30363d;
@ -475,8 +510,9 @@ @@ -475,8 +510,9 @@
position: relative;
padding: 8px 0 8px 25px;
margin: 0;
border-bottom: 1px solid #30363d;;
border-bottom: 1px solid #30363d;
}
.requirement-item:before {
content: "✓";
color: #00ff88;
@ -484,6 +520,7 @@ @@ -484,6 +520,7 @@
left: 0;
}
/* === MODAL === */
.modal {
position: absolute;
top: 30%;
@ -496,24 +533,18 @@ @@ -496,24 +533,18 @@
z-index: 999;
color: #ccc;
}
.modal-content label {
display: block;
margin: 10px 0;
}
.modal-actions {
margin-top: 10px;
text-align: right;
}
.modal input {
width: 100%;
padding: 6px;
margin-top: 4px;
background: #222;
border: 1px solid #444;
color: #fff;
border-radius: 4px;
}
.modal input,
.modal select {
width: 100%;
padding: 6px;
@ -524,26 +555,40 @@ @@ -524,26 +555,40 @@
border-radius: 4px;
}
#node-props-panel .form-group {
margin-bottom: 10px;
/* === MISC === */
#score-panel {
margin-top: 16px;
}
#node-props-panel label {
display: block;
font-weight: bold;
margin-bottom: 4px;
.userbox {
display: flex;
align-items: center;
gap: 12px;
}
#node-props-panel select {
width: 100%;
padding: 4px;
font-size: 14px;
.avatar {
width: 32px;
height: 32px;
border-radius: 12px;
}
</style>
</head>
<body>
<div id="page-container">
<div id="sd-header">System Design Game</div>
<div id="sd-header">
<h1 class="header-text">System Design Game</h1>
{{ if and .Username .Avatar }}
<div class="userbox">
<img src="{{ .Avatar }}" class="avatar" />
<span class="username">{{ .Username }}</span>
</div>
{{ else }}
<a href="/login" id="github-login-btn">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/github/github-original.svg" alt="GitHub Logo">
Login with GitHub
</a>
{{ end }}
</div>
<div id="main-content">
<div id="challenge-container">
<h2 class="sidebar-title">Challenges</h2>
@ -553,7 +598,7 @@ @@ -553,7 +598,7 @@
<li class="challenge-item {{if and (eq .Name $.Level.Name) (eq .Difficulty $.Level.Difficulty)}}active{{end}}">
<div class="challenge-name">{{.Name}}</div>
<div class="challenge-difficulty {{.Difficulty}}">{{.Difficulty}}</div>
</li>
</li>
{{end}}
</ul>
</div>
@ -572,36 +617,36 @@ @@ -572,36 +617,36 @@
<!-- Requirements -->
<div id="content1" class="tab-content">
{{ if .Level.InterviewerRequirements }}
<div class="requirements-section">
<h3>Interviewer Requirements</h3>
<ul class="requirements-list">
{{ range .Level.InterviewerRequirements }}
<li class="requirement-item">{{ . }}</li>
{{ end }}
</ul>
</div>
<div class="requirements-section">
<h3>Interviewer Requirements</h3>
<ul class="requirements-list">
{{ range .Level.InterviewerRequirements }}
<li class="requirement-item">{{ . }}</li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if .Level.FunctionalRequirements }}
<div class="requirements-section">
<h3>Functional Requirements</h3>
<ul class="requirements-list">
{{ range .Level.FunctionalRequirements }}
<li class="requirement-item">{{ . }}</li>
{{ end }}
</ul>
</div>
<div class="requirements-section">
<h3>Functional Requirements</h3>
<ul class="requirements-list">
{{ range .Level.FunctionalRequirements }}
<li class="requirement-item">{{ . }}</li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if .Level.NonFunctionalRequirements }}
<div class="requirements-section">
<h3>Non-Functional Requirements</h3>
<ul class="requirements-list">
{{ range .Level.NonFunctionalRequirements }}
<li class="requirement-item">{{ . }}</li>
{{ end }}
</ul>
</div>
<div class="requirements-section">
<h3>Non-Functional Requirements</h3>
<ul class="requirements-list">
{{ range .Level.NonFunctionalRequirements }}
<li class="requirement-item">{{ . }}</li>
{{ end }}
</ul>
</div>
{{ end }}
</div>
@ -695,18 +740,10 @@ @@ -695,18 +740,10 @@
<div id="info-panel">
<div id="constraints-panel">
<div class="panel-title">level constraints</div>
<div class="panel-metric"><span class="label">🎯 target rps:</span> <span id="constraint-rps"></span></div>
<div class="panel-metric"><span class="label"> max p95 latency:</span> <span id="constraint-latency"></span></div>
<div class="panel-metric"><span class="label">💸 max cost:</span> <span id="constraint-cost"></span></div>
<div class="panel-metric"><span class="label">🔒 availability:</span> <span id="constraint-availability"></span></div>
</div>
<div id="score-panel">
<div class="panel-title">simulation results</div>
<div class="panel-metric"><span class="label">✅ cost:</span> <span id="score-cost"></span></div>
<div class="panel-metric"><span class="label">⚡ p95 latency:</span> <span id="score-p95"></span></div>
<div class="panel-metric"><span class="label">📈 achieved rps:</span> <span id="score-rps"></span></div>
<div class="panel-metric"><span class="label">🛡 availability:</span> <span id="score-availability"></span></div>
<div class="panel-metric"><span class="label">🎯 target rps:</span> <span id="constraint-rps">{{.Level.TargetRPS}}</span></div>
<div class="panel-metric"><span class="label"> max p95 latency:</span> <span id="constraint-latency">{{.Level.MaxP95LatencyMs}}ms</span></div>
<div class="panel-metric"><span class="label">💸 max cost:</span> <span id="constraint-cost">${{.Level.MaxMonthlyUSD}}</span></div>
<div class="panel-metric"><span class="label">🔒 availability:</span> <span id="constraint-availability">{{printf "%.2f" .Level.RequiredAvailabilityPct}}%</span></div>
</div>
</div>

Loading…
Cancel
Save