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:
2026-05-26 23:51:02 +02:00
parent f60d223c06
commit 68eaee0b9f
12 changed files with 310 additions and 49 deletions
+65 -3
View File
@@ -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