feat: added auth and crazy more things

This commit is contained in:
didier committed 2026-06-16 12:55:56 -04:00
1 parent 1ecf468140
commit be9d6b610f
10 files changed
+1485 -102

No files matched your search

+5 -1
View File
@@ -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
+3
View File
@@ -4,6 +4,9 @@ st-pierre
# Nix build result
result
# Go vendor
/vendor/
# Environment
.env
+20 -45
View File
@@ -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
}
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
}
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] == "" {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
publicID, err := strconv.ParseInt(segments[0], 10, 64)
if err != nil {
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
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 {
http.Error(w, `{"error":"invalid version"}`, http.StatusBadRequest)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"})
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) {
e, err := store.getByPublicIDAndVersion(r.Context(), publicID, version)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
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
}
writeJSON(w, http.StatusOK, e)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(e)
e, err := store.getLatestByPublicID(r.Context(), publicID)
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)
}
+734
View File
@@ -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)
}
}
+34 -9
View File
@@ -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;
};
};
};
+6 -5
View File
@@ -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
+14 -12
View File
@@ -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=
+67 -26
View File
@@ -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", "*")
@@ -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;
+580 -4
View File
@@ -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)) },
});