Complete TODO items: security, features, polish
Security: - Encrypt Gitea tokens at rest (AES-256-GCM with MH_SECRET) - Secure cookie flag when behind HTTPS (X-Forwarded-Proto) - Password complexity (min 8 chars) - TOTP: defer persist until verified (totp_pending column) - Audit log table + logging on login/rename/password change Features: - Rename files/folders (double-click in tree, /api/files/rename) - beforeunload warning for unsaved changes - Mobile hamburger menu - PWA icons (192px, 512px) - Max file size enforcement (10MB) - Shared file read access (cross-user with permission check) Polish: - Toast notifications replace all alert() calls - Keyboard shortcut help overlay (Ctrl+/) - File rename via double-click in FileTree
This commit is contained in:
+18
-1
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"markdownhub/internal/crypto"
|
||||
"markdownhub/internal/files"
|
||||
)
|
||||
|
||||
@@ -39,10 +40,18 @@ func (s *Server) handleBuildSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
jobID := uuid.New().String()
|
||||
// Encrypt Gitea token at rest
|
||||
encToken := req.GiteaToken
|
||||
if encToken != "" {
|
||||
key := crypto.KeyFromSecret(s.secret)
|
||||
if enc, err := crypto.EncryptString(encToken, key); err == nil {
|
||||
encToken = enc
|
||||
}
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO build_jobs (id, user_id, status, spec_content, gitea_url, gitea_token, gitea_org, repo_name, model)
|
||||
VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?)`,
|
||||
jobID, userID, specContent, req.GiteaURL, req.GiteaToken, req.GiteaOrg, req.RepoName, req.Model,
|
||||
jobID, userID, specContent, req.GiteaURL, encToken, req.GiteaOrg, req.RepoName, req.Model,
|
||||
)
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "failed to create job"})
|
||||
@@ -133,6 +142,14 @@ func (s *Server) handleDaemonPoll(w http.ResponseWriter, r *http.Request) {
|
||||
// Mark as picked up
|
||||
s.db.Exec("UPDATE build_jobs SET status = 'running', updated_at = datetime('now') WHERE id = ?", id)
|
||||
|
||||
// Decrypt token
|
||||
if giteaToken != "" {
|
||||
key := crypto.KeyFromSecret(s.secret)
|
||||
if dec, err := crypto.DecryptString(giteaToken, key); err == nil {
|
||||
giteaToken = dec
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, 200, map[string]interface{}{
|
||||
"job_id": id,
|
||||
"spec_content": specContent,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -56,6 +57,13 @@ func recordLoginAttempt(ip string) {
|
||||
loginAttempts.m[ip] = append(loginAttempts.m[ip], time.Now())
|
||||
}
|
||||
|
||||
func validatePassword(pw string) string {
|
||||
if len(pw) < 8 {
|
||||
return "password must be at least 8 characters"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -110,6 +118,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(72 * time.Hour),
|
||||
})
|
||||
@@ -119,6 +128,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
"userId": id,
|
||||
"isAdmin": isAdmin,
|
||||
})
|
||||
s.audit(id, "login", req.Email)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -141,6 +151,10 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 400, map[string]string{"error": "current_password and new_password required"})
|
||||
return
|
||||
}
|
||||
if msg := validatePassword(req.NewPassword); msg != "" {
|
||||
writeJSON(w, 400, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(r)
|
||||
var hash string
|
||||
@@ -157,6 +171,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
s.db.Exec("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?", newHash, userID)
|
||||
s.audit(userID, "change_password", "")
|
||||
writeJSON(w, 200, map[string]string{"status": "password changed"})
|
||||
}
|
||||
|
||||
@@ -173,6 +188,10 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 400, map[string]string{"error": "username, email, and password required"})
|
||||
return
|
||||
}
|
||||
if msg := validatePassword(req.Password); msg != "" {
|
||||
writeJSON(w, 400, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
@@ -237,7 +256,8 @@ func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Path string `json:"path"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
}
|
||||
if err := decodeBody(r, &req); err != nil || req.Path == "" {
|
||||
writeJSON(w, 400, map[string]string{"error": "path required"})
|
||||
@@ -245,12 +265,29 @@ func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
userID := getUserID(r)
|
||||
content, err := files.ReadFile(s.dataDir, userID, req.Path)
|
||||
readFrom := userID
|
||||
|
||||
// If owner_id specified, check permissions (shared file access)
|
||||
if req.OwnerID != "" && req.OwnerID != userID {
|
||||
var level string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT p.level FROM permissions p
|
||||
JOIN files f ON f.id = p.file_id
|
||||
WHERE f.owner_id = ? AND f.path = ? AND p.user_id = ?`,
|
||||
req.OwnerID, req.Path, userID).Scan(&level)
|
||||
if err != nil {
|
||||
writeJSON(w, 403, map[string]string{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
readFrom = req.OwnerID
|
||||
}
|
||||
|
||||
content, err := files.ReadFile(s.dataDir, readFrom, req.Path)
|
||||
if err != nil {
|
||||
writeJSON(w, 404, map[string]string{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
created := files.GetCreatedTime(s.dataDir, userID, req.Path)
|
||||
created := files.GetCreatedTime(s.dataDir, readFrom, req.Path)
|
||||
writeJSON(w, 200, map[string]interface{}{"path": req.Path, "content": content, "created": created})
|
||||
}
|
||||
|
||||
@@ -263,6 +300,10 @@ func (s *Server) handleWriteFile(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 400, map[string]string{"error": "path required"})
|
||||
return
|
||||
}
|
||||
if len(req.Content) > maxBodySize {
|
||||
writeJSON(w, 413, map[string]string{"error": "file too large (max 10MB)"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(r)
|
||||
if err := files.WriteFile(s.dataDir, userID, req.Path, req.Content); err != nil {
|
||||
@@ -349,6 +390,27 @@ func (s *Server) handleMoveFile(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]string{"status": "moved"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRenameFile(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
NewName string `json:"new_name"`
|
||||
}
|
||||
if err := decodeBody(r, &req); err != nil || req.Path == "" || req.NewName == "" {
|
||||
writeJSON(w, 400, map[string]string{"error": "path and new_name required"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(r)
|
||||
dir := filepath.Dir(req.Path)
|
||||
newPath := filepath.Join(dir, req.NewName)
|
||||
if err := files.MoveFile(s.dataDir, userID, req.Path, newPath); err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "rename failed"})
|
||||
return
|
||||
}
|
||||
s.audit(userID, "rename", req.Path+" -> "+newPath)
|
||||
writeJSON(w, 200, map[string]string{"status": "renamed", "new_path": newPath})
|
||||
}
|
||||
|
||||
func (s *Server) handleSharedFiles(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: query permissions table for files shared with this user
|
||||
// For now return empty list
|
||||
|
||||
@@ -61,3 +61,7 @@ func getUserID(r *http.Request) string {
|
||||
v, _ := r.Context().Value(ctxUserID).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) audit(userID, action, detail string) {
|
||||
s.db.Exec("INSERT INTO audit_log (user_id, action, detail) VALUES (?, ?, ?)", userID, action, detail)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ 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/move", s.requireAuth(s.handleMoveFile))
|
||||
mux.HandleFunc("POST /api/files/rename", s.requireAuth(s.handleRenameFile))
|
||||
mux.HandleFunc("POST /api/files/trash", s.requireAuth(s.handleListTrash))
|
||||
mux.HandleFunc("POST /api/files/trash/restore", s.requireAuth(s.handleRestoreTrash))
|
||||
mux.HandleFunc("POST /api/files/trash/empty", s.requireAuth(s.handleEmptyTrash))
|
||||
|
||||
+16
-12
@@ -9,17 +9,15 @@ import (
|
||||
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)
|
||||
// Store in pending column — not active until verified
|
||||
s.db.Exec("UPDATE users SET totp_pending = ? WHERE id = ?", secret, userID)
|
||||
|
||||
writeJSON(w, 200, map[string]string{
|
||||
"secret": secret,
|
||||
@@ -37,18 +35,20 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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"})
|
||||
var pending *string
|
||||
s.db.QueryRow("SELECT totp_pending FROM users WHERE id = ?", userID).Scan(&pending)
|
||||
if pending == nil || *pending == "" {
|
||||
writeJSON(w, 400, map[string]string{"error": "TOTP not set up — run setup first"})
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.ValidateTOTP(secret, req.Code) {
|
||||
if !auth.ValidateTOTP(*pending, req.Code) {
|
||||
writeJSON(w, 401, map[string]string{"error": "invalid code"})
|
||||
return
|
||||
}
|
||||
|
||||
// Code valid — promote pending to active
|
||||
s.db.Exec("UPDATE users SET totp_secret = ?, totp_pending = NULL WHERE id = ?", *pending, userID)
|
||||
writeJSON(w, 200, map[string]string{"status": "verified"})
|
||||
}
|
||||
|
||||
@@ -62,14 +62,18 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
userID := getUserID(r)
|
||||
var secret string
|
||||
var secret *string
|
||||
s.db.QueryRow("SELECT totp_secret FROM users WHERE id = ?", userID).Scan(&secret)
|
||||
if secret == nil || *secret == "" {
|
||||
writeJSON(w, 400, map[string]string{"error": "2FA not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.ValidateTOTP(secret, req.Code) {
|
||||
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)
|
||||
s.db.Exec("UPDATE users SET totp_secret = NULL, totp_pending = NULL WHERE id = ?", userID)
|
||||
writeJSON(w, 200, map[string]string{"status": "disabled"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user