feat: added auth and crazy more things
This commit is contained in:
1 parent
1ecf468140
commit
be9d6b610f
10 files changed
+1500
-117
No files matched your search
+5
-1
@@ -5,6 +5,10 @@ QDRANT_PORT=6334
|
|||||||
OPENAI_API_KEY=sk-...
|
OPENAI_API_KEY=sk-...
|
||||||
# EMBEDDING_BASE_URL=https://api.openai.com
|
# EMBEDDING_BASE_URL=https://api.openai.com
|
||||||
EMBEDDING_MODEL=text-embedding-3-small
|
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
|
PORT=8080
|
||||||
SQLITE_PATH=flashcard.db
|
SQLITE_PATH=flashcard.db
|
||||||
@@ -4,6 +4,9 @@ st-pierre
|
|||||||
# Nix build result
|
# Nix build result
|
||||||
result
|
result
|
||||||
|
|
||||||
|
# Go vendor
|
||||||
|
/vendor/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +1,51 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func registerAPI(mux *http.ServeMux, sqlite *sqliteStore, logger *zap.Logger) {
|
func serveFlashcardDetail(w http.ResponseWriter, r *http.Request, store *sqliteStore) {
|
||||||
mux.HandleFunc("/api/v1/flashcards", func(w http.ResponseWriter, r *http.Request) {
|
segments := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/flashcards/"), "/")
|
||||||
if r.Method != http.MethodGet {
|
if len(segments) == 0 || segments[0] == "" {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
summaries, err := sqlite.listAll(r.Context())
|
publicID, err := strconv.ParseInt(segments[0], 10, 64)
|
||||||
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) {
|
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if e == nil {
|
if e == nil {
|
||||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
writeJSON(w, http.StatusOK, e)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(e)
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
pname = "st-pierre";
|
pname = "st-pierre";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
vendorHash = "sha256-6Rahf2Mldeln9EVBr2kMOMCU8fv/DaavVZO7ToyG9mc=";
|
vendorHash = "sha256-Z8jGsWOTlw47eJ9n7SUJvGfNtXM5HWIe+Aaip0IuOxE=";
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
@@ -89,16 +89,34 @@
|
|||||||
description = "Embedding model name.";
|
description = "Embedding model name.";
|
||||||
};
|
};
|
||||||
|
|
||||||
tokenFile = lib.mkOption {
|
adminPasswordFile = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.path;
|
type = lib.types.nullOr lib.types.path;
|
||||||
default = null;
|
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;
|
type = lib.types.nullOr lib.types.str;
|
||||||
default = null;
|
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 {
|
sqlitePath = lib.mkOption {
|
||||||
@@ -136,9 +154,10 @@
|
|||||||
StateDirectory = "st-pierre";
|
StateDirectory = "st-pierre";
|
||||||
ExecStart = "${st-pierre-pkg}/bin/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
|
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;
|
++ lib.optional (cfg.qdrantApiKeyFile != null) cfg.qdrantApiKeyFile;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,8 +176,14 @@
|
|||||||
// lib.optionalAttrs (cfg.openaiApiKey != null) {
|
// lib.optionalAttrs (cfg.openaiApiKey != null) {
|
||||||
OPENAI_API_KEY = cfg.openaiApiKey;
|
OPENAI_API_KEY = cfg.openaiApiKey;
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (cfg.token != null) {
|
// lib.optionalAttrs (cfg.adminPassword != null) {
|
||||||
MCP_TOKEN = cfg.token;
|
ADMIN_PASSWORD = cfg.adminPassword;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (true) {
|
||||||
|
ADMIN_USERNAME = cfg.adminUsername;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.mcpToken != null) {
|
||||||
|
MCP_TOKEN = cfg.mcpToken;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ module github.com/didier/st-pierre
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mark3labs/mcp-go v0.54.1
|
github.com/mark3labs/mcp-go v0.54.1
|
||||||
github.com/pressly/goose/v3 v3.27.1
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
github.com/qdrant/go-client v1.18.2
|
github.com/qdrant/go-client v1.18.2
|
||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
modernc.org/sqlite v1.51.0
|
modernc.org/sqlite v1.51.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +16,6 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // 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/mattn/go-isatty v0.0.21 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // 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/spf13/cast v1.7.1 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/net v0.53.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.36.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/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
|
||||||
google.golang.org/grpc v1.80.0 // indirect
|
google.golang.org/grpc v1.80.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
|||||||
@@ -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.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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
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=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -30,18 +29,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer logger.Sync()
|
defer logger.Sync()
|
||||||
|
|
||||||
token := requireEnv(logger, "MCP_TOKEN")
|
|
||||||
openAIKey := requireEnv(logger, "OPENAI_API_KEY")
|
openAIKey := requireEnv(logger, "OPENAI_API_KEY")
|
||||||
port := envOrDefault("PORT", "8080")
|
port := envOrDefault("PORT", "8080")
|
||||||
|
|
||||||
sqlitePath := envOrDefault("SQLITE_PATH", "flashcard.db")
|
sqlitePath := envOrDefault("SQLITE_PATH", "flashcard.db")
|
||||||
sqliteStore, err := newSqliteStore(sqlitePath)
|
store, err := newSqliteStore(sqlitePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal("failed to init sqlite", zap.Error(err))
|
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(
|
mcpServer := server.NewMCPServer(
|
||||||
"flashcard-mcp",
|
"flashcard-mcp",
|
||||||
@@ -50,15 +50,20 @@ func main() {
|
|||||||
server.WithLogging(),
|
server.WithLogging(),
|
||||||
)
|
)
|
||||||
|
|
||||||
registerStoreFlashcard(mcpServer, sqliteStore, searchEngine, logger)
|
registerStoreFlashcard(mcpServer, store, searchEngine, logger)
|
||||||
registerSearchFlashcard(mcpServer, searchEngine)
|
registerSearchFlashcard(mcpServer, searchEngine)
|
||||||
registerUpdateFlashcard(mcpServer, sqliteStore, searchEngine, logger)
|
registerUpdateFlashcard(mcpServer, store, searchEngine, logger)
|
||||||
registerDeleteFlashcard(mcpServer, sqliteStore, searchEngine, logger)
|
registerDeleteFlashcard(mcpServer, store, searchEngine, logger)
|
||||||
registerVoteFlashcard(mcpServer, sqliteStore, logger)
|
registerVoteFlashcard(mcpServer, store, logger)
|
||||||
|
|
||||||
streamableServer := server.NewStreamableHTTPServer(mcpServer)
|
streamableServer := server.NewStreamableHTTPServer(mcpServer)
|
||||||
|
|
||||||
|
withAuth := func(next http.Handler) http.Handler {
|
||||||
|
return authMiddleware(store, logger, next)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
@@ -70,8 +75,58 @@ func main() {
|
|||||||
"git_commit": os.Getenv("GIT_COMMIT"),
|
"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())
|
mux.Handle("/", publicHandler())
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
@@ -91,7 +146,7 @@ func main() {
|
|||||||
if err := streamableServer.Shutdown(shutdownCtx); err != nil {
|
if err := streamableServer.Shutdown(shutdownCtx); err != nil {
|
||||||
logger.Error("shutdown error", zap.Error(err))
|
logger.Error("shutdown error", zap.Error(err))
|
||||||
}
|
}
|
||||||
sqliteStore.close()
|
store.close()
|
||||||
os.Exit(0)
|
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 {
|
func corsMiddleware(next http.Handler) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
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
@@ -1,12 +1,168 @@
|
|||||||
import m from "https://esm.sh/mithril@2.3.8";
|
import m from "https://esm.sh/mithril@2.3.8";
|
||||||
|
|
||||||
const API = "/api/v1/flashcards";
|
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 = {
|
const Card = {
|
||||||
view: function (v) {
|
view: function (v) {
|
||||||
const c = v.attrs.card;
|
const c = v.attrs.card;
|
||||||
const maxNote =
|
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(
|
return m(
|
||||||
".cyber-card.cyber-card--interactive.cyber-neon-border.cyber-mb-md",
|
".cyber-card.cyber-card--interactive.cyber-neon-border.cyber-mb-md",
|
||||||
{
|
{
|
||||||
@@ -76,7 +232,7 @@ const List = {
|
|||||||
if (v.state.loading) {
|
if (v.state.loading) {
|
||||||
return m(".cyber-center-xy", [
|
return m(".cyber-center-xy", [
|
||||||
m(".cyber-spinner.cyber-spinner--lg"),
|
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));
|
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"), "/", {
|
m.route(document.getElementById("app"), "/", {
|
||||||
"/": List,
|
"/": { render: () => m(Layout, m(List)) },
|
||||||
"/flashcard/:id": Detail,
|
"/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)) },
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user