Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions internal/api/files_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ func (h *FilesHandler) UploadHandler(w http.ResponseWriter, r *http.Request) {
}
node = existingNode
}
return nil
return tx.Nodes.TouchAncestors(r.Context(), user.ID, destPath)
})

if err != nil {
Expand Down Expand Up @@ -319,7 +319,7 @@ func (h *FilesHandler) MkdirHandler(w http.ResponseWriter, r *http.Request) {
}
return err
}
return nil
return tx.Nodes.TouchAncestors(r.Context(), user.ID, dirPath)
})

if err != nil {
Expand Down Expand Up @@ -379,7 +379,10 @@ func (h *FilesHandler) DeleteHandler(w http.ResponseWriter, r *http.Request) {

if node.IsDir {
if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error {
return tx.Nodes.DeletePrefix(r.Context(), user.ID, targetPath)
if err := tx.Nodes.DeletePrefix(r.Context(), user.ID, targetPath); err != nil {
return err
}
return tx.Nodes.TouchAncestors(r.Context(), user.ID, targetPath)
}); err != nil {
h.logger.Error("API delete dir DB transaction failed", slog.String("path", targetPath), slog.Any("error", err))
writeJSONError(w, http.StatusInternalServerError, "Failed to delete directory metadata")
Expand All @@ -389,7 +392,10 @@ func (h *FilesHandler) DeleteHandler(w http.ResponseWriter, r *http.Request) {
audit.Record(r.Context(), "api_delete_dir", targetPath, fmt.Sprintf("User %q deleted directory %q", user.Username, targetPath))
} else {
if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error {
return tx.Nodes.Delete(r.Context(), user.ID, targetPath)
if err := tx.Nodes.Delete(r.Context(), user.ID, targetPath); err != nil {
return err
}
return tx.Nodes.TouchAncestors(r.Context(), user.ID, targetPath)
}); err != nil {
h.logger.Error("API delete file DB transaction failed", slog.String("path", targetPath), slog.Any("error", err))
writeJSONError(w, http.StatusInternalServerError, "Failed to delete file metadata")
Expand Down Expand Up @@ -435,7 +441,10 @@ func (h *FilesHandler) RenameHandler(w http.ResponseWriter, r *http.Request) {
}

if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error {
return tx.Nodes.MovePrefix(r.Context(), user.ID, oldPath, newPath)
if err := tx.Nodes.MovePrefix(r.Context(), user.ID, oldPath, newPath); err != nil {
return err
}
return tx.Nodes.TouchAncestors(r.Context(), user.ID, oldPath, newPath)
}); err != nil {
if revertErr := h.storage.Move(user.Username, newPath, oldPath); revertErr != nil {
h.logger.Error("CRITICAL: compensating storage move failed, filesystem and database are out of sync",
Expand Down
59 changes: 59 additions & 0 deletions internal/api/files_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ func TestFilesHandlerOperations(t *testing.T) {
}
})

t.Run("Delete removes directory atomically", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/api/v1/files?path=/documents", nil)
req = req.WithContext(middleware.WithUser(req.Context(), user))
w := httptest.NewRecorder()

handler.DeleteHandler(w, req)

if w.Code != http.StatusOK {
t.Fatalf("expected status 200 OK, got %d: %s", w.Code, w.Body.String())
}
})

t.Run("Mkdir cleans up storage directory when DB transaction fails", func(t *testing.T) {
failingTx := &failingMockTransactor{err: os.ErrInvalid}
failHandler := NewFilesHandler(failingTx, nodeRepo, st, logger)
Expand Down Expand Up @@ -193,6 +205,53 @@ func TestFilesHandlerOperations(t *testing.T) {
t.Fatalf("expected 413 Request Entity Too Large, got %d: %s", w.Code, w.Body.String())
}
})

t.Run("Delete returns 500 when DeletePrefix fails in DB", func(t *testing.T) {
nodeRepo.nodes["/del_dir_err"] = &domain.Node{UserID: 1, Path: "/del_dir_err", Name: "del_dir_err", IsDir: true}
nodeRepo.deletePrefixErr = os.ErrInvalid
defer func() { nodeRepo.deletePrefixErr = nil }()

req := httptest.NewRequest(http.MethodDelete, "/api/v1/files?path=/del_dir_err", nil)
req = req.WithContext(middleware.WithUser(req.Context(), user))
w := httptest.NewRecorder()

handler.DeleteHandler(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 when DeletePrefix fails, got %d", w.Code)
}
})

t.Run("Delete returns 500 when Delete file fails in DB", func(t *testing.T) {
nodeRepo.nodes["/del_file_err.txt"] = &domain.Node{UserID: 1, Path: "/del_file_err.txt", Name: "del_file_err.txt", IsDir: false}
nodeRepo.deleteErr = os.ErrInvalid
defer func() { nodeRepo.deleteErr = nil }()

req := httptest.NewRequest(http.MethodDelete, "/api/v1/files?path=/del_file_err.txt", nil)
req = req.WithContext(middleware.WithUser(req.Context(), user))
w := httptest.NewRecorder()

handler.DeleteHandler(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 when Delete file fails, got %d", w.Code)
}
})

t.Run("Rename returns 500 when MovePrefix fails in DB", func(t *testing.T) {
nodeRepo.nodes["/rename_err.txt"] = &domain.Node{UserID: 1, Path: "/rename_err.txt", Name: "rename_err.txt"}
_, _ = st.Write(user.Username, "/rename_err.txt", bytes.NewReader([]byte("data")))
nodeRepo.movePrefixErr = os.ErrInvalid
defer func() { nodeRepo.movePrefixErr = nil }()

reqBody, _ := json.Marshal(RenameRequest{OldPath: "/rename_err.txt", NewPath: "/renamed_target.txt"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/files/rename", bytes.NewReader(reqBody))
req = req.WithContext(middleware.WithUser(req.Context(), user))
w := httptest.NewRecorder()

handler.RenameHandler(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 when MovePrefix fails, got %d", w.Code)
}
})
}

type failingMockTransactor struct {
Expand Down
3 changes: 3 additions & 0 deletions internal/api/graph_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ func (m *mockNodeRepoForGraph) MovePrefix(ctx context.Context, userID int64, old
func (m *mockNodeRepoForGraph) EnsureParentDirectoryExists(ctx context.Context, userID int64, parentPath string) error {
return nil
}
func (m *mockNodeRepoForGraph) TouchAncestors(ctx context.Context, userID int64, paths ...string) error {
return nil
}

func TestMeDrivesHandler(t *testing.T) {
handler := api.NewGraphHandler(nil, &mockNodeRepoForGraph{})
Expand Down
18 changes: 17 additions & 1 deletion internal/api/shares_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ func (m *mockTransactor) WithTx(ctx context.Context, fn func(txRepo *repository.
}

type mockNodeRepo struct {
nodes map[string]*domain.Node
nodes map[string]*domain.Node
deleteErr error
deletePrefixErr error
movePrefixErr error
}

func (m *mockNodeRepo) GetByPath(ctx context.Context, userID int64, path string) (*domain.Node, error) {
Expand All @@ -44,13 +47,23 @@ func (m *mockNodeRepo) Update(ctx context.Context, node *domain.Node) error {
return nil
}
func (m *mockNodeRepo) Delete(ctx context.Context, userID int64, path string) error {
if m.deleteErr != nil {
return m.deleteErr
}
delete(m.nodes, path)
return nil
}
func (m *mockNodeRepo) DeletePrefix(ctx context.Context, userID int64, pathPrefix string) error {
if m.deletePrefixErr != nil {
return m.deletePrefixErr
}
delete(m.nodes, pathPrefix)
return nil
}
func (m *mockNodeRepo) MovePrefix(ctx context.Context, userID int64, oldPrefix, newPrefix string) error {
if m.movePrefixErr != nil {
return m.movePrefixErr
}
if n, ok := m.nodes[oldPrefix]; ok {
delete(m.nodes, oldPrefix)
n.Path = newPrefix
Expand All @@ -61,6 +74,9 @@ func (m *mockNodeRepo) MovePrefix(ctx context.Context, userID int64, oldPrefix,
func (m *mockNodeRepo) EnsureParentDirectoryExists(ctx context.Context, userID int64, parentPath string) error {
return nil
}
func (m *mockNodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...string) error {
return nil
}

type mockShareRepo struct {
shares map[string]*domain.Share
Expand Down
4 changes: 4 additions & 0 deletions internal/indexer/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ func (r *Reindexer) ReindexUser(ctx context.Context, user *domain.User, removeOr
stats.Deleted += orphanStats
}

if !r.dryRun && (stats.Created > 0 || stats.Updated > 0 || stats.Deleted > 0) {
_ = r.nodeRepo.TouchAncestors(ctx, user.ID, "/")
}

return stats, nil
}

Expand Down
24 changes: 24 additions & 0 deletions internal/pathutil/pathutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,27 @@ func GenerateFileID(userID int64) string {
_, _ = rand.Read(b)
return fmt.Sprintf("%08doc_%s", userID, hex.EncodeToString(b))
}

// Ancestors returns all ancestor directory paths for a given path up to and including root "/".
// For example, for "/Temp/macroses.txt", it returns []string{"/Temp", "/"}.
// For "/file.txt", it returns []string{"/"}.
// For "/Temp" or "/", it returns []string{"/"}.
func Ancestors(p string) []string {
p = NormalizePath(p)
if p == "" || p == "/" {
return []string{"/"}
}

var ancestors []string
curr := p
for {
parent, _ := SplitPath(curr)
ancestors = append(ancestors, parent)
if parent == "/" {
break
}
curr = parent
}
return ancestors
}

28 changes: 28 additions & 0 deletions internal/pathutil/pathutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,34 @@ func TestFormatContentDisposition(t *testing.T) {
}
}

func TestAncestors(t *testing.T) {
tests := []struct {
input string
want []string
}{
{"", []string{"/"}},
{"/", []string{"/"}},
{"/file.txt", []string{"/"}},
{"/Temp", []string{"/"}},
{"/Temp/macroses.txt", []string{"/Temp", "/"}},
{"/a/b/c/d.txt", []string{"/a/b/c", "/a/b", "/a", "/"}},
{"/a/b/c/", []string{"/a/b", "/a", "/"}},
}

for _, tt := range tests {
got := Ancestors(tt.input)
if len(got) != len(tt.want) {
t.Errorf("Ancestors(%q) len = %d, want %d (%v vs %v)", tt.input, len(got), len(tt.want), got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("Ancestors(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
}
}

func TestValidatePathLength(t *testing.T) {
shortPath := "/valid/short/path.txt"
if err := ValidatePathLength(shortPath); err != nil {
Expand Down
49 changes: 49 additions & 0 deletions internal/repository/node_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package repository
import (
"context"
"crypto/md5"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
Expand Down Expand Up @@ -299,6 +301,53 @@ func (r *nodeRepo) ensureParentDirectoryExistsWithDepth(ctx context.Context, use
return nil
}

func (r *nodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...string) error {
seen := make(map[string]struct{})
var ancestors []string

for _, p := range paths {
for _, a := range pathutil.Ancestors(p) {
if _, exists := seen[a]; !exists {
seen[a] = struct{}{}
ancestors = append(ancestors, a)
}
}
}

if len(ancestors) == 0 {
return nil
}

now := time.Now()
for _, a := range ancestors {
b := make([]byte, 16)
_, _ = rand.Read(b)
newETag := hex.EncodeToString(b)

query, args, err := dialect.Update("nodes").
Set(goqu.Record{
"etag": newETag,
"mtime": now,
"updated_at": now,
}).
Where(goqu.Ex{
"user_id": userID,
"path": a,
"is_dir": 1,
}).
ToSQL()
if err != nil {
return fmt.Errorf("failed to build SQL for TouchAncestors: %w", err)
}

if _, err := r.db.ExecContext(ctx, query, args...); err != nil {
return fmt.Errorf("failed to update ancestor %s: %w", a, err)
}
}

return nil
}

func normalizePath(p string) string {
return pathutil.NormalizePath(p)
}
Expand Down
1 change: 1 addition & 0 deletions internal/repository/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ type NodeRepository interface {
DeletePrefix(ctx context.Context, userID int64, pathPrefix string) error
MovePrefix(ctx context.Context, userID int64, oldPrefix, newPrefix string) error
EnsureParentDirectoryExists(ctx context.Context, userID int64, parentPath string) error
TouchAncestors(ctx context.Context, userID int64, paths ...string) error
}

type ShareRepository interface {
Expand Down
23 changes: 23 additions & 0 deletions internal/repository/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,29 @@ func TestRepositoryOperations(t *testing.T) {
t.Errorf("Delete node failed: %v", err)
}

// Test TouchAncestors
if err := repos.Nodes.TouchAncestors(ctx, fetchedUser.ID); err != nil {
t.Errorf("TouchAncestors with empty paths failed: %v", err)
}
canceledCtx, cancel := context.WithCancel(ctx)
cancel()
if err := repos.Nodes.TouchAncestors(canceledCtx, fetchedUser.ID, "/folderB/child.txt"); err == nil {
t.Errorf("expected error with canceled context in TouchAncestors, got nil")
}
oldRoot, _ := repos.Nodes.GetByPath(ctx, fetchedUser.ID, "/")
oldFolderB, _ := repos.Nodes.GetByPath(ctx, fetchedUser.ID, "/folderB")
if err := repos.Nodes.TouchAncestors(ctx, fetchedUser.ID, "/folderB/child.txt"); err != nil {
t.Errorf("TouchAncestors failed: %v", err)
}
newRoot, _ := repos.Nodes.GetByPath(ctx, fetchedUser.ID, "/")
newFolderB, _ := repos.Nodes.GetByPath(ctx, fetchedUser.ID, "/folderB")
if newRoot.ETag == oldRoot.ETag {
t.Errorf("expected root ETag to change after TouchAncestors, remained %s", newRoot.ETag)
}
if newFolderB.ETag == oldFolderB.ETag {
t.Errorf("expected /folderB ETag to change after TouchAncestors, remained %s", newFolderB.ETag)
}

// 3. Share Repository with ExpiresAt & ListByUserID
expiresAt := time.Now().Add(24 * time.Hour)
share := &domain.Share{
Expand Down
Loading
Loading