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;
}
-
+
@@ -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}}%