diff --git a/internal/api/graph_handler_test.go b/internal/api/graph_handler_test.go index c7a23a6..edda526 100644 --- a/internal/api/graph_handler_test.go +++ b/internal/api/graph_handler_test.go @@ -43,6 +43,9 @@ func (m *mockNodeRepoForGraph) EnsureParentDirectoryExists(ctx context.Context, func (m *mockNodeRepoForGraph) TouchAncestors(ctx context.Context, userID int64, paths ...string) error { return nil } +func (m *mockNodeRepoForGraph) RecalculateDirectorySizes(ctx context.Context, userID int64) error { + return nil +} func TestMeDrivesHandler(t *testing.T) { handler := api.NewGraphHandler(nil, &mockNodeRepoForGraph{}) diff --git a/internal/api/shares_handler_test.go b/internal/api/shares_handler_test.go index b298463..83baba6 100644 --- a/internal/api/shares_handler_test.go +++ b/internal/api/shares_handler_test.go @@ -77,6 +77,9 @@ func (m *mockNodeRepo) EnsureParentDirectoryExists(ctx context.Context, userID i func (m *mockNodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...string) error { return nil } +func (m *mockNodeRepo) RecalculateDirectorySizes(ctx context.Context, userID int64) error { + return nil +} type mockShareRepo struct { shares map[string]*domain.Share diff --git a/internal/db/db.go b/internal/db/db.go index c0b3c2b..53168c9 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -232,6 +232,25 @@ func Migrate(db *sql.DB) error { return nil }, }, + { + version: 5, + description: "Calculate initial sizes for all directory nodes", + run: func(db *sql.DB) error { + query := `UPDATE nodes d + JOIN ( + SELECT d2.id, COALESCE(SUM(f.size), 0) AS total_size + FROM nodes d2 + LEFT JOIN nodes f ON f.user_id = d2.user_id AND f.is_dir = 0 AND ( + CASE WHEN d2.path = '/' THEN 1 ELSE f.path LIKE CONCAT(d2.path, '/%') END + ) + WHERE d2.is_dir = 1 + GROUP BY d2.id + ) s ON d.id = s.id + SET d.size = s.total_size;` + _, err := db.Exec(query) + return err + }, + }, } for _, m := range migrations { diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..53f67b4 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,26 @@ +package db + +import ( + "os" + "testing" +) + +func TestMigrations(t *testing.T) { + dsn := os.Getenv("DB_DSN") + if dsn == "" { + dsn = "selfcloud:selfcloud_pass@tcp(127.0.0.1:3306)/selfcloud?parseTime=true" + } + + sqlDB, err := Connect("mysql", dsn) + if err != nil { + t.Skipf("Skipping db migration test: MariaDB connection failed: %v", err) + return + } + defer sqlDB.Close() + + // Reset migration 5 record so Migrate runs it + _, _ = sqlDB.Exec("DELETE FROM schema_migrations WHERE version = 5") + if err := Migrate(sqlDB); err != nil { + t.Fatalf("Migrate failed: %v", err) + } +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 27559bb..94694ce 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -192,6 +192,7 @@ func (r *Reindexer) ReindexUser(ctx context.Context, user *domain.User, removeOr if !r.dryRun && (stats.Created > 0 || stats.Updated > 0 || stats.Deleted > 0) { _ = r.nodeRepo.TouchAncestors(ctx, user.ID, "/") + _ = r.nodeRepo.RecalculateDirectorySizes(ctx, user.ID) } return stats, nil diff --git a/internal/repository/node_repository.go b/internal/repository/node_repository.go index 372bfe7..73f0a97 100644 --- a/internal/repository/node_repository.go +++ b/internal/repository/node_repository.go @@ -324,9 +324,24 @@ func (r *nodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...st _, _ = rand.Read(b) newETag := hex.EncodeToString(b) + var dirSize int64 + if a == "/" { + const sizeQuery = `SELECT COALESCE(SUM(size), 0) FROM nodes WHERE user_id = ? AND is_dir = 0` + if err := r.db.QueryRowContext(ctx, sizeQuery, userID).Scan(&dirSize); err != nil { + return fmt.Errorf("failed to calculate size for root ancestor: %w", err) + } + } else { + const sizeQuery = `SELECT COALESCE(SUM(size), 0) FROM nodes WHERE user_id = ? AND is_dir = 0 AND path LIKE ?` + likePattern := escapeSQLLike(a) + "/%" + if err := r.db.QueryRowContext(ctx, sizeQuery, userID, likePattern).Scan(&dirSize); err != nil { + return fmt.Errorf("failed to calculate size for ancestor %s: %w", a, err) + } + } + query, args, err := dialect.Update("nodes"). Set(goqu.Record{ "etag": newETag, + "size": dirSize, "mtime": now, "updated_at": now, }). @@ -348,6 +363,24 @@ func (r *nodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...st return nil } +func (r *nodeRepo) RecalculateDirectorySizes(ctx context.Context, userID int64) error { + query := `UPDATE nodes d + JOIN ( + SELECT d2.id, COALESCE(SUM(f.size), 0) AS total_size + FROM nodes d2 + LEFT JOIN nodes f ON f.user_id = d2.user_id AND f.is_dir = 0 AND ( + CASE WHEN d2.path = '/' THEN 1 ELSE f.path LIKE CONCAT(d2.path, '/%') END + ) + WHERE d2.user_id = ? AND d2.is_dir = 1 + GROUP BY d2.id + ) s ON d.id = s.id + SET d.size = s.total_size;` + if _, err := r.db.ExecContext(ctx, query, userID); err != nil { + return fmt.Errorf("failed to recalculate directory sizes for user %d: %w", userID, err) + } + return nil +} + func normalizePath(p string) string { return pathutil.NormalizePath(p) } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 32b1914..ca9230c 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -68,6 +68,7 @@ type NodeRepository interface { 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 + RecalculateDirectorySizes(ctx context.Context, userID int64) error } type ShareRepository interface { diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index c53b925..7482ec8 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -192,6 +192,27 @@ func TestRepositoryOperations(t *testing.T) { if newFolderB.ETag == oldFolderB.ETag { t.Errorf("expected /folderB ETag to change after TouchAncestors, remained %s", newFolderB.ETag) } + if newFolderB.Size != 999 { + t.Errorf("expected /folderB Size to be 999 after TouchAncestors, got %d", newFolderB.Size) + } + if newRoot.Size < 999 { + t.Errorf("expected root Size to be at least 999 after TouchAncestors, got %d", newRoot.Size) + } + + // Test RecalculateDirectorySizes + canceledCtx2, cancel2 := context.WithCancel(ctx) + cancel2() + if err := repos.Nodes.RecalculateDirectorySizes(canceledCtx2, fetchedUser.ID); err == nil { + t.Errorf("expected error with canceled context in RecalculateDirectorySizes, got nil") + } + + if err := repos.Nodes.RecalculateDirectorySizes(ctx, fetchedUser.ID); err != nil { + t.Errorf("RecalculateDirectorySizes failed: %v", err) + } + recalcFolderB, _ := repos.Nodes.GetByPath(ctx, fetchedUser.ID, "/folderB") + if recalcFolderB.Size != 999 { + t.Errorf("expected /folderB Size to be 999 after RecalculateDirectorySizes, got %d", recalcFolderB.Size) + } // 3. Share Repository with ExpiresAt & ListByUserID expiresAt := time.Now().Add(24 * time.Hour) diff --git a/internal/webdav/handler_test.go b/internal/webdav/handler_test.go index f84e455..0f3f305 100644 --- a/internal/webdav/handler_test.go +++ b/internal/webdav/handler_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -143,12 +144,38 @@ func (m *mockNodeRepo) TouchAncestors(ctx context.Context, userID int64, paths . if node, ok := m.nodes[a]; ok && node.IsDir { node.ETag = fmt.Sprintf("touched_%d", time.Now().UnixNano()) node.MTime = time.Now() + var totalSize int64 + for _, child := range m.nodes { + if !child.IsDir { + if a == "/" || strings.HasPrefix(child.Path, a+"/") { + totalSize += child.Size + } + } + } + node.Size = totalSize } } } return nil } +func (m *mockNodeRepo) RecalculateDirectorySizes(ctx context.Context, userID int64) error { + for _, dir := range m.nodes { + if dir.IsDir { + var totalSize int64 + for _, child := range m.nodes { + if !child.IsDir { + if dir.Path == "/" || strings.HasPrefix(child.Path, dir.Path+"/") { + totalSize += child.Size + } + } + } + dir.Size = totalSize + } + } + return nil +} + func TestWebDAVHandlerGetAndRange(t *testing.T) { tempDir, err := os.MkdirTemp("", "webdav_test_*") if err != nil { @@ -353,6 +380,27 @@ func TestWebDAVHandlerGetAndRange(t *testing.T) { } }) + t.Run("PROPFIND on directory returns oc:size matching directory size", func(t *testing.T) { + repo.nodes["/sizedir"] = &domain.Node{ + UserID: 1, Path: "/sizedir", Name: "sizedir", IsDir: true, Size: 12345, + } + req := withUserCtx(httptest.NewRequest("PROPFIND", "/remote.php/webdav/sizedir", nil)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusMultiStatus { + t.Fatalf("expected status 207 Multi-Status, got %d", rec.Code) + } + + body := rec.Body.String() + if !strings.Contains(body, "12345") { + t.Fatalf("expected oc:size to be 12345 for directory, got: %s", body) + } + if strings.Contains(body, "") { + t.Fatalf("expected d:getcontentlength to NOT be present on directory resource, got: %s", body) + } + }) + t.Run("MKCOL creates directory successfully", func(t *testing.T) { req := withUserCtx(httptest.NewRequest("MKCOL", "/remote.php/webdav/testdir", nil)) rec := httptest.NewRecorder() diff --git a/internal/webdav/xml.go b/internal/webdav/xml.go index b208153..05da3ac 100644 --- a/internal/webdav/xml.go +++ b/internal/webdav/xml.go @@ -83,13 +83,14 @@ func FormatMultiStatus(basePath string, nodes []*domain.Node) ([]byte, error) { OCPermissions: permissions, } + size := node.Size + prop.OCSize = &size + if node.IsDir { prop.ResourceType = &ResourceType{Collection: &struct{}{}} } else { prop.ResourceType = &ResourceType{} - size := node.Size prop.GetContentLength = &size - prop.OCSize = &size if node.ContentType != "" { prop.GetContentType = node.ContentType } else { diff --git a/test/functional/api_test.go b/test/functional/api_test.go index c8cbf45..28cda22 100644 --- a/test/functional/api_test.go +++ b/test/functional/api_test.go @@ -820,6 +820,7 @@ func TestOwnCloudSyncClientOAuthWorkflow(t *testing.T) { PropStat struct { Prop struct { GetETag string `xml:"getetag"` + OCSize int64 `xml:"size"` } `xml:"prop"` } `xml:"propstat"` } `xml:"response"` @@ -869,6 +870,10 @@ func TestOwnCloudSyncClientOAuthWorkflow(t *testing.T) { t.Fatalf("expected root ETag to change after PUT file (to notify sync client), before=%q, after=%q", rootETagBefore, rootETagAfter) } + if rootSizeAfter := msAfter.Responses[0].PropStat.Prop.OCSize; rootSizeAfter < int64(len(putContent)) { + t.Fatalf("expected root directory oc:size to reflect uploaded file, got %d, expected at least %d", rootSizeAfter, len(putContent)) + } + // 8d. GET /remote.php/webdav/client_sync_test.txt getReq, _ := http.NewRequest("GET", server.URL+"/remote.php/webdav/client_sync_test.txt", nil) getReq.Header.Set("Authorization", "Bearer "+tokenData.AccessToken) diff --git a/web/src/components/FileList.tsx b/web/src/components/FileList.tsx index 89fdcf2..b9b75e6 100644 --- a/web/src/components/FileList.tsx +++ b/web/src/components/FileList.tsx @@ -215,7 +215,7 @@ export const FileList: React.FC = ({ {/* Size */} - {item.is_dir ? '-' : formatBytes(item.size)} + {item.is_dir ? (item.size > 0 ? formatBytes(item.size) : '-') : formatBytes(item.size)} {/* Actions */}