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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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"})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"markdownhub/internal/auth"
|
"markdownhub/internal/auth"
|
||||||
"markdownhub/internal/files"
|
"markdownhub/internal/files"
|
||||||
|
"markdownhub/internal/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
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 {
|
var req struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
TOTPCode string `json:"totp_code"`
|
||||||
}
|
}
|
||||||
if err := decodeBody(r, &req); err != nil {
|
if err := decodeBody(r, &req); err != nil {
|
||||||
writeJSON(w, 400, map[string]string{"error": "invalid request"})
|
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 id, hash string
|
||||||
var isAdmin bool
|
var isAdmin bool
|
||||||
|
var totpSecret *string
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
"SELECT id, password_hash, is_admin FROM users WHERE email = ?", req.Email,
|
"SELECT id, password_hash, is_admin, totp_secret FROM users WHERE email = ?", req.Email,
|
||||||
).Scan(&id, &hash, &isAdmin)
|
).Scan(&id, &hash, &isAdmin, &totpSecret)
|
||||||
if err != nil || !auth.CheckPassword(hash, req.Password) {
|
if err != nil || !auth.CheckPassword(hash, req.Password) {
|
||||||
writeJSON(w, 401, map[string]string{"error": "invalid credentials"})
|
writeJSON(w, 401, map[string]string{"error": "invalid credentials"})
|
||||||
return
|
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)
|
token, err := auth.CreateToken(id, isAdmin, s.secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, 500, map[string]string{"error": "token creation failed"})
|
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"})
|
writeJSON(w, 500, map[string]string{"error": "write failed"})
|
||||||
return
|
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})
|
writeJSON(w, 200, map[string]string{"status": "ok", "path": req.Path})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -20,6 +20,9 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler {
|
|||||||
// Auth
|
// Auth
|
||||||
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
||||||
mux.HandleFunc("POST /api/auth/logout", s.handleLogout)
|
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)
|
// Users (admin)
|
||||||
mux.HandleFunc("POST /api/users/create", s.requireAdmin(s.handleCreateUser))
|
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/create-folder", s.requireAuth(s.handleCreateFolder))
|
||||||
mux.HandleFunc("POST /api/files/delete", s.requireAuth(s.handleDeleteFile))
|
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/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
|
// Build jobs
|
||||||
mux.HandleFunc("POST /api/build/submit", s.requireAuth(s.handleBuildSubmit))
|
mux.HandleFunc("POST /api/build/submit", s.requireAuth(s.handleBuildSubmit))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user