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
This commit is contained in:
2026-05-22 19:53:24 +02:00
parent 0c1047d390
commit 4df87cbf9a
8 changed files with 830 additions and 3 deletions
+173
View File
@@ -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
}
+161
View File
@@ -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"})
}
+24 -2
View File
@@ -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})
}
+24 -1
View File
@@ -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))
+139
View File
@@ -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)
}
+75
View File
@@ -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"})
}