From 60aee6370fb15b12fffc6f29582dd4a235f87d94 Mon Sep 17 00:00:00 2001
From: Gergely Nagy <forgejo@gergo.csillger.hu>
Date: Fri, 5 Jan 2024 10:44:33 +0100
Subject: [PATCH] [GITEA] Find README.md for user profiles case insensitively

When trying to find a `README.md` in a `.profile` repo, do so case
insensitively. This change does not make it possible to render readmes
in formats other than Markdown, it just removes the hard-coded
"README.md".

Also adds a few tests to make sure the change works.

Fixes #1494.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
(cherry picked from commit edd219d8e9d69becb9814ab0a8359555e80fcd4f)
(cherry picked from commit 2c0105ef17b9673e6892a66aa689af7c5c87b8a1)
(cherry picked from commit 3975a9f3aaf8ed3ceb5788abc325dbe8e89225d3)
(cherry picked from commit dee4a18423151ac7f22221e6fce12d863921c200)
---
 modules/git/tree_blob.go               |  21 +++++
 routers/web/shared/user/header.go      |   2 +-
 tests/integration/user_profile_test.go | 120 +++++++++++++++++++++++++
 3 files changed, 142 insertions(+), 1 deletion(-)
 create mode 100644 tests/integration/user_profile_test.go

diff --git a/modules/git/tree_blob.go b/modules/git/tree_blob.go
index 31d9f3d73b..e60c1f915b 100644
--- a/modules/git/tree_blob.go
+++ b/modules/git/tree_blob.go
@@ -1,9 +1,12 @@
 // Copyright 2015 The Gogs Authors. All rights reserved.
 // Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
 // SPDX-License-Identifier: MIT
 
 package git
 
+import "strings"
+
 // GetBlobByPath get the blob object according the path
 func (t *Tree) GetBlobByPath(relpath string) (*Blob, error) {
 	entry, err := t.GetTreeEntryByPath(relpath)
@@ -17,3 +20,21 @@ func (t *Tree) GetBlobByPath(relpath string) (*Blob, error) {
 
 	return nil, ErrNotExist{"", relpath}
 }
+
+// GetBlobByFoldedPath returns the blob object at relpath, regardless of the
+// case of relpath. If there are multiple files with the same case-insensitive
+// name, the first one found will be returned.
+func (t *Tree) GetBlobByFoldedPath(relpath string) (*Blob, error) {
+	entries, err := t.ListEntries()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, entry := range entries {
+		if strings.EqualFold(entry.Name(), relpath) {
+			return t.GetBlobByPath(entry.Name())
+		}
+	}
+
+	return nil, ErrNotExist{"", relpath}
+}
diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go
index a2c0abb47e..f28af550f5 100644
--- a/routers/web/shared/user/header.go
+++ b/routers/web/shared/user/header.go
@@ -97,7 +97,7 @@ func FindUserProfileReadme(ctx *context.Context, doer *user_model.User) (profile
 				if commit, err := profileGitRepo.GetBranchCommit(profileDbRepo.DefaultBranch); err != nil {
 					log.Error("FindUserProfileReadme failed to GetBranchCommit: %v", err)
 				} else {
-					profileReadmeBlob, _ = commit.GetBlobByPath("README.md")
+					profileReadmeBlob, _ = commit.GetBlobByFoldedPath("README.md")
 				}
 			}
 		}
diff --git a/tests/integration/user_profile_test.go b/tests/integration/user_profile_test.go
new file mode 100644
index 0000000000..001e061674
--- /dev/null
+++ b/tests/integration/user_profile_test.go
@@ -0,0 +1,120 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/git"
+	repo_service "code.gitea.io/gitea/services/repository"
+	files_service "code.gitea.io/gitea/services/repository/files"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func createProfileRepo(t *testing.T, readmeName string) func() {
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+	// Create a new repository
+	repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+		Name:          ".profile",
+		DefaultBranch: "main",
+		IsPrivate:     false,
+		AutoInit:      true,
+		Readme:        "Default",
+	})
+	assert.NoError(t, err)
+	assert.NotEmpty(t, repo)
+
+	deleteInitialReadmeResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2,
+		&files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation: "delete",
+					TreePath:  "README.md",
+				},
+			},
+			Message: "Delete the initial readme",
+			Author: &files_service.IdentityOptions{
+				Name:  user2.Name,
+				Email: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				Name:  user2.Name,
+				Email: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+	assert.NoError(t, err)
+	assert.NotEmpty(t, deleteInitialReadmeResp)
+
+	if readmeName != "" {
+		addReadmeResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2,
+			&files_service.ChangeRepoFilesOptions{
+				Files: []*files_service.ChangeRepoFile{
+					{
+						Operation:     "create",
+						TreePath:      readmeName,
+						ContentReader: strings.NewReader("# Hi!\n"),
+					},
+				},
+				Message: "Add a readme",
+				Author: &files_service.IdentityOptions{
+					Name:  user2.Name,
+					Email: user2.Email,
+				},
+				Committer: &files_service.IdentityOptions{
+					Name:  user2.Name,
+					Email: user2.Email,
+				},
+				Dates: &files_service.CommitDateOptions{
+					Author:    time.Now(),
+					Committer: time.Now(),
+				},
+			})
+
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addReadmeResp)
+	}
+
+	return func() {
+		repo_service.DeleteRepository(db.DefaultContext, user2, repo, false)
+	}
+}
+
+func TestUserProfile(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		checkReadme := func(t *testing.T, title, readmeFilename string, expectedCount int) {
+			t.Run(title, func(t *testing.T) {
+				defer tests.PrintCurrentTest(t)()
+				defer createProfileRepo(t, readmeFilename)()
+
+				req := NewRequest(t, "GET", "/user2")
+				resp := MakeRequest(t, req, http.StatusOK)
+
+				doc := NewHTMLParser(t, resp.Body)
+				readmeCount := doc.Find("#readme_profile").Length()
+
+				assert.Equal(t, expectedCount, readmeCount)
+			})
+		}
+
+		checkReadme(t, "No readme", "", 0)
+		checkReadme(t, "README.md", "README.md", 1)
+		checkReadme(t, "readme.md", "readme.md", 1)
+		checkReadme(t, "ReadMe.mD", "ReadMe.mD", 1)
+		checkReadme(t, "readme.org does not render", "README.org", 0)
+	})
+}