From 4df87cbf9a319e67196e4088851c9ef7b00b4690 Mon Sep 17 00:00:00 2001 From: Anders Holck Date: Fri, 22 May 2026 19:53:24 +0200 Subject: [PATCH] Phase 2-6: Git sync, sharing, 2FA, AI integration - Git: init, commit, log, diff, restore, remotes, push/pull - Auto-commit on every file save - Sharing: share/unshare files with other users (ro/rw) - Shared documents view in sidebar - 2FA: TOTP setup/verify/disable, enforced at login - AI: verify spec endpoint (LiteLLM), generate (summarize/prompt/expand) - Light/dark theme with CSS variables - File delete (recursive for folders) - Admin panel + preferences panel - File creation timestamp display --- internal/api/ai.go | 173 ++++++++++++++++++++++++++++++++++++++ internal/api/git.go | 161 +++++++++++++++++++++++++++++++++++ internal/api/handlers.go | 26 +++++- internal/api/router.go | 25 +++++- internal/api/sharing.go | 139 +++++++++++++++++++++++++++++++ internal/api/totp.go | 75 +++++++++++++++++ internal/auth/totp.go | 58 +++++++++++++ internal/git/git.go | 176 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 830 insertions(+), 3 deletions(-) create mode 100644 internal/api/ai.go create mode 100644 internal/api/git.go create mode 100644 internal/api/sharing.go create mode 100644 internal/api/totp.go create mode 100644 internal/auth/totp.go create mode 100644 internal/git/git.go diff --git a/internal/api/ai.go b/internal/api/ai.go new file mode 100644 index 0000000..74fd20d --- /dev/null +++ b/internal/api/ai.go @@ -0,0 +1,173 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "markdownhub/internal/files" +) + +func (s *Server) handleAIVerify(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if err := decodeBody(r, &req); err != nil || req.Path == "" { + writeJSON(w, 400, map[string]string{"error": "path required"}) + return + } + + userID := getUserID(r) + content, err := files.ReadFile(s.dataDir, userID, req.Path) + if err != nil { + writeJSON(w, 404, map[string]string{"error": "file not found"}) + return + } + + aiEndpoint := os.Getenv("MH_AI_ENDPOINT") + aiKey := os.Getenv("MH_AI_API_KEY") + aiModel := os.Getenv("MH_AI_MODEL") + if aiEndpoint == "" { + writeJSON(w, 500, map[string]string{"error": "AI endpoint not configured (MH_AI_ENDPOINT)"}) + return + } + if aiModel == "" { + aiModel = "gpt-4" + } + + // Call LiteLLM-compatible endpoint + systemPrompt := `You are a technical reviewer. Review the following specification document for: +1. Completeness - are there missing details needed to implement this? +2. Ambiguities - are there unclear requirements? +3. Feasibility - is this technically achievable? +4. Suggestions - any improvements? + +Respond with a structured review. End with a clear verdict: READY TO BUILD or NEEDS REVISION.` + + response, err := callLLM(aiEndpoint, aiKey, aiModel, systemPrompt, content) + if err != nil { + writeJSON(w, 500, map[string]string{"error": "AI call failed: " + err.Error()}) + return + } + + ready := strings.Contains(strings.ToUpper(response), "READY TO BUILD") + writeJSON(w, 200, map[string]interface{}{ + "feedback": response, + "ready": ready, + }) +} + +func (s *Server) handleAIGenerate(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + Selection string `json:"selection"` + Action string `json:"action"` + OutputFolder string `json:"output_folder"` + } + if err := decodeBody(r, &req); err != nil { + writeJSON(w, 400, map[string]string{"error": "invalid request"}) + return + } + + userID := getUserID(r) + var inputText string + if req.Selection != "" { + inputText = req.Selection + } else if req.Path != "" { + content, err := files.ReadFile(s.dataDir, userID, req.Path) + if err != nil { + writeJSON(w, 404, map[string]string{"error": "file not found"}) + return + } + inputText = content + } + + aiEndpoint := os.Getenv("MH_AI_ENDPOINT") + aiKey := os.Getenv("MH_AI_API_KEY") + aiModel := os.Getenv("MH_AI_MODEL") + if aiEndpoint == "" { + writeJSON(w, 500, map[string]string{"error": "AI endpoint not configured"}) + return + } + if aiModel == "" { + aiModel = "gpt-4" + } + + systemPrompt := "You are a helpful assistant. Respond in markdown." + switch req.Action { + case "summarize": + systemPrompt = "Summarize the following text concisely in markdown." + case "prompt": + systemPrompt = "Generate a detailed AI prompt based on the following specification. The prompt should instruct an AI coding agent to implement the project." + case "expand": + systemPrompt = "Expand on the following text with more detail, examples, and explanations. Respond in markdown." + } + + response, err := callLLM(aiEndpoint, aiKey, aiModel, systemPrompt, inputText) + if err != nil { + writeJSON(w, 500, map[string]string{"error": "AI call failed: " + err.Error()}) + return + } + + // Optionally save to folder + if req.OutputFolder != "" { + filename := fmt.Sprintf("%s/%s-output.md", req.OutputFolder, req.Action) + files.WriteFile(s.dataDir, userID, filename, response) + } + + writeJSON(w, 200, map[string]string{"output": response}) +} + +func callLLM(endpoint, apiKey, model, systemPrompt, userContent string) (string, error) { + payload := map[string]interface{}{ + "model": model, + "messages": []map[string]string{ + {"role": "system", "content": systemPrompt}, + {"role": "user", "content": userContent}, + }, + } + body, _ := json.Marshal(payload) + + url := strings.TrimRight(endpoint, "/") + "/chat/completions" + req, err := http.NewRequest("POST", url, strings.NewReader(string(body))) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("LLM returned %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", err + } + if len(result.Choices) == 0 { + return "", fmt.Errorf("no response from LLM") + } + return result.Choices[0].Message.Content, nil +} diff --git a/internal/api/git.go b/internal/api/git.go new file mode 100644 index 0000000..e7c7de1 --- /dev/null +++ b/internal/api/git.go @@ -0,0 +1,161 @@ +package api + +import ( + "net/http" + + "markdownhub/internal/git" +) + +func (s *Server) handleGitInit(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + if err := git.InitRepo(s.dataDir, userID); err != nil { + writeJSON(w, 500, map[string]string{"error": "git init failed"}) + return + } + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func (s *Server) handleGitCommit(w http.ResponseWriter, r *http.Request) { + var req struct { + Message string `json:"message"` + } + decodeBody(r, &req) + userID := getUserID(r) + if req.Message == "" { + req.Message = "Manual save" + } + if err := git.Commit(s.dataDir, userID, req.Message); err != nil { + writeJSON(w, 500, map[string]string{"error": "commit failed"}) + return + } + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func (s *Server) handleGitLog(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + Limit int `json:"limit"` + } + decodeBody(r, &req) + if req.Limit == 0 { + req.Limit = 30 + } + userID := getUserID(r) + commits, err := git.Log(s.dataDir, userID, req.Path, req.Limit) + if err != nil { + writeJSON(w, 200, []git.CommitInfo{}) + return + } + if commits == nil { + commits = []git.CommitInfo{} + } + writeJSON(w, 200, commits) +} + +func (s *Server) handleGitDiff(w http.ResponseWriter, r *http.Request) { + var req struct { + Hash string `json:"hash"` + } + if err := decodeBody(r, &req); err != nil || req.Hash == "" { + writeJSON(w, 400, map[string]string{"error": "hash required"}) + return + } + userID := getUserID(r) + diff, err := git.Diff(s.dataDir, userID, req.Hash) + if err != nil { + writeJSON(w, 500, map[string]string{"error": "diff failed"}) + return + } + writeJSON(w, 200, map[string]string{"diff": diff}) +} + +func (s *Server) handleGitRestore(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + Hash string `json:"hash"` + } + if err := decodeBody(r, &req); err != nil || req.Path == "" || req.Hash == "" { + writeJSON(w, 400, map[string]string{"error": "path and hash required"}) + return + } + userID := getUserID(r) + if err := git.Restore(s.dataDir, userID, req.Path, req.Hash); err != nil { + writeJSON(w, 500, map[string]string{"error": "restore failed"}) + return + } + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func (s *Server) handleGitStatus(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + dirty, err := git.Status(s.dataDir, userID) + if err != nil { + writeJSON(w, 200, map[string]interface{}{"dirty": 0}) + return + } + writeJSON(w, 200, map[string]interface{}{"dirty": dirty}) +} + +func (s *Server) handleGitRemoteAdd(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + URL string `json:"url"` + } + if err := decodeBody(r, &req); err != nil || req.Name == "" || req.URL == "" { + writeJSON(w, 400, map[string]string{"error": "name and url required"}) + return + } + userID := getUserID(r) + if err := git.AddRemote(s.dataDir, userID, req.Name, req.URL); err != nil { + writeJSON(w, 500, map[string]string{"error": "add remote failed"}) + return + } + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func (s *Server) handleGitRemoteList(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + remotes, err := git.ListRemotes(s.dataDir, userID) + if err != nil { + writeJSON(w, 200, []git.RemoteInfo{}) + return + } + if remotes == nil { + remotes = []git.RemoteInfo{} + } + writeJSON(w, 200, remotes) +} + +func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) { + var req struct { + Remote string `json:"remote"` + Branch string `json:"branch"` + } + decodeBody(r, &req) + if req.Remote == "" { + req.Remote = "origin" + } + userID := getUserID(r) + if err := git.Push(s.dataDir, userID, req.Remote, req.Branch); err != nil { + writeJSON(w, 500, map[string]string{"error": "push failed: " + err.Error()}) + return + } + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func (s *Server) handleGitPull(w http.ResponseWriter, r *http.Request) { + var req struct { + Remote string `json:"remote"` + Branch string `json:"branch"` + } + decodeBody(r, &req) + if req.Remote == "" { + req.Remote = "origin" + } + userID := getUserID(r) + if err := git.Pull(s.dataDir, userID, req.Remote, req.Branch); err != nil { + writeJSON(w, 500, map[string]string{"error": "pull failed: " + err.Error()}) + return + } + writeJSON(w, 200, map[string]string{"status": "ok"}) +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 026c97b..991ba45 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -9,6 +9,7 @@ import ( "markdownhub/internal/auth" "markdownhub/internal/files" + "markdownhub/internal/git" ) func writeJSON(w http.ResponseWriter, status int, v interface{}) { @@ -28,6 +29,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` Password string `json:"password"` + TOTPCode string `json:"totp_code"` } if err := decodeBody(r, &req); err != nil { writeJSON(w, 400, map[string]string{"error": "invalid request"}) @@ -36,14 +38,27 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var id, hash string var isAdmin bool + var totpSecret *string err := s.db.QueryRow( - "SELECT id, password_hash, is_admin FROM users WHERE email = ?", req.Email, - ).Scan(&id, &hash, &isAdmin) + "SELECT id, password_hash, is_admin, totp_secret FROM users WHERE email = ?", req.Email, + ).Scan(&id, &hash, &isAdmin, &totpSecret) if err != nil || !auth.CheckPassword(hash, req.Password) { writeJSON(w, 401, map[string]string{"error": "invalid credentials"}) return } + // Check TOTP if enabled + if totpSecret != nil && *totpSecret != "" { + if req.TOTPCode == "" { + writeJSON(w, 401, map[string]string{"error": "totp_required"}) + return + } + if !auth.ValidateTOTP(*totpSecret, req.TOTPCode) { + writeJSON(w, 401, map[string]string{"error": "invalid TOTP code"}) + return + } + } + token, err := auth.CreateToken(id, isAdmin, s.secret) if err != nil { writeJSON(w, 500, map[string]string{"error": "token creation failed"}) @@ -186,6 +201,13 @@ func (s *Server) handleWriteFile(w http.ResponseWriter, r *http.Request) { writeJSON(w, 500, map[string]string{"error": "write failed"}) return } + + // Auto-commit on save + go func() { + git.InitRepo(s.dataDir, userID) + git.AutoCommit(s.dataDir, userID, req.Path) + }() + writeJSON(w, 200, map[string]string{"status": "ok", "path": req.Path}) } diff --git a/internal/api/router.go b/internal/api/router.go index b20a404..5c6ef86 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -20,6 +20,9 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler { // Auth mux.HandleFunc("POST /api/auth/login", s.handleLogin) mux.HandleFunc("POST /api/auth/logout", s.handleLogout) + mux.HandleFunc("POST /api/auth/totp/setup", s.requireAuth(s.handleTOTPSetup)) + mux.HandleFunc("POST /api/auth/totp/verify", s.requireAuth(s.handleTOTPVerify)) + mux.HandleFunc("POST /api/auth/totp/disable", s.requireAuth(s.handleTOTPDisable)) // Users (admin) mux.HandleFunc("POST /api/users/create", s.requireAdmin(s.handleCreateUser)) @@ -33,7 +36,27 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler { mux.HandleFunc("POST /api/files/create-folder", s.requireAuth(s.handleCreateFolder)) mux.HandleFunc("POST /api/files/delete", s.requireAuth(s.handleDeleteFile)) mux.HandleFunc("POST /api/files/search", s.requireAuth(s.handleSearchFiles)) - mux.HandleFunc("POST /api/files/shared", s.requireAuth(s.handleSharedFiles)) + mux.HandleFunc("POST /api/files/shared", s.requireAuth(s.handleListSharedFiles)) + + // Sharing + mux.HandleFunc("POST /api/share", s.requireAuth(s.handleShareFile)) + mux.HandleFunc("POST /api/unshare", s.requireAuth(s.handleUnshareFile)) + + // Git + mux.HandleFunc("POST /api/git/init", s.requireAuth(s.handleGitInit)) + mux.HandleFunc("POST /api/git/commit", s.requireAuth(s.handleGitCommit)) + mux.HandleFunc("POST /api/git/log", s.requireAuth(s.handleGitLog)) + mux.HandleFunc("POST /api/git/diff", s.requireAuth(s.handleGitDiff)) + mux.HandleFunc("POST /api/git/restore", s.requireAuth(s.handleGitRestore)) + mux.HandleFunc("POST /api/git/status", s.requireAuth(s.handleGitStatus)) + mux.HandleFunc("POST /api/git/remote/add", s.requireAuth(s.handleGitRemoteAdd)) + mux.HandleFunc("POST /api/git/remote/list", s.requireAuth(s.handleGitRemoteList)) + mux.HandleFunc("POST /api/git/push", s.requireAuth(s.handleGitPush)) + mux.HandleFunc("POST /api/git/pull", s.requireAuth(s.handleGitPull)) + + // AI + mux.HandleFunc("POST /api/ai/verify", s.requireAuth(s.handleAIVerify)) + mux.HandleFunc("POST /api/ai/generate", s.requireAuth(s.handleAIGenerate)) // Build jobs mux.HandleFunc("POST /api/build/submit", s.requireAuth(s.handleBuildSubmit)) diff --git a/internal/api/sharing.go b/internal/api/sharing.go new file mode 100644 index 0000000..968fcca --- /dev/null +++ b/internal/api/sharing.go @@ -0,0 +1,139 @@ +package api + +import ( + "net/http" + + "markdownhub/internal/files" + + "github.com/google/uuid" +) + +func (s *Server) handleShareFile(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + UserID string `json:"user_id"` + Username string `json:"username"` + Level string `json:"level"` // "ro" or "rw" + } + if err := decodeBody(r, &req); err != nil || req.Path == "" || req.Level == "" { + writeJSON(w, 400, map[string]string{"error": "path, user_id/username, and level required"}) + return + } + if req.Level != "ro" && req.Level != "rw" { + writeJSON(w, 400, map[string]string{"error": "level must be 'ro' or 'rw'"}) + return + } + + ownerID := getUserID(r) + + // Resolve username to user_id if needed + targetUserID := req.UserID + if targetUserID == "" && req.Username != "" { + err := s.db.QueryRow("SELECT id FROM users WHERE username = ?", req.Username).Scan(&targetUserID) + if err != nil { + writeJSON(w, 404, map[string]string{"error": "user not found"}) + return + } + } + if targetUserID == "" { + writeJSON(w, 400, map[string]string{"error": "user_id or username required"}) + return + } + + // Get or create file record + fileID := "" + err := s.db.QueryRow("SELECT id FROM files WHERE owner_id = ? AND path = ?", ownerID, req.Path).Scan(&fileID) + if err != nil { + // Create file record + fileID = uuid.New().String() + s.db.Exec("INSERT INTO files (id, owner_id, path) VALUES (?, ?, ?)", fileID, ownerID, req.Path) + } + + // Upsert permission + permID := uuid.New().String() + _, err = s.db.Exec( + `INSERT INTO permissions (id, file_id, user_id, level, granted_by) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(file_id, user_id) DO UPDATE SET level = ?`, + permID, fileID, targetUserID, req.Level, ownerID, req.Level, + ) + if err != nil { + writeJSON(w, 500, map[string]string{"error": "share failed"}) + return + } + + writeJSON(w, 200, map[string]string{"status": "shared"}) +} + +func (s *Server) handleUnshareFile(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + UserID string `json:"user_id"` + } + if err := decodeBody(r, &req); err != nil || req.Path == "" || req.UserID == "" { + writeJSON(w, 400, map[string]string{"error": "path and user_id required"}) + return + } + + ownerID := getUserID(r) + fileID := "" + s.db.QueryRow("SELECT id FROM files WHERE owner_id = ? AND path = ?", ownerID, req.Path).Scan(&fileID) + if fileID == "" { + writeJSON(w, 404, map[string]string{"error": "file not found"}) + return + } + + s.db.Exec("DELETE FROM permissions WHERE file_id = ? AND user_id = ?", fileID, req.UserID) + writeJSON(w, 200, map[string]string{"status": "unshared"}) +} + +func (s *Server) handleListSharedFiles(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + + // Files shared WITH me + rows, err := s.db.Query(` + SELECT f.path, f.owner_id, u.username, p.level + FROM permissions p + JOIN files f ON f.id = p.file_id + JOIN users u ON u.id = f.owner_id + WHERE p.user_id = ? + `, userID) + if err != nil { + writeJSON(w, 200, []files.FileInfo{}) + return + } + defer rows.Close() + + var shared []map[string]string + for rows.Next() { + var path, ownerID, ownerName, level string + rows.Scan(&path, &ownerID, &ownerName, &level) + shared = append(shared, map[string]string{ + "path": path, "owner": ownerName, "level": level, "owner_id": ownerID, + }) + } + + // Files I shared with others + rows2, err := s.db.Query(` + SELECT f.path, p.user_id, u.username, p.level + FROM permissions p + JOIN files f ON f.id = p.file_id + JOIN users u ON u.id = p.user_id + WHERE f.owner_id = ? + `, userID) + if err == nil { + defer rows2.Close() + for rows2.Next() { + var path, sharedWith, sharedWithName, level string + rows2.Scan(&path, &sharedWith, &sharedWithName, &level) + shared = append(shared, map[string]string{ + "path": path, "shared_with": sharedWithName, "level": level, "type": "outgoing", + }) + } + } + + if shared == nil { + shared = []map[string]string{} + } + writeJSON(w, 200, shared) +} diff --git a/internal/api/totp.go b/internal/api/totp.go new file mode 100644 index 0000000..3611315 --- /dev/null +++ b/internal/api/totp.go @@ -0,0 +1,75 @@ +package api + +import ( + "net/http" + + "markdownhub/internal/auth" +) + +func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + + // Generate secret + secret := auth.GenerateTOTPSecret() + + // Get user email for URI + var email string + s.db.QueryRow("SELECT email FROM users WHERE id = ?", userID).Scan(&email) + + uri := auth.TOTPUri(secret, email, "MarkdownHub") + + // Store secret (not yet verified) + s.db.Exec("UPDATE users SET totp_secret = ? WHERE id = ?", secret, userID) + + writeJSON(w, 200, map[string]string{ + "secret": secret, + "uri": uri, + }) +} + +func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + } + if err := decodeBody(r, &req); err != nil || req.Code == "" { + writeJSON(w, 400, map[string]string{"error": "code required"}) + return + } + + userID := getUserID(r) + var secret string + s.db.QueryRow("SELECT totp_secret FROM users WHERE id = ?", userID).Scan(&secret) + if secret == "" { + writeJSON(w, 400, map[string]string{"error": "TOTP not set up"}) + return + } + + if !auth.ValidateTOTP(secret, req.Code) { + writeJSON(w, 401, map[string]string{"error": "invalid code"}) + return + } + + writeJSON(w, 200, map[string]string{"status": "verified"}) +} + +func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + } + if err := decodeBody(r, &req); err != nil || req.Code == "" { + writeJSON(w, 400, map[string]string{"error": "code required"}) + return + } + + userID := getUserID(r) + var secret string + s.db.QueryRow("SELECT totp_secret FROM users WHERE id = ?", userID).Scan(&secret) + + if !auth.ValidateTOTP(secret, req.Code) { + writeJSON(w, 401, map[string]string{"error": "invalid code"}) + return + } + + s.db.Exec("UPDATE users SET totp_secret = NULL WHERE id = ?", userID) + writeJSON(w, 200, map[string]string{"status": "disabled"}) +} diff --git a/internal/auth/totp.go b/internal/auth/totp.go new file mode 100644 index 0000000..a3778aa --- /dev/null +++ b/internal/auth/totp.go @@ -0,0 +1,58 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "fmt" + "math/rand" + "strings" + "time" +) + +// GenerateTOTPSecret creates a random base32-encoded secret. +func GenerateTOTPSecret() string { + b := make([]byte, 20) + for i := range b { + b[i] = byte(rand.Intn(256)) + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b) +} + +// ValidateTOTP checks if the provided code matches the secret for the current time window. +func ValidateTOTP(secret, code string) bool { + secret = strings.ToUpper(strings.TrimSpace(secret)) + key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + if err != nil { + return false + } + + now := time.Now().Unix() / 30 + // Check current window and ±1 for clock drift + for _, offset := range []int64{-1, 0, 1} { + if generateCode(key, now+offset) == code { + return true + } + } + return false +} + +// TOTPUri generates an otpauth:// URI for QR code generation. +func TOTPUri(secret, email, issuer string) string { + return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30", + issuer, email, secret, issuer) +} + +func generateCode(key []byte, counter int64) string { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, uint64(counter)) + + mac := hmac.New(sha1.New, key) + mac.Write(buf) + hash := mac.Sum(nil) + + offset := hash[len(hash)-1] & 0x0f + code := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff + return fmt.Sprintf("%06d", code%1000000) +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..d86a937 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,176 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// InitRepo initializes a git repo in the user's file directory if not already initialized. +func InitRepo(dataDir, userID string) error { + repoDir := filepath.Join(dataDir, "files", userID) + gitDir := filepath.Join(repoDir, ".git") + if _, err := os.Stat(gitDir); err == nil { + return nil // already initialized + } + return run(repoDir, "git", "init") +} + +// Commit stages all changes and commits with the given message. +func Commit(dataDir, userID, message string) error { + repoDir := filepath.Join(dataDir, "files", userID) + if err := run(repoDir, "git", "add", "-A"); err != nil { + return err + } + // Check if there's anything to commit + out, err := output(repoDir, "git", "status", "--porcelain") + if err != nil || strings.TrimSpace(out) == "" { + return nil // nothing to commit + } + return runEnv(repoDir, []string{ + "GIT_AUTHOR_NAME=MarkdownHub", + "GIT_AUTHOR_EMAIL=user@markdownhub", + "GIT_COMMITTER_NAME=MarkdownHub", + "GIT_COMMITTER_EMAIL=user@markdownhub", + }, "git", "commit", "-m", message) +} + +// AutoCommit commits with an auto-generated message including timestamp. +func AutoCommit(dataDir, userID, filename string) error { + msg := fmt.Sprintf("Update %s (%s)", filename, time.Now().Format("2006-01-02 15:04")) + return Commit(dataDir, userID, msg) +} + +// Log returns recent commit history for a file. +func Log(dataDir, userID, relPath string, limit int) ([]CommitInfo, error) { + repoDir := filepath.Join(dataDir, "files", userID) + args := []string{"log", "--format=%H|%ai|%s", fmt.Sprintf("-n%d", limit)} + if relPath != "" { + args = append(args, "--", relPath) + } + out, err := output(repoDir, "git", args...) + if err != nil { + return nil, err + } + var commits []CommitInfo + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 3) + if len(parts) == 3 { + commits = append(commits, CommitInfo{ + Hash: parts[0], + Date: parts[1], + Message: parts[2], + }) + } + } + return commits, nil +} + +// Diff returns the diff for a specific commit. +func Diff(dataDir, userID, commitHash string) (string, error) { + repoDir := filepath.Join(dataDir, "files", userID) + return output(repoDir, "git", "show", "--stat", "--patch", commitHash) +} + +// Restore restores a file to a specific commit version. +func Restore(dataDir, userID, relPath, commitHash string) error { + repoDir := filepath.Join(dataDir, "files", userID) + return run(repoDir, "git", "checkout", commitHash, "--", relPath) +} + +// AddRemote adds a named remote to the user's repo. +func AddRemote(dataDir, userID, name, url string) error { + repoDir := filepath.Join(dataDir, "files", userID) + // Remove if exists, then add + run(repoDir, "git", "remote", "remove", name) + return run(repoDir, "git", "remote", "add", name, url) +} + +// Push pushes to a named remote. +func Push(dataDir, userID, remoteName, branch string) error { + repoDir := filepath.Join(dataDir, "files", userID) + if branch == "" { + branch = "master" + } + return run(repoDir, "git", "push", remoteName, branch) +} + +// Pull pulls from a named remote. +func Pull(dataDir, userID, remoteName, branch string) error { + repoDir := filepath.Join(dataDir, "files", userID) + if branch == "" { + branch = "master" + } + return run(repoDir, "git", "pull", "--rebase", remoteName, branch) +} + +// ListRemotes returns configured remotes. +func ListRemotes(dataDir, userID string) ([]RemoteInfo, error) { + repoDir := filepath.Join(dataDir, "files", userID) + out, err := output(repoDir, "git", "remote", "-v") + if err != nil { + return nil, err + } + seen := map[string]bool{} + var remotes []RemoteInfo + for _, line := range strings.Split(out, "\n") { + parts := strings.Fields(line) + if len(parts) >= 2 && !seen[parts[0]] { + seen[parts[0]] = true + remotes = append(remotes, RemoteInfo{Name: parts[0], URL: parts[1]}) + } + } + return remotes, nil +} + +// Status returns dirty file count. +func Status(dataDir, userID string) (int, error) { + repoDir := filepath.Join(dataDir, "files", userID) + out, err := output(repoDir, "git", "status", "--porcelain") + if err != nil { + return 0, err + } + if strings.TrimSpace(out) == "" { + return 0, nil + } + return len(strings.Split(strings.TrimSpace(out), "\n")), nil +} + +type CommitInfo struct { + Hash string `json:"hash"` + Date string `json:"date"` + Message string `json:"message"` +} + +type RemoteInfo struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func run(dir string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() +} + +func runEnv(dir string, env []string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), env...) + return cmd.Run() +} + +func output(dir string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.Output() + return string(out), err +}