From be9d6b610fa044b2550d3c53c334c5dadf88a998 Mon Sep 17 00:00:00 2001 From: didier amyot Date: Tue, 16 Jun 2026 12:55:56 -0400 Subject: [PATCH] feat: added auth and crazy more things --- .env.example | 6 +- .gitignore | 3 + api.go | 95 +-- auth.go | 734 ++++++++++++++++++++ flake.nix | 43 +- go.mod | 11 +- go.sum | 26 +- main.go | 93 ++- migrations/000004_create_users_sessions.sql | 22 + public/app.js | 584 +++++++++++++++- 10 files changed, 1500 insertions(+), 117 deletions(-) create mode 100644 auth.go create mode 100644 migrations/000004_create_users_sessions.sql diff --git a/.env.example b/.env.example index 649d929..2b5047e 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ QDRANT_PORT=6334 OPENAI_API_KEY=sk-... # EMBEDDING_BASE_URL=https://api.openai.com EMBEDDING_MODEL=text-embedding-3-small -MCP_TOKEN=change-me +# Admin user bootstrap credentials +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +# Optional: initial MCP session token for backward compatibility +# MCP_TOKEN=legacy-token PORT=8080 SQLITE_PATH=flashcard.db diff --git a/.gitignore b/.gitignore index 83b316e..b847883 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ st-pierre # Nix build result result +# Go vendor +/vendor/ + # Environment .env diff --git a/api.go b/api.go index 390a290..a4a6d0a 100644 --- a/api.go +++ b/api.go @@ -1,76 +1,51 @@ package main import ( - "encoding/json" "net/http" "strconv" "strings" - - "go.uber.org/zap" ) -func registerAPI(mux *http.ServeMux, sqlite *sqliteStore, logger *zap.Logger) { - mux.HandleFunc("/api/v1/flashcards", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - return - } +func serveFlashcardDetail(w http.ResponseWriter, r *http.Request, store *sqliteStore) { + segments := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/flashcards/"), "/") + if len(segments) == 0 || segments[0] == "" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } - summaries, err := sqlite.listAll(r.Context()) - if err != nil { - logger.Error("list flashcards", zap.Error(err)) - http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string][]flashcardSummary{"flashcards": summaries}) - }) - - mux.HandleFunc("/api/v1/flashcards/", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - return - } - - segments := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/flashcards/"), "/") - if len(segments) == 0 || segments[0] == "" { - http.Error(w, `{"error":"not found"}`, http.StatusNotFound) - return - } - - publicID, err := strconv.ParseInt(segments[0], 10, 64) - if err != nil { - http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) - return - } - - if len(segments) >= 2 && segments[1] != "" { - version, err := strconv.Atoi(segments[1]) - if err != nil { - http.Error(w, `{"error":"invalid version"}`, http.StatusBadRequest) - return - } - e, err := sqlite.getByPublicIDAndVersion(r.Context(), publicID, version) - writeEntry(w, e, err) - return - } - - e, err := sqlite.getLatestByPublicID(r.Context(), publicID) - writeEntry(w, e, err) - }) -} - -func writeEntry(w http.ResponseWriter, e *entry, err error) { + publicID, err := strconv.ParseInt(segments[0], 10, 64) if err != nil { - http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) + return + } + + if len(segments) >= 2 && segments[1] != "" { + version, err := strconv.Atoi(segments[1]) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"}) + return + } + e, err := store.getByPublicIDAndVersion(r.Context(), publicID, version) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + if e == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + writeJSON(w, http.StatusOK, e) + return + } + + e, err := store.getLatestByPublicID(r.Context(), publicID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) return } if e == nil { - http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(e) + writeJSON(w, http.StatusOK, e) } diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..792be8b --- /dev/null +++ b/auth.go @@ -0,0 +1,734 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" +) + +var sessionTokenPrefix = "stp_" + +type userRow struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + CreatedAt string `json:"created_at"` + Active bool `json:"active"` +} + +type sessionRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Label string `json:"label"` + TokenHash string `json:"-"` + CreatedAt string `json:"created_at"` + LastUsedAt string `json:"last_used_at,omitempty"` + Active bool `json:"active"` +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type createSessionResponse struct { + ID int64 `json:"id"` + Label string `json:"label"` + Token string `json:"token"` + CreatedAt string `json:"created_at"` +} + +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func checkPassword(hashed, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) == nil +} + +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return sessionTokenPrefix + hex.EncodeToString(b), nil +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +func (s *sqliteStore) countUsers(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n) + return n, err +} + +func (s *sqliteStore) createUser(ctx context.Context, username, password string) (*userRow, error) { + h, err := hashPassword(password) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + result, err := s.db.ExecContext(ctx, + `INSERT INTO users (username, password_hash) VALUES (?, ?)`, + username, h, + ) + if err != nil { + return nil, fmt.Errorf("insert user: %w", err) + } + + id, _ := result.LastInsertId() + return s.getUserByID(ctx, id) +} + +func (s *sqliteStore) getUserByUsername(ctx context.Context, username string) (*userRow, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, password_hash, created_at, active FROM users WHERE username = ?`, username, + ) + + var u userRow + var active int + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &active) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query user: %w", err) + } + u.Active = active == 1 + return &u, nil +} + +func (s *sqliteStore) getUserByID(ctx context.Context, id int64) (*userRow, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, password_hash, created_at, active FROM users WHERE id = ?`, id, + ) + + var u userRow + var active int + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &active) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query user: %w", err) + } + u.Active = active == 1 + return &u, nil +} + +func (s *sqliteStore) listUsers(ctx context.Context) ([]userRow, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, username, created_at, active FROM users ORDER BY id`, + ) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var users []userRow + for rows.Next() { + var u userRow + var active int + if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt, &active); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + u.Active = active == 1 + users = append(users, u) + } + return users, rows.Err() +} + +func (s *sqliteStore) deactivateUser(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET active = 0 WHERE id = ?`, id) + return err +} + +func (s *sqliteStore) changePassword(ctx context.Context, userID int64, newHash string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE users SET password_hash = ? WHERE id = ?`, newHash, userID, + ) + return err +} + +func (s *sqliteStore) createSession(ctx context.Context, userID int64, token, label string) (*sessionRow, error) { + h := hashToken(token) + result, err := s.db.ExecContext(ctx, + `INSERT INTO sessions (user_id, label, token_hash) VALUES (?, ?, ?)`, + userID, label, h, + ) + if err != nil { + return nil, fmt.Errorf("insert session: %w", err) + } + + id, _ := result.LastInsertId() + row := s.db.QueryRowContext(ctx, + `SELECT id, user_id, label, created_at, active FROM sessions WHERE id = ?`, id, + ) + + var sr sessionRow + var active int + err = row.Scan(&sr.ID, &sr.UserID, &sr.Label, &sr.CreatedAt, &active) + if err != nil { + return nil, fmt.Errorf("query session: %w", err) + } + sr.Active = active == 1 + return &sr, nil +} + +func (s *sqliteStore) getSessionByToken(ctx context.Context, token string) (*sessionRow, *userRow, error) { + h := hashToken(token) + row := s.db.QueryRowContext(ctx, + `SELECT s.id, s.user_id, s.label, s.created_at, s.last_used_at, s.active, + u.id, u.username, u.created_at, u.active + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ?`, h, + ) + + var sr sessionRow + var ur userRow + var sActive, uActive int + var lastUsed sql.NullString + + err := row.Scan(&sr.ID, &sr.UserID, &sr.Label, &sr.CreatedAt, &lastUsed, &sActive, + &ur.ID, &ur.Username, &ur.CreatedAt, &uActive) + if err == sql.ErrNoRows { + return nil, nil, nil + } + if err != nil { + return nil, nil, fmt.Errorf("query session: %w", err) + } + + sr.Active = sActive == 1 + if lastUsed.Valid { + sr.LastUsedAt = lastUsed.String + } + ur.Active = uActive == 1 + return &sr, &ur, nil +} + +func (s *sqliteStore) listSessions(ctx context.Context, userID int64) ([]sessionRow, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, user_id, label, created_at, last_used_at, active + FROM sessions WHERE user_id = ? ORDER BY id`, userID, + ) + if err != nil { + return nil, fmt.Errorf("list sessions: %w", err) + } + defer rows.Close() + + var sessions []sessionRow + for rows.Next() { + var sr sessionRow + var active int + var lastUsed sql.NullString + if err := rows.Scan(&sr.ID, &sr.UserID, &sr.Label, &sr.CreatedAt, &lastUsed, &active); err != nil { + return nil, fmt.Errorf("scan session: %w", err) + } + sr.Active = active == 1 + if lastUsed.Valid { + sr.LastUsedAt = lastUsed.String + } + sessions = append(sessions, sr) + } + return sessions, rows.Err() +} + +func (s *sqliteStore) deactivateSession(ctx context.Context, id, userID int64) error { + _, err := s.db.ExecContext(ctx, + `UPDATE sessions SET active = 0 WHERE id = ? AND user_id = ?`, id, userID, + ) + return err +} + +func (s *sqliteStore) updateSessionLastUsed(ctx context.Context, id int64) { + s.db.ExecContext(ctx, + `UPDATE sessions SET last_used_at = datetime('now') WHERE id = ?`, id, + ) +} + +type contextKey string + +const userContextKey contextKey = "user" + +func getUserFromContext(r *http.Request) *userRow { + if u, ok := r.Context().Value(userContextKey).(*userRow); ok { + return u + } + return nil +} + +func authMiddleware(store *sqliteStore, logger *zap.Logger, next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + + token := "" + + cookie, err := r.Cookie("st_pierre_session") + if err == nil && cookie.Value != "" { + token = cookie.Value + } + + if token == "" { + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token = strings.TrimPrefix(authHeader, "Bearer ") + } + } + + if token == "" { + writeUnauthorized(w) + return + } + + session, user, err := store.getSessionByToken(r.Context(), token) + if err != nil { + logger.Error("auth lookup failed", zap.Error(err)) + writeUnauthorized(w) + return + } + if session == nil || !session.Active || !user.Active { + writeUnauthorized(w) + return + } + + store.updateSessionLastUsed(r.Context(), session.ID) + + ctx := context.WithValue(r.Context(), userContextKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + } +} + +func writeUnauthorized(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) +} + +func (s *sqliteStore) bootstrapAdmin(ctx context.Context, logger *zap.Logger) { + n, err := s.countUsers(ctx) + if err != nil { + logger.Fatal("failed to check users table", zap.Error(err)) + } + if n > 0 { + return + } + + username := getenvDefault("ADMIN_USERNAME", "admin") + password := os.Getenv("ADMIN_PASSWORD") + if password == "" { + password = generateRandomPassword() + logger.Warn("ADMIN_PASSWORD not set, generated random password", + zap.String("password", password)) + } + + admin, err := s.createUser(ctx, username, password) + if err != nil { + logger.Fatal("failed to create admin user", zap.Error(err)) + } + logger.Info("created admin user", zap.String("username", admin.Username)) + + mcpToken := os.Getenv("MCP_TOKEN") + if mcpToken != "" { + tokenHash := hashToken(mcpToken) + _, err := s.db.ExecContext(ctx, + `INSERT INTO sessions (user_id, label, token_hash) VALUES (?, ?, ?)`, + admin.ID, "MCP_TOKEN bootstrap", tokenHash, + ) + if err != nil { + logger.Fatal("failed to bootstrap session from MCP_TOKEN", zap.Error(err)) + } + logger.Info("bootstrapped session from MCP_TOKEN") + } +} + +func generateRandomPassword() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +func getenvDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func loginHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if req.Username == "" || req.Password == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"}) + return + } + + user, err := store.getUserByUsername(r.Context(), req.Username) + if err != nil { + logger.Error("login lookup failed", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + if user == nil || !user.Active || !checkPassword(user.PasswordHash, req.Password) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return + } + + token, err := generateToken() + if err != nil { + logger.Error("generate token failed", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + session, err := store.createSession(r.Context(), user.ID, token, "UI login") + if err != nil { + logger.Error("create session failed", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "st_pierre_session", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(365 * 24 * time.Hour), + }) + + writeJSON(w, http.StatusOK, map[string]any{ + "session": session, + "user": map[string]any{ + "id": user.ID, + "username": user.Username, + }, + }) + } +} + +func logoutHandler(store *sqliteStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + cookie, err := r.Cookie("st_pierre_session") + if err == nil && cookie.Value != "" { + session, _, _ := store.getSessionByToken(r.Context(), cookie.Value) + if session != nil { + store.deactivateSession(r.Context(), session.ID, session.UserID) + } + } + + http.SetCookie(w, &http.Cookie{ + Name: "st_pierre_session", + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Unix(0, 0), + }) + + writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) + } +} + +func createSessionHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + user := getUserFromContext(r) + if user == nil { + writeUnauthorized(w) + return + } + + var body struct { + Label string `json:"label"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if body.Label == "" { + body.Label = "API session" + } + + token, err := generateToken() + if err != nil { + logger.Error("generate token failed", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + session, err := store.createSession(r.Context(), user.ID, token, body.Label) + if err != nil { + logger.Error("create session failed", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + writeJSON(w, http.StatusCreated, createSessionResponse{ + ID: session.ID, + Label: session.Label, + Token: token, + CreatedAt: session.CreatedAt, + }) + } +} + +func listSessionsHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + user := getUserFromContext(r) + if user == nil { + writeUnauthorized(w) + return + } + + sessions, err := store.listSessions(r.Context(), user.ID) + if err != nil { + logger.Error("list sessions failed", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"sessions": sessions}) + } +} + +func deleteSessionHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + user := getUserFromContext(r) + if user == nil { + writeUnauthorized(w) + return + } + + id, err := parseIDFromPath(r.URL.Path, "/api/v1/sessions/") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid session id"}) + return + } + + if err := store.deactivateSession(r.Context(), id, user.ID); err != nil { + logger.Error("deactivate session failed", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "session revoked"}) + } +} + +func createUserHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + currentUser := getUserFromContext(r) + if currentUser == nil { + writeUnauthorized(w) + return + } + + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if body.Username == "" || body.Password == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"}) + return + } + if len(body.Password) < 8 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"}) + return + } + + user, err := store.createUser(r.Context(), body.Username, body.Password) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint") { + writeJSON(w, http.StatusConflict, map[string]string{"error": "username already exists"}) + return + } + logger.Error("create user failed", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + writeJSON(w, http.StatusCreated, user) + } +} + +func listUsersHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + currentUser := getUserFromContext(r) + if currentUser == nil { + writeUnauthorized(w) + return + } + + users, err := store.listUsers(r.Context()) + if err != nil { + logger.Error("list users failed", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"users": users}) + } +} + +func changePasswordHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + user := getUserFromContext(r) + if user == nil { + writeUnauthorized(w) + return + } + + var body struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if body.OldPassword == "" || body.NewPassword == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "old_password and new_password required"}) + return + } + if len(body.NewPassword) < 8 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new password must be at least 8 characters"}) + return + } + if body.OldPassword == body.NewPassword { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new password must differ from old"}) + return + } + + fullUser, err := store.getUserByID(r.Context(), user.ID) + if err != nil || fullUser == nil { + logger.Error("get user for password change", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + if !checkPassword(fullUser.PasswordHash, body.OldPassword) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "incorrect old password"}) + return + } + + newHash, err := hashPassword(body.NewPassword) + if err != nil { + logger.Error("hash new password", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + if err := store.changePassword(r.Context(), user.ID, newHash); err != nil { + logger.Error("change password", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "password changed"}) + } +} + +func parseIDFromPath(path, prefix string) (int64, error) { + rest := strings.TrimPrefix(path, prefix) + rest = strings.TrimSuffix(rest, "/") + if rest == "" { + return 0, fmt.Errorf("empty id") + } + var id int64 + _, err := fmt.Sscanf(rest, "%d", &id) + return id, err +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func getUserHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + user := getUserFromContext(r) + if user == nil { + writeUnauthorized(w) + return + } + + writeJSON(w, http.StatusOK, user) + } +} + diff --git a/flake.nix b/flake.nix index f7949e9..4b88755 100644 --- a/flake.nix +++ b/flake.nix @@ -14,7 +14,7 @@ pname = "st-pierre"; version = "0.1.0"; src = ./.; - vendorHash = "sha256-6Rahf2Mldeln9EVBr2kMOMCU8fv/DaavVZO7ToyG9mc="; + vendorHash = "sha256-Z8jGsWOTlw47eJ9n7SUJvGfNtXM5HWIe+Aaip0IuOxE="; }; in { @@ -89,16 +89,34 @@ description = "Embedding model name."; }; - tokenFile = lib.mkOption { + adminPasswordFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; - description = "Path to a file containing the MCP Bearer token."; + description = "Path to a file containing the admin user password for bootstrap."; }; - token = lib.mkOption { + adminPassword = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; - description = "MCP Bearer token directly. Prefer tokenFile for secrets."; + description = "Admin user password directly. Prefer adminPasswordFile for secrets."; + }; + + adminUsername = lib.mkOption { + type = lib.types.str; + default = "admin"; + description = "Admin username for bootstrap."; + }; + + mcpTokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to a file containing the initial MCP session token."; + }; + + mcpToken = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Initial MCP session token directly. Prefer mcpTokenFile for secrets."; }; sqlitePath = lib.mkOption { @@ -136,9 +154,10 @@ StateDirectory = "st-pierre"; ExecStart = "${st-pierre-pkg}/bin/st-pierre"; } - // lib.optionalAttrs (cfg.openaiApiKeyFile != null || cfg.tokenFile != null || cfg.qdrantApiKeyFile != null) { + // lib.optionalAttrs (cfg.openaiApiKeyFile != null || cfg.adminPasswordFile != null || cfg.mcpTokenFile != null || cfg.qdrantApiKeyFile != null) { EnvironmentFile = lib.optional (cfg.openaiApiKeyFile != null) cfg.openaiApiKeyFile - ++ lib.optional (cfg.tokenFile != null) cfg.tokenFile + ++ lib.optional (cfg.adminPasswordFile != null) cfg.adminPasswordFile + ++ lib.optional (cfg.mcpTokenFile != null) cfg.mcpTokenFile ++ lib.optional (cfg.qdrantApiKeyFile != null) cfg.qdrantApiKeyFile; }; @@ -157,8 +176,14 @@ // lib.optionalAttrs (cfg.openaiApiKey != null) { OPENAI_API_KEY = cfg.openaiApiKey; } - // lib.optionalAttrs (cfg.token != null) { - MCP_TOKEN = cfg.token; + // lib.optionalAttrs (cfg.adminPassword != null) { + ADMIN_PASSWORD = cfg.adminPassword; + } + // lib.optionalAttrs (true) { + ADMIN_USERNAME = cfg.adminUsername; + } + // lib.optionalAttrs (cfg.mcpToken != null) { + MCP_TOKEN = cfg.mcpToken; }; }; }; diff --git a/go.mod b/go.mod index 386df46..6889b0e 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module github.com/didier/st-pierre go 1.26.2 require ( + github.com/joho/godotenv v1.5.1 github.com/mark3labs/mcp-go v0.54.1 github.com/pressly/goose/v3 v3.27.1 github.com/qdrant/go-client v1.18.2 go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.53.0 modernc.org/sqlite v1.51.0 ) @@ -14,7 +16,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -24,10 +25,10 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 9f709c1..be067da 100644 --- a/go.sum +++ b/go.sum @@ -78,18 +78,20 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= diff --git a/main.go b/main.go index 0f8726d..ff28c86 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,6 @@ import ( "os" "os/signal" "strconv" - "strings" "syscall" "time" @@ -30,18 +29,19 @@ func main() { } defer logger.Sync() - token := requireEnv(logger, "MCP_TOKEN") openAIKey := requireEnv(logger, "OPENAI_API_KEY") port := envOrDefault("PORT", "8080") sqlitePath := envOrDefault("SQLITE_PATH", "flashcard.db") - sqliteStore, err := newSqliteStore(sqlitePath) + store, err := newSqliteStore(sqlitePath) if err != nil { logger.Fatal("failed to init sqlite", zap.Error(err)) } - defer sqliteStore.close() + defer store.close() - searchEngine := createSearchEngine(logger, sqliteStore, openAIKey) + store.bootstrapAdmin(context.Background(), logger) + + searchEngine := createSearchEngine(logger, store, openAIKey) mcpServer := server.NewMCPServer( "flashcard-mcp", @@ -50,15 +50,20 @@ func main() { server.WithLogging(), ) - registerStoreFlashcard(mcpServer, sqliteStore, searchEngine, logger) + registerStoreFlashcard(mcpServer, store, searchEngine, logger) registerSearchFlashcard(mcpServer, searchEngine) - registerUpdateFlashcard(mcpServer, sqliteStore, searchEngine, logger) - registerDeleteFlashcard(mcpServer, sqliteStore, searchEngine, logger) - registerVoteFlashcard(mcpServer, sqliteStore, logger) + registerUpdateFlashcard(mcpServer, store, searchEngine, logger) + registerDeleteFlashcard(mcpServer, store, searchEngine, logger) + registerVoteFlashcard(mcpServer, store, logger) streamableServer := server.NewStreamableHTTPServer(mcpServer) + withAuth := func(next http.Handler) http.Handler { + return authMiddleware(store, logger, next) + } + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) @@ -70,8 +75,58 @@ func main() { "git_commit": os.Getenv("GIT_COMMIT"), }) }) - mux.Handle("/mcp", authMiddleware(token, streamableServer)) - registerAPI(mux, sqliteStore, logger) + + mux.Handle("/mcp", withAuth(streamableServer)) + + mux.HandleFunc("/api/v1/auth/login", loginHandler(store, logger)) + mux.HandleFunc("/api/v1/auth/logout", logoutHandler(store)) + mux.HandleFunc("/api/v1/auth/me", withAuth(getUserHandler(store, logger)).ServeHTTP) + mux.HandleFunc("/api/v1/auth/password", withAuth(changePasswordHandler(store, logger)).ServeHTTP) + + mux.Handle("/api/v1/sessions", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + createSessionHandler(store, logger).ServeHTTP(w, r) + case http.MethodGet: + listSessionsHandler(store, logger).ServeHTTP(w, r) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } + }))) + mux.Handle("/api/v1/sessions/", withAuth(deleteSessionHandler(store, logger))) + + mux.Handle("/api/v1/users", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + createUserHandler(store, logger).ServeHTTP(w, r) + case http.MethodGet: + listUsersHandler(store, logger).ServeHTTP(w, r) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } + }))) + + mux.Handle("/api/v1/flashcards", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + summaries, err := store.listAll(r.Context()) + if err != nil { + logger.Error("list flashcards", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + writeJSON(w, http.StatusOK, map[string][]flashcardSummary{"flashcards": summaries}) + }))) + mux.Handle("/api/v1/flashcards/", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + serveFlashcardDetail(w, r, store) + }))) + mux.Handle("/", publicHandler()) httpServer := &http.Server{ @@ -91,7 +146,7 @@ func main() { if err := streamableServer.Shutdown(shutdownCtx); err != nil { logger.Error("shutdown error", zap.Error(err)) } - sqliteStore.close() + store.close() os.Exit(0) }() @@ -505,20 +560,6 @@ func handleSearchFlashcard(engine SearchEngine) server.ToolHandlerFunc { } } -func authMiddleware(token string, next http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - authHeader := strings.ToLower(r.Header.Get("Authorization")) - if !strings.HasPrefix(authHeader, "bearer ") || strings.TrimPrefix(authHeader, "bearer ") != token { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error":"unauthorized"}`)) - return - } - - next.ServeHTTP(w, r) - } -} - func corsMiddleware(next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") diff --git a/migrations/000004_create_users_sessions.sql b/migrations/000004_create_users_sessions.sql new file mode 100644 index 0000000..d052e83 --- /dev/null +++ b/migrations/000004_create_users_sessions.sql @@ -0,0 +1,22 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + active INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + label TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT, + active INTEGER NOT NULL DEFAULT 1 +); + +-- +goose Down +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; diff --git a/public/app.js b/public/app.js index 1f3a84f..6c5879c 100644 --- a/public/app.js +++ b/public/app.js @@ -1,12 +1,168 @@ import m from "https://esm.sh/mithril@2.3.8"; const API = "/api/v1/flashcards"; +const AUTH = "/api/v1/auth"; +const USERS = "/api/v1/users"; +const SESSIONS = "/api/v1/sessions"; + +let user = null; +let authLoading = true; + +async function checkAuth() { + try { + const res = await fetch(AUTH + "/me"); + user = res.ok ? await res.json() : null; + } catch { + user = null; + } + authLoading = false; +} + +const Login = { + view: function (v) { + if (authLoading) { + return m(".cyber-center-xy", [ + m(".cyber-spinner.cyber-spinner--lg"), + m("p.cyber-text-muted.cyber-mt-md", "loading..."), + ]); + } + if (user) return null; + + const err = v.state.error; + return m(".cyber-center-xy", [ + m( + ".cyber-card.cyber-card--holo", + { style: "width:360px;max-width:90vw" }, + [ + m(".cyber-card__header", [ + m("h2.cyber-text-cyan", "St-Pierre"), + m("p.cyber-text-muted", "Login"), + ]), + m(".cyber-card__body", [ + err + ? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err) + : null, + m( + "form", + { + onsubmit: async function (e) { + e.preventDefault(); + v.state.error = null; + const fd = new FormData(e.target); + const res = await fetch(AUTH + "/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: fd.get("username"), + password: fd.get("password"), + }), + }); + if (res.ok) { + await checkAuth(); + m.redraw(); + return; + } + const d = await res.json(); + v.state.error = d.error || "Login failed"; + m.redraw(); + }, + }, + [ + m(".cyber-field.cyber-mb-sm", [ + m( + "input.cyber-input", + { + name: "username", + placeholder: "Username", + autofocus: true, + required: true, + }, + ), + ]), + m(".cyber-field.cyber-mb-md", [ + m("input.cyber-input", { + name: "password", + type: "password", + placeholder: "Password", + required: true, + }), + ]), + m( + "button.cyber-btn.cyber-btn--primary.cyber-btn--block", + { type: "submit" }, + "Login", + ), + ], + ), + ]), + ], + ), + ]); + }, +}; + +const Layout = { + oninit: async function () { + if (user === null) { + await checkAuth(); + m.redraw(); + } + }, + view: function (v) { + if (authLoading) { + return m(".cyber-center-xy", [ + m(".cyber-spinner.cyber-spinner--lg"), + m("p.cyber-text-muted.cyber-mt-md", "loading..."), + ]); + } + if (!user) return m(Login); + + return m("div", [ + m( + "header.cyber-flex.cyber-justify-between.cyber-items-center.cyber-p-md", + { style: "border-bottom:1px solid var(--cyber-void-700)" }, + [ + m( + "button.cyber-btn.cyber-btn--ghost.cyber-font-mono", + { onclick: () => m.route.set("/") }, + "St-Pierre", + ), + m("div.cyber-flex.cyber-items-center.cyber-gap-sm", [ + m( + "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm", + { onclick: () => m.route.set("/tokens") }, + "Tokens", + ), + m("span.cyber-badge.cyber-badge--pulse", user.username), + m( + "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm", + { onclick: () => m.route.set("/profile") }, + "Profile", + ), + m( + "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm", + { + onclick: async () => { + await fetch(AUTH + "/logout", { method: "POST" }); + user = null; + m.redraw(); + }, + }, + "Logout", + ), + ]), + ], + ), + m("main", v.children), + ]); + }, +}; const Card = { view: function (v) { const c = v.attrs.card; const maxNote = - (c.note || "").length > 120 ? c.note.slice(0, 120) + "…" : c.note; + (c.note || "").length > 120 ? c.note.slice(0, 120) + "\u2026" : c.note; return m( ".cyber-card.cyber-card--interactive.cyber-neon-border.cyber-mb-md", { @@ -76,7 +232,7 @@ const List = { if (v.state.loading) { return m(".cyber-center-xy", [ m(".cyber-spinner.cyber-spinner--lg"), - m("p.cyber-text-muted.cyber-mt-md", "loading…"), + m("p.cyber-text-muted.cyber-mt-md", "loading\u2026"), ]); } const totalPages = Math.max(1, Math.ceil(v.state.cards.length / PAGE_SIZE)); @@ -195,7 +351,427 @@ const Detail = { }, }; +const Users = { + oninit: async function (v) { + v.state.users = []; + v.state.loading = true; + try { + const res = await fetch(USERS); + const data = await res.json(); + v.state.users = data.users || []; + } catch {} + v.state.loading = false; + m.redraw(); + }, + view: function (v) { + if (v.state.loading) { + return m(".cyber-center-xy", [ + m(".cyber-spinner.cyber-spinner--lg"), + m("p.cyber-text-muted.cyber-mt-md", "loading\u2026"), + ]); + } + + const err = v.state.error; + const ok = v.state.ok; + + return m(".cyber-p-lg", { style: "max-width:640px;margin:0 auto" }, [ + m("h2.cyber-text-cyan.cyber-mb-md", "Users"), + ok + ? m(".cyber-alert.cyber-alert--success.cyber-mb-md", ok) + : null, + err + ? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err) + : null, + m( + ".cyber-card.cyber-card--holo.cyber-mb-lg", + [ + m(".cyber-card__header", m("h3", "Create User")), + m(".cyber-card__body", [ + m( + "form", + { + onsubmit: async function (e) { + e.preventDefault(); + v.state.error = null; + v.state.ok = null; + const fd = new FormData(e.target); + const res = await fetch(USERS, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: fd.get("username"), + password: fd.get("password"), + }), + }); + if (res.ok) { + e.target.reset(); + v.state.ok = `User ${fd.get("username")} created.`; + const data = await fetch(USERS).then((r) => r.json()); + v.state.users = data.users || []; + } else { + const d = await res.json(); + v.state.error = d.error || "Failed to create user"; + } + m.redraw(); + }, + }, + [ + m(".cyber-flex.cyber-gap-md.cyber-mb-sm", [ + m("input.cyber-input", { + name: "username", + placeholder: "Username", + required: true, + style: "flex:1", + }), + m("input.cyber-input", { + name: "password", + type: "password", + placeholder: "Password (min 8 chars)", + required: true, + minlength: 8, + style: "flex:1", + }), + m( + "button.cyber-btn.cyber-btn--primary", + { type: "submit" }, + "Create", + ), + ]), + ], + ), + ]), + ], + ), + v.state.users.length === 0 + ? m("p.cyber-text-muted", "No users found.") + : m( + ".cyber-card.cyber-card--holo", + [ + m(".cyber-card__header", m("h3", `All Users (${v.state.users.length})`)), + m(".cyber-card__body", [ + m( + "table.cyber-table", + { style: "width:100%" }, + [ + m("thead", m("tr", [ + m("th", "ID"), + m("th", "Username"), + m("th", "Created"), + m("th", "Active"), + ])), + m( + "tbody", + v.state.users.map((u) => + m("tr", [ + m("td.cyber-font-mono", u.id), + m("td", u.username), + m("td.cyber-font-mono", u.created_at), + m("td", u.active ? m("span.cyber-badge.cyber-badge--pulse", "yes") : m("span.cyber-badge", "no")), + ]), + ), + ), + ], + ), + ]), + ], + ), + ]); + }, +}; + +const Profile = { + view: function (v) { + const err = v.state.error; + const ok = v.state.ok; + + return m(".cyber-p-lg", { style: "max-width:640px;margin:0 auto" }, [ + m("h2.cyber-text-cyan.cyber-mb-md", "Profile"), + m( + ".cyber-card.cyber-card--holo.cyber-mb-lg", + [ + m(".cyber-card__header", m("h3", user.username)), + m(".cyber-card__body", [ + m(".cyber-field.cyber-mb-sm", [ + m(".cyber-field__label", "User ID"), + m("p.cyber-font-mono", user.id), + ]), + m(".cyber-field", [ + m(".cyber-field__label", "Created"), + m("p.cyber-font-mono", user.created_at), + ]), + ]), + ], + ), + m( + ".cyber-card.cyber-card--holo", + [ + m(".cyber-card__header", m("h3", "Change Password")), + m(".cyber-card__body", [ + ok + ? m(".cyber-alert.cyber-alert--success.cyber-mb-md", ok) + : null, + err + ? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err) + : null, + m( + "form", + { + onsubmit: async function (e) { + e.preventDefault(); + v.state.error = null; + v.state.ok = null; + const fd = new FormData(e.target); + const res = await fetch(AUTH + "/password", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + old_password: fd.get("old_password"), + new_password: fd.get("new_password"), + }), + }); + if (res.ok) { + e.target.reset(); + v.state.ok = "Password changed."; + } else { + const d = await res.json(); + v.state.error = d.error || "Failed to change password"; + } + m.redraw(); + }, + }, + [ + m(".cyber-field.cyber-mb-sm", [ + m("input.cyber-input", { + name: "old_password", + type: "password", + placeholder: "Current password", + required: true, + autofocus: true, + }), + ]), + m(".cyber-field.cyber-mb-md", [ + m("input.cyber-input", { + name: "new_password", + type: "password", + placeholder: "New password (min 8 chars)", + required: true, + minlength: 8, + }), + ]), + m( + "button.cyber-btn.cyber-btn--primary.cyber-btn--block", + { type: "submit" }, + "Change Password", + ), + ], + ), + ]), + ], + ), + ]); + }, +}; + +const Tokens = { + oninit: async function (v) { + v.state.sessions = []; + v.state.loading = true; + try { + const res = await fetch(SESSIONS); + const data = await res.json(); + v.state.sessions = data.sessions || []; + } catch {} + v.state.loading = false; + m.redraw(); + }, + view: function (v) { + if (v.state.loading) { + return m(".cyber-center-xy", [ + m(".cyber-spinner.cyber-spinner--lg"), + m("p.cyber-text-muted.cyber-mt-md", "loading\u2026"), + ]); + } + + const err = v.state.error; + const newToken = v.state.newToken; + + return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [ + m("h2.cyber-text-cyan.cyber-mb-md", "API Tokens"), + + newToken + ? m( + ".cyber-card.cyber-card--holo.cyber-mb-lg", + { style: "border-color:var(--cyber-yellow-500)" }, + [ + m(".cyber-card__header", [ + m( + "span.cyber-badge.cyber-badge--pulse.cyber-mr-sm", + "NEW", + ), + m("span.cyber-text-yellow", newToken.label), + ]), + m(".cyber-card__body", [ + m("p.cyber-text-yellow.cyber-mb-sm", [ + m("strong", "Copy this token now. "), + "It will never be shown again.", + ]), + m( + ".cyber-field.cyber-mb-sm", + m( + ".cyber-flex.cyber-gap-sm", + [ + m("input.cyber-input.cyber-font-mono", { + value: newToken.token, + readonly: true, + style: "flex:1;user-select:all", + onfocus: function (e) { + e.target.select(); + }, + }), + m( + "button.cyber-btn.cyber-btn--sm", + { + onclick: function () { + const input = v.dom.querySelector( + "input[readonly]", + ); + navigator.clipboard.writeText(newToken.token); + input.select(); + }, + }, + "Copy", + ), + ], + ), + ), + m( + "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm", + { onclick: () => { v.state.newToken = null; } }, + "Dismiss", + ), + ]), + ], + ) + : null, + + err + ? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err) + : null, + + m( + ".cyber-card.cyber-card--holo.cyber-mb-lg", + [ + m(".cyber-card__header", m("h3", "Create Token")), + m(".cyber-card__body", [ + m( + "form", + { + onsubmit: async function (e) { + e.preventDefault(); + v.state.error = null; + const fd = new FormData(e.target); + const res = await fetch(SESSIONS, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: fd.get("label") }), + }); + if (res.ok) { + const token = await res.json(); + e.target.reset(); + const data = await fetch(SESSIONS).then((r) => r.json()); + v.state.sessions = data.sessions || []; + v.state.newToken = token; + } else { + const d = await res.json(); + v.state.error = d.error || "Failed to create token"; + } + m.redraw(); + }, + }, + [ + m(".cyber-flex.cyber-gap-md.cyber-mb-sm", [ + m("input.cyber-input", { + name: "label", + placeholder: "e.g. opencode on shulgi", + required: true, + style: "flex:1", + }), + m( + "button.cyber-btn.cyber-btn--primary", + { type: "submit" }, + "Generate", + ), + ]), + ], + ), + ]), + ], + ), + + v.state.sessions.length === 0 + ? m("p.cyber-text-muted", "No tokens yet.") + : m( + ".cyber-card.cyber-card--holo", + [ + m(".cyber-card__header", m("h3", `Your Tokens (${v.state.sessions.length})`)), + m(".cyber-card__body", [ + m( + "table.cyber-table", + { style: "width:100%" }, + [ + m("thead", m("tr", [ + m("th", "ID"), + m("th", "Label"), + m("th", "Created"), + m("th", "Last Used"), + m("th", "Status"), + m("th"), + ])), + m( + "tbody", + v.state.sessions.map((s) => + m("tr", [ + m("td.cyber-font-mono", s.id), + m("td", s.label), + m("td.cyber-font-mono", s.created_at), + m("td.cyber-font-mono", s.last_used_at || "\u2014"), + m("td", s.active + ? m("span.cyber-badge.cyber-badge--pulse", "active") + : m("span.cyber-badge.cyber-badge--ghost", "revoked"), + ), + m("td", s.active + ? m( + "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm.cyber-text-magenta", + { + onclick: async () => { + await fetch(`${SESSIONS}/${s.id}`, { + method: "DELETE", + }); + const data = await fetch(SESSIONS).then((r) => r.json()); + v.state.sessions = data.sessions || []; + m.redraw(); + }, + }, + "Revoke", + ) + : null, + ), + ]), + ), + ), + ], + ), + ]), + ], + ), + ]); + }, +}; + m.route(document.getElementById("app"), "/", { - "/": List, - "/flashcard/:id": Detail, + "/": { render: () => m(Layout, m(List)) }, + "/flashcard/:id": { render: (v) => m(Layout, m(Detail, { id: v.attrs.id })) }, + "/users": { render: () => m(Layout, m(Users)) }, + "/tokens": { render: () => m(Layout, m(Tokens)) }, + "/profile": { render: () => m(Layout, m(Profile)) }, });