diff --git a/main.go b/main.go index 1ac4b3e..1428516 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "encoding/json" + "fmt" "html/template" "net/http" "net/url" @@ -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() { 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) { 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) { } 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)) + } +} diff --git a/static/game.html b/static/game.html index 1dfe9b9..e6cd876 100644 --- a/static/game.html +++ b/static/game.html @@ -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 @@ --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 @@ 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 @@ 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 @@ stroke-width: 2; } + /* === TOOLBAR === */ #canvas-toolbar { position: absolute; top: 12px; @@ -234,6 +246,7 @@ color: var(--color-text-white); border-color: var(--color-button); } + /* === PANELS === */ #info-panel { position: absolute; @@ -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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ left: 0; } + /* === MODAL === */ .modal { position: absolute; top: 30%; @@ -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 @@ 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; }
-
System Design Game
+
+

System Design Game

+ {{ if and .Username .Avatar }} +
+ + {{ .Username }} +
+ {{ else }} + + GitHub Logo + Login with GitHub + + {{ end }} + +
@@ -553,7 +598,7 @@
  • {{.Name}}
    {{.Difficulty}}
    -
  • + {{end}}
    @@ -572,36 +617,36 @@
    {{ if .Level.InterviewerRequirements }} -
    -

    Interviewer Requirements

    -
      - {{ range .Level.InterviewerRequirements }} -
    • {{ . }}
    • - {{ end }} -
    -
    +
    +

    Interviewer Requirements

    +
      + {{ range .Level.InterviewerRequirements }} +
    • {{ . }}
    • + {{ end }} +
    +
    {{ end }} {{ if .Level.FunctionalRequirements }} -
    -

    Functional Requirements

    -
      - {{ range .Level.FunctionalRequirements }} -
    • {{ . }}
    • - {{ end }} -
    -
    +
    +

    Functional Requirements

    +
      + {{ range .Level.FunctionalRequirements }} +
    • {{ . }}
    • + {{ end }} +
    +
    {{ end }} {{ if .Level.NonFunctionalRequirements }} -
    -

    Non-Functional Requirements

    -
      - {{ range .Level.NonFunctionalRequirements }} -
    • {{ . }}
    • - {{ end }} -
    -
    +
    +

    Non-Functional Requirements

    +
      + {{ range .Level.NonFunctionalRequirements }} +
    • {{ . }}
    • + {{ end }} +
    +
    {{ end }}
    @@ -695,18 +740,10 @@
    level constraints
    -
    🎯 target rps:
    -
    ⏱️ max p95 latency:
    -
    💸 max cost:
    -
    🔒 availability:
    -
    - -
    -
    simulation results
    -
    ✅ cost:
    -
    ⚡ p95 latency:
    -
    📈 achieved rps:
    -
    🛡️ availability:
    +
    🎯 target rps: {{.Level.TargetRPS}}
    +
    ⏱️ max p95 latency: {{.Level.MaxP95LatencyMs}}ms
    +
    💸 max cost: ${{.Level.MaxMonthlyUSD}}
    +
    🔒 availability: {{printf "%.2f" .Level.RequiredAvailabilityPct}}%