Make wiki title supports dashes and improve wiki name related features (#24143)

Close #7570


1. Clearly define the wiki path behaviors, see
`services/wiki/wiki_path.go` and tests
2. Keep compatibility with old contents
3. Allow to use dashes in titles, eg: "2000-01-02 Meeting record"
4. Add a "Pages" link in the dropdown, otherwise users can't go to the
Pages page easily.
5. Add a "View original git file" link in the Pages list, even if some
file names are broken, users still have a chance to edit or remove it,
without cloning the wiki repo to local.
6. Fix 500 error when the name contains prefix spaces.


This PR also introduces the ability to support sub-directories, but it
can't be done at the moment due to there are a lot of legacy wiki data,
which use "%2F" in file names.



![image](https://user-images.githubusercontent.com/2114189/232239004-3359d7b9-7bf3-4ff3-8446-bfb0e79645dd.png)


![image](https://user-images.githubusercontent.com/2114189/232239020-74b92c72-bf73-4377-a319-1c85609f82b1.png)

Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
wxiaoguang 2023-04-20 01:50:10 +08:00 committed by GitHub
parent 738f2af527
commit b39a5bbbd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 400 additions and 256 deletions

View File

@ -84,6 +84,9 @@ func (repo *Repository) GetCommitByPath(relpath string) (*Commit, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(commits) == 0 {
return nil, ErrNotExist{ID: relpath}
}
return commits[0], nil return commits[0], nil
} }

View File

@ -1791,6 +1791,7 @@ wiki.reserved_page = The wiki page name "%s" is reserved.
wiki.pages = Pages wiki.pages = Pages
wiki.last_updated = Last updated %s wiki.last_updated = Last updated %s
wiki.page_name_desc = Enter a name for this Wiki page. Some special names are: 'Home', '_Sidebar' and '_Footer'. wiki.page_name_desc = Enter a name for this Wiki page. Some special names are: 'Home', '_Sidebar' and '_Footer'.
wiki.original_git_entry_tooltip = View original Git file instead of using friendly link.
activity = Activity activity = Activity
activity.period.filter_label = Period: activity.period.filter_label = Period:

View File

@ -58,10 +58,10 @@ func NewWikiPage(ctx *context.APIContext) {
return return
} }
wikiName := wiki_service.NormalizeWikiName(form.Title) wikiName := wiki_service.UserTitleToWebPath("", form.Title)
if len(form.Message) == 0 { if len(form.Message) == 0 {
form.Message = fmt.Sprintf("Add '%s'", form.Title) form.Message = fmt.Sprintf("Add %q", form.Title)
} }
content, err := base64.StdEncoding.DecodeString(form.ContentBase64) content, err := base64.StdEncoding.DecodeString(form.ContentBase64)
@ -85,7 +85,7 @@ func NewWikiPage(ctx *context.APIContext) {
wikiPage := getWikiPage(ctx, wikiName) wikiPage := getWikiPage(ctx, wikiName)
if !ctx.Written() { if !ctx.Written() {
notification.NotifyNewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName, form.Message) notification.NotifyNewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message)
ctx.JSON(http.StatusCreated, wikiPage) ctx.JSON(http.StatusCreated, wikiPage)
} }
} }
@ -127,15 +127,15 @@ func EditWikiPage(ctx *context.APIContext) {
form := web.GetForm(ctx).(*api.CreateWikiPageOptions) form := web.GetForm(ctx).(*api.CreateWikiPageOptions)
oldWikiName := wiki_service.NormalizeWikiName(ctx.Params(":pageName")) oldWikiName := wiki_service.WebPathFromRequest(ctx.Params(":pageName"))
newWikiName := wiki_service.NormalizeWikiName(form.Title) newWikiName := wiki_service.UserTitleToWebPath("", form.Title)
if len(newWikiName) == 0 { if len(newWikiName) == 0 {
newWikiName = oldWikiName newWikiName = oldWikiName
} }
if len(form.Message) == 0 { if len(form.Message) == 0 {
form.Message = fmt.Sprintf("Update '%s'", newWikiName) form.Message = fmt.Sprintf("Update %q", newWikiName)
} }
content, err := base64.StdEncoding.DecodeString(form.ContentBase64) content, err := base64.StdEncoding.DecodeString(form.ContentBase64)
@ -153,14 +153,12 @@ func EditWikiPage(ctx *context.APIContext) {
wikiPage := getWikiPage(ctx, newWikiName) wikiPage := getWikiPage(ctx, newWikiName)
if !ctx.Written() { if !ctx.Written() {
notification.NotifyEditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, newWikiName, form.Message) notification.NotifyEditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message)
ctx.JSON(http.StatusOK, wikiPage) ctx.JSON(http.StatusOK, wikiPage)
} }
} }
func getWikiPage(ctx *context.APIContext, title string) *api.WikiPage { func getWikiPage(ctx *context.APIContext, wikiName wiki_service.WebPath) *api.WikiPage {
title = wiki_service.NormalizeWikiName(title)
wikiRepo, commit := findWikiRepoCommit(ctx) wikiRepo, commit := findWikiRepoCommit(ctx)
if wikiRepo != nil { if wikiRepo != nil {
defer wikiRepo.Close() defer wikiRepo.Close()
@ -170,7 +168,7 @@ func getWikiPage(ctx *context.APIContext, title string) *api.WikiPage {
} }
// lookup filename in wiki - get filecontent, real filename // lookup filename in wiki - get filecontent, real filename
content, pageFilename := wikiContentsByName(ctx, commit, title, false) content, pageFilename := wikiContentsByName(ctx, commit, wikiName, false)
if ctx.Written() { if ctx.Written() {
return nil return nil
} }
@ -196,7 +194,7 @@ func getWikiPage(ctx *context.APIContext, title string) *api.WikiPage {
} }
return &api.WikiPage{ return &api.WikiPage{
WikiPageMetaData: convert.ToWikiPageMetaData(title, lastCommit, ctx.Repo.Repository), WikiPageMetaData: convert.ToWikiPageMetaData(wikiName, lastCommit, ctx.Repo.Repository),
ContentBase64: content, ContentBase64: content,
CommitCount: commitsCount, CommitCount: commitsCount,
Sidebar: sidebarContent, Sidebar: sidebarContent,
@ -233,7 +231,7 @@ func DeleteWikiPage(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
wikiName := wiki_service.NormalizeWikiName(ctx.Params(":pageName")) wikiName := wiki_service.WebPathFromRequest(ctx.Params(":pageName"))
if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName); err != nil { if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName); err != nil {
if err.Error() == "file does not exist" { if err.Error() == "file does not exist" {
@ -244,7 +242,7 @@ func DeleteWikiPage(ctx *context.APIContext) {
return return
} }
notification.NotifyDeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName) notification.NotifyDeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName))
ctx.Status(http.StatusNoContent) ctx.Status(http.StatusNoContent)
} }
@ -316,7 +314,7 @@ func ListWikiPages(ctx *context.APIContext) {
ctx.Error(http.StatusInternalServerError, "GetCommit", err) ctx.Error(http.StatusInternalServerError, "GetCommit", err)
return return
} }
wikiName, err := wiki_service.FilenameToName(entry.Name()) wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
if err != nil { if err != nil {
if repo_model.IsErrWikiInvalidFileName(err) { if repo_model.IsErrWikiInvalidFileName(err) {
continue continue
@ -361,7 +359,7 @@ func GetWikiPage(ctx *context.APIContext) {
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
// get requested pagename // get requested pagename
pageName := wiki_service.NormalizeWikiName(ctx.Params(":pageName")) pageName := wiki_service.WebPathFromRequest(ctx.Params(":pageName"))
wikiPage := getWikiPage(ctx, pageName) wikiPage := getWikiPage(ctx, pageName)
if !ctx.Written() { if !ctx.Written() {
@ -411,7 +409,7 @@ func ListPageRevisions(ctx *context.APIContext) {
} }
// get requested pagename // get requested pagename
pageName := wiki_service.NormalizeWikiName(ctx.Params(":pageName")) pageName := wiki_service.WebPathFromRequest(ctx.Params(":pageName"))
if len(pageName) == 0 { if len(pageName) == 0 {
pageName = "Home" pageName = "Home"
} }
@ -502,9 +500,9 @@ func wikiContentsByEntry(ctx *context.APIContext, entry *git.TreeEntry) string {
// wikiContentsByName returns the contents of a wiki page, along with a boolean // wikiContentsByName returns the contents of a wiki page, along with a boolean
// indicating whether the page exists. Writes to ctx if an error occurs. // indicating whether the page exists. Writes to ctx if an error occurs.
func wikiContentsByName(ctx *context.APIContext, commit *git.Commit, wikiName string, isSidebarOrFooter bool) (string, string) { func wikiContentsByName(ctx *context.APIContext, commit *git.Commit, wikiName wiki_service.WebPath, isSidebarOrFooter bool) (string, string) {
pageFilename := wiki_service.NameToFilename(wikiName) gitFilename := wiki_service.WebPathToGitPath(wikiName)
entry, err := findEntryForFile(commit, pageFilename) entry, err := findEntryForFile(commit, gitFilename)
if err != nil { if err != nil {
if git.IsErrNotExist(err) { if git.IsErrNotExist(err) {
if !isSidebarOrFooter { if !isSidebarOrFooter {
@ -515,5 +513,5 @@ func wikiContentsByName(ctx *context.APIContext, commit *git.Commit, wikiName st
} }
return "", "" return "", ""
} }
return wikiContentsByEntry(ctx, entry), pageFilename return wikiContentsByEntry(ctx, entry), gitFilename
} }

View File

@ -70,6 +70,7 @@ func MustEnableWiki(ctx *context.Context) {
type PageMeta struct { type PageMeta struct {
Name string Name string
SubURL string SubURL string
GitEntryName string
UpdatedUnix timeutil.TimeStamp UpdatedUnix timeutil.TimeStamp
} }
@ -83,7 +84,7 @@ func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error)
return entry, nil return entry, nil
} }
// Then the unescaped, shortest alternative // Then the unescaped, the shortest alternative
var unescapedTarget string var unescapedTarget string
if unescapedTarget, err = url.QueryUnescape(target); err != nil { if unescapedTarget, err = url.QueryUnescape(target); err != nil {
return nil, err return nil, err
@ -124,16 +125,16 @@ func wikiContentsByEntry(ctx *context.Context, entry *git.TreeEntry) []byte {
// wikiContentsByName returns the contents of a wiki page, along with a boolean // wikiContentsByName returns the contents of a wiki page, along with a boolean
// indicating whether the page exists. Writes to ctx if an error occurs. // indicating whether the page exists. Writes to ctx if an error occurs.
func wikiContentsByName(ctx *context.Context, commit *git.Commit, wikiName string) ([]byte, *git.TreeEntry, string, bool) { func wikiContentsByName(ctx *context.Context, commit *git.Commit, wikiName wiki_service.WebPath) ([]byte, *git.TreeEntry, string, bool) {
pageFilename := wiki_service.NameToFilename(wikiName) gitFilename := wiki_service.WebPathToGitPath(wikiName)
entry, err := findEntryForFile(commit, pageFilename) entry, err := findEntryForFile(commit, gitFilename)
if err != nil && !git.IsErrNotExist(err) { if err != nil && !git.IsErrNotExist(err) {
ctx.ServerError("findEntryForFile", err) ctx.ServerError("findEntryForFile", err)
return nil, nil, "", false return nil, nil, "", false
} else if entry == nil { } else if entry == nil {
return nil, nil, "", true return nil, nil, "", true
} }
return wikiContentsByEntry(ctx, entry), entry, pageFilename, false return wikiContentsByEntry(ctx, entry), entry, gitFilename, false
} }
func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
@ -162,7 +163,7 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
if !entry.IsRegular() { if !entry.IsRegular() {
continue continue
} }
wikiName, err := wiki_service.FilenameToName(entry.Name()) wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
if err != nil { if err != nil {
if repo_model.IsErrWikiInvalidFileName(err) { if repo_model.IsErrWikiInvalidFileName(err) {
continue continue
@ -175,22 +176,26 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
} else if wikiName == "_Sidebar" || wikiName == "_Footer" { } else if wikiName == "_Sidebar" || wikiName == "_Footer" {
continue continue
} }
_, displayName := wiki_service.WebPathToUserTitle(wikiName)
pages = append(pages, PageMeta{ pages = append(pages, PageMeta{
Name: wikiName, Name: displayName,
SubURL: wiki_service.NameToSubURL(wikiName), SubURL: wiki_service.WebPathToURLPath(wikiName),
GitEntryName: entry.Name(),
}) })
} }
ctx.Data["Pages"] = pages ctx.Data["Pages"] = pages
// get requested pagename // get requested page name
pageName := wiki_service.NormalizeWikiName(ctx.Params("*")) pageName := wiki_service.WebPathFromRequest(ctx.Params("*"))
if len(pageName) == 0 { if len(pageName) == 0 {
pageName = "Home" pageName = "Home"
} }
ctx.Data["PageURL"] = wiki_service.NameToSubURL(pageName)
ctx.Data["old_title"] = pageName _, displayName := wiki_service.WebPathToUserTitle(pageName)
ctx.Data["Title"] = pageName ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName)
ctx.Data["title"] = pageName ctx.Data["old_title"] = displayName
ctx.Data["Title"] = displayName
ctx.Data["title"] = displayName
isSideBar := pageName == "_Sidebar" isSideBar := pageName == "_Sidebar"
isFooter := pageName == "_Footer" isFooter := pageName == "_Footer"
@ -328,14 +333,17 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry)
} }
// get requested pagename // get requested pagename
pageName := wiki_service.NormalizeWikiName(ctx.Params("*")) pageName := wiki_service.WebPathFromRequest(ctx.Params("*"))
if len(pageName) == 0 { if len(pageName) == 0 {
pageName = "Home" pageName = "Home"
} }
ctx.Data["PageURL"] = wiki_service.NameToSubURL(pageName)
ctx.Data["old_title"] = pageName _, displayName := wiki_service.WebPathToUserTitle(pageName)
ctx.Data["Title"] = pageName ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName)
ctx.Data["title"] = pageName ctx.Data["old_title"] = displayName
ctx.Data["Title"] = displayName
ctx.Data["title"] = displayName
ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name
@ -403,14 +411,16 @@ func renderEditPage(ctx *context.Context) {
}() }()
// get requested pagename // get requested pagename
pageName := wiki_service.NormalizeWikiName(ctx.Params("*")) pageName := wiki_service.WebPathFromRequest(ctx.Params("*"))
if len(pageName) == 0 { if len(pageName) == 0 {
pageName = "Home" pageName = "Home"
} }
ctx.Data["PageURL"] = wiki_service.NameToSubURL(pageName)
ctx.Data["old_title"] = pageName _, displayName := wiki_service.WebPathToUserTitle(pageName)
ctx.Data["Title"] = pageName ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName)
ctx.Data["title"] = pageName ctx.Data["old_title"] = displayName
ctx.Data["Title"] = displayName
ctx.Data["title"] = displayName
// lookup filename in wiki - get filecontent, gitTree entry , real filename // lookup filename in wiki - get filecontent, gitTree entry , real filename
data, entry, _, noEntry := wikiContentsByName(ctx, commit, pageName) data, entry, _, noEntry := wikiContentsByName(ctx, commit, pageName)
@ -594,7 +604,7 @@ func WikiPages(ctx *context.Context) {
ctx.ServerError("GetCommit", err) ctx.ServerError("GetCommit", err)
return return
} }
wikiName, err := wiki_service.FilenameToName(entry.Name()) wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
if err != nil { if err != nil {
if repo_model.IsErrWikiInvalidFileName(err) { if repo_model.IsErrWikiInvalidFileName(err) {
continue continue
@ -602,9 +612,11 @@ func WikiPages(ctx *context.Context) {
ctx.ServerError("WikiFilenameToName", err) ctx.ServerError("WikiFilenameToName", err)
return return
} }
_, displayName := wiki_service.WebPathToUserTitle(wikiName)
pages = append(pages, PageMeta{ pages = append(pages, PageMeta{
Name: wikiName, Name: displayName,
SubURL: wiki_service.NameToSubURL(wikiName), SubURL: wiki_service.WebPathToURLPath(wikiName),
GitEntryName: entry.Name(),
UpdatedUnix: timeutil.TimeStamp(c.Author.When.Unix()), UpdatedUnix: timeutil.TimeStamp(c.Author.When.Unix()),
}) })
} }
@ -631,12 +643,12 @@ func WikiRaw(ctx *context.Context) {
return return
} }
providedPath := ctx.Params("*") providedWebPath := wiki_service.WebPathFromRequest(ctx.Params("*"))
providedGitPath := wiki_service.WebPathToGitPath(providedWebPath)
var entry *git.TreeEntry var entry *git.TreeEntry
if commit != nil { if commit != nil {
// Try to find a file with that name // Try to find a file with that name
entry, err = findEntryForFile(commit, providedPath) entry, err = findEntryForFile(commit, providedGitPath)
if err != nil && !git.IsErrNotExist(err) { if err != nil && !git.IsErrNotExist(err) {
ctx.ServerError("findFile", err) ctx.ServerError("findFile", err)
return return
@ -644,10 +656,8 @@ func WikiRaw(ctx *context.Context) {
if entry == nil { if entry == nil {
// Try to find a wiki page with that name // Try to find a wiki page with that name
providedPath = strings.TrimSuffix(providedPath, ".md") providedGitPath = strings.TrimSuffix(providedGitPath, ".md")
entry, err = findEntryForFile(commit, providedGitPath)
wikiPath := wiki_service.NameToFilename(providedPath)
entry, err = findEntryForFile(commit, wikiPath)
if err != nil && !git.IsErrNotExist(err) { if err != nil && !git.IsErrNotExist(err) {
ctx.ServerError("findFile", err) ctx.ServerError("findFile", err)
return return
@ -694,7 +704,7 @@ func NewWikiPost(ctx *context.Context) {
return return
} }
wikiName := wiki_service.NormalizeWikiName(form.Title) wikiName := wiki_service.UserTitleToWebPath("", form.Title)
if len(form.Message) == 0 { if len(form.Message) == 0 {
form.Message = ctx.Tr("repo.editor.add", form.Title) form.Message = ctx.Tr("repo.editor.add", form.Title)
@ -713,9 +723,9 @@ func NewWikiPost(ctx *context.Context) {
return return
} }
notification.NotifyNewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName, form.Message) notification.NotifyNewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message)
ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.NameToSubURL(wikiName)) ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(wikiName))
} }
// EditWiki render wiki modify page // EditWiki render wiki modify page
@ -745,8 +755,8 @@ func EditWikiPost(ctx *context.Context) {
return return
} }
oldWikiName := wiki_service.NormalizeWikiName(ctx.Params("*")) oldWikiName := wiki_service.WebPathFromRequest(ctx.Params("*"))
newWikiName := wiki_service.NormalizeWikiName(form.Title) newWikiName := wiki_service.UserTitleToWebPath("", form.Title)
if len(form.Message) == 0 { if len(form.Message) == 0 {
form.Message = ctx.Tr("repo.editor.update", form.Title) form.Message = ctx.Tr("repo.editor.update", form.Title)
@ -757,14 +767,14 @@ func EditWikiPost(ctx *context.Context) {
return return
} }
notification.NotifyEditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, newWikiName, form.Message) notification.NotifyEditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message)
ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.NameToSubURL(newWikiName)) ctx.Redirect(ctx.Repo.RepoLink + "/wiki/" + wiki_service.WebPathToURLPath(newWikiName))
} }
// DeleteWikiPagePost delete wiki page // DeleteWikiPagePost delete wiki page
func DeleteWikiPagePost(ctx *context.Context) { func DeleteWikiPagePost(ctx *context.Context) {
wikiName := wiki_service.NormalizeWikiName(ctx.Params("*")) wikiName := wiki_service.WebPathFromRequest(ctx.Params("*"))
if len(wikiName) == 0 { if len(wikiName) == 0 {
wikiName = "Home" wikiName = "Home"
} }
@ -774,7 +784,7 @@ func DeleteWikiPagePost(ctx *context.Context) {
return return
} }
notification.NotifyDeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName) notification.NotifyDeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName))
ctx.JSON(http.StatusOK, map[string]interface{}{ ctx.JSON(http.StatusOK, map[string]interface{}{
"redirect": ctx.Repo.RepoLink + "/wiki/", "redirect": ctx.Repo.RepoLink + "/wiki/",

View File

@ -6,6 +6,7 @@ package repo
import ( import (
"io" "io"
"net/http" "net/http"
"net/url"
"testing" "testing"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
@ -24,7 +25,7 @@ const (
message = "Wiki commit message for unit tests" message = "Wiki commit message for unit tests"
) )
func wikiEntry(t *testing.T, repo *repo_model.Repository, wikiName string) *git.TreeEntry { func wikiEntry(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) *git.TreeEntry {
wikiRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath()) wikiRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath())
assert.NoError(t, err) assert.NoError(t, err)
defer wikiRepo.Close() defer wikiRepo.Close()
@ -33,14 +34,14 @@ func wikiEntry(t *testing.T, repo *repo_model.Repository, wikiName string) *git.
entries, err := commit.ListEntries() entries, err := commit.ListEntries()
assert.NoError(t, err) assert.NoError(t, err)
for _, entry := range entries { for _, entry := range entries {
if entry.Name() == wiki_service.NameToFilename(wikiName) { if entry.Name() == wiki_service.WebPathToGitPath(wikiName) {
return entry return entry
} }
} }
return nil return nil
} }
func wikiContent(t *testing.T, repo *repo_model.Repository, wikiName string) string { func wikiContent(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) string {
entry := wikiEntry(t, repo, wikiName) entry := wikiEntry(t, repo, wikiName)
if !assert.NotNil(t, entry) { if !assert.NotNil(t, entry) {
return "" return ""
@ -53,11 +54,11 @@ func wikiContent(t *testing.T, repo *repo_model.Repository, wikiName string) str
return string(bytes) return string(bytes)
} }
func assertWikiExists(t *testing.T, repo *repo_model.Repository, wikiName string) { func assertWikiExists(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) {
assert.NotNil(t, wikiEntry(t, repo, wikiName)) assert.NotNil(t, wikiEntry(t, repo, wikiName))
} }
func assertWikiNotExists(t *testing.T, repo *repo_model.Repository, wikiName string) { func assertWikiNotExists(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) {
assert.Nil(t, wikiEntry(t, repo, wikiName)) assert.Nil(t, wikiEntry(t, repo, wikiName))
} }
@ -124,8 +125,8 @@ func TestNewWikiPost(t *testing.T) {
}) })
NewWikiPost(ctx) NewWikiPost(ctx)
assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
assertWikiExists(t, ctx.Repo.Repository, title) assertWikiExists(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title))
assert.Equal(t, wikiContent(t, ctx.Repo.Repository, title), content) assert.Equal(t, wikiContent(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title)), content)
} }
} }
@ -176,8 +177,8 @@ func TestEditWikiPost(t *testing.T) {
}) })
EditWikiPost(ctx) EditWikiPost(ctx)
assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status())
assertWikiExists(t, ctx.Repo.Repository, title) assertWikiExists(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title))
assert.Equal(t, wikiContent(t, ctx.Repo.Repository, title), content) assert.Equal(t, wikiContent(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title)), content)
if title != "Home" { if title != "Home" {
assertWikiNotExists(t, ctx.Repo.Repository, "Home") assertWikiNotExists(t, ctx.Repo.Repository, "Home")
} }
@ -201,17 +202,21 @@ func TestWikiRaw(t *testing.T) {
"images/jpeg.jpg": "image/jpeg", "images/jpeg.jpg": "image/jpeg",
"Page With Spaced Name": "text/plain; charset=utf-8", "Page With Spaced Name": "text/plain; charset=utf-8",
"Page-With-Spaced-Name": "text/plain; charset=utf-8", "Page-With-Spaced-Name": "text/plain; charset=utf-8",
"Page With Spaced Name.md": "text/plain; charset=utf-8", "Page With Spaced Name.md": "", // there is no "Page With Spaced Name.md" in repo
"Page-With-Spaced-Name.md": "text/plain; charset=utf-8", "Page-With-Spaced-Name.md": "text/plain; charset=utf-8",
} { } {
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
ctx := test.MockContext(t, "user2/repo1/wiki/raw/"+filepath) ctx := test.MockContext(t, "user2/repo1/wiki/raw/"+url.PathEscape(filepath))
ctx.SetParams("*", filepath) ctx.SetParams("*", filepath)
test.LoadUser(t, ctx, 2) test.LoadUser(t, ctx, 2)
test.LoadRepo(t, ctx, 1) test.LoadRepo(t, ctx, 1)
WikiRaw(ctx) WikiRaw(ctx)
assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) if filetype == "" {
assert.EqualValues(t, filetype, ctx.Resp.Header().Get("Content-Type")) assert.EqualValues(t, http.StatusNotFound, ctx.Resp.Status(), "filepath: %s", filepath)
} else {
assert.EqualValues(t, http.StatusOK, ctx.Resp.Status(), "filepath: %s", filepath)
assert.EqualValues(t, filetype, ctx.Resp.Header().Get("Content-Type"), "filepath: %s", filepath)
}
} }
} }

View File

@ -48,12 +48,13 @@ func ToWikiCommitList(commits []*git.Commit, total int64) *api.WikiCommitList {
} }
// ToWikiPageMetaData converts meta information to a WikiPageMetaData // ToWikiPageMetaData converts meta information to a WikiPageMetaData
func ToWikiPageMetaData(title string, lastCommit *git.Commit, repo *repo_model.Repository) *api.WikiPageMetaData { func ToWikiPageMetaData(wikiName wiki_service.WebPath, lastCommit *git.Commit, repo *repo_model.Repository) *api.WikiPageMetaData {
suburl := wiki_service.NameToSubURL(title) subURL := string(wikiName)
_, title := wiki_service.WebPathToUserTitle(wikiName)
return &api.WikiPageMetaData{ return &api.WikiPageMetaData{
Title: title, Title: title,
HTMLURL: util.URLJoin(repo.HTMLURL(), "wiki", suburl), HTMLURL: util.URLJoin(repo.HTMLURL(), "wiki", subURL),
SubURL: suburl, SubURL: subURL,
LastCommit: ToWikiCommit(lastCommit), LastCommit: ToWikiCommit(lastCommit),
} }
} }

View File

@ -7,7 +7,6 @@ package wiki
import ( import (
"context" "context"
"fmt" "fmt"
"net/url"
"os" "os"
"strings" "strings"
@ -19,61 +18,17 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/sync" "code.gitea.io/gitea/modules/sync"
"code.gitea.io/gitea/modules/util"
asymkey_service "code.gitea.io/gitea/services/asymkey" asymkey_service "code.gitea.io/gitea/services/asymkey"
) )
var ( // TODO: use clustered lock (unique queue? or *abuse* cache)
reservedWikiNames = []string{"_pages", "_new", "_edit", "raw"} var wikiWorkingPool = sync.NewExclusivePool()
// TODO: use clustered lock (unique queue? or *abuse* cache)
wikiWorkingPool = sync.NewExclusivePool()
)
const ( const (
DefaultRemote = "origin" DefaultRemote = "origin"
DefaultBranch = "master" DefaultBranch = "master"
) )
func nameAllowed(name string) error {
if util.SliceContainsString(reservedWikiNames, name) {
return repo_model.ErrWikiReservedName{
Title: name,
}
}
return nil
}
// NameToSubURL converts a wiki name to its corresponding sub-URL.
func NameToSubURL(name string) string {
return url.PathEscape(strings.ReplaceAll(name, " ", "-"))
}
// NormalizeWikiName normalizes a wiki name
func NormalizeWikiName(name string) string {
return strings.ReplaceAll(name, "-", " ")
}
// NameToFilename converts a wiki name to its corresponding filename.
func NameToFilename(name string) string {
name = strings.ReplaceAll(name, " ", "-")
return url.QueryEscape(name) + ".md"
}
// FilenameToName converts a wiki filename to its corresponding page name.
func FilenameToName(filename string) (string, error) {
if !strings.HasSuffix(filename, ".md") {
return "", repo_model.ErrWikiInvalidFileName{
FileName: filename,
}
}
basename := filename[:len(filename)-3]
unescaped, err := url.QueryUnescape(basename)
if err != nil {
return "", err
}
return NormalizeWikiName(unescaped), nil
}
// InitWiki initializes a wiki for repository, // InitWiki initializes a wiki for repository,
// it does nothing when repository already has wiki. // it does nothing when repository already has wiki.
func InitWiki(ctx context.Context, repo *repo_model.Repository) error { func InitWiki(ctx context.Context, repo *repo_model.Repository) error {
@ -91,20 +46,20 @@ func InitWiki(ctx context.Context, repo *repo_model.Repository) error {
return nil return nil
} }
// prepareWikiFileName try to find a suitable file path with file name by the given raw wiki name. // prepareGitPath try to find a suitable file path with file name by the given raw wiki name.
// return: existence, prepared file path with name, error // return: existence, prepared file path with name, error
func prepareWikiFileName(gitRepo *git.Repository, wikiName string) (bool, string, error) { func prepareGitPath(gitRepo *git.Repository, wikiPath WebPath) (bool, string, error) {
unescaped := wikiName + ".md" unescaped := string(wikiPath) + ".md"
escaped := NameToFilename(wikiName) gitPath := WebPathToGitPath(wikiPath)
// Look for both files // Look for both files
filesInIndex, err := gitRepo.LsTree(DefaultBranch, unescaped, escaped) filesInIndex, err := gitRepo.LsTree(DefaultBranch, unescaped, gitPath)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "Not a valid object name master") { if strings.Contains(err.Error(), "Not a valid object name master") {
return false, escaped, nil return false, gitPath, nil
} }
log.Error("%v", err) log.Error("%v", err)
return false, escaped, err return false, gitPath, err
} }
foundEscaped := false foundEscaped := false
@ -113,18 +68,18 @@ func prepareWikiFileName(gitRepo *git.Repository, wikiName string) (bool, string
case unescaped: case unescaped:
// if we find the unescaped file return it // if we find the unescaped file return it
return true, unescaped, nil return true, unescaped, nil
case escaped: case gitPath:
foundEscaped = true foundEscaped = true
} }
} }
// If not return whether the escaped file exists, and the escaped filename to keep backwards compatibility. // If not return whether the escaped file exists, and the escaped filename to keep backwards compatibility.
return foundEscaped, escaped, nil return foundEscaped, gitPath, nil
} }
// updateWikiPage adds a new page or edits an existing page in repository wiki. // updateWikiPage adds a new page or edits an existing page in repository wiki.
func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldWikiName, newWikiName, content, message string, isNew bool) (err error) { func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldWikiName, newWikiName WebPath, content, message string, isNew bool) (err error) {
if err = nameAllowed(newWikiName); err != nil { if err = validateWebPath(newWikiName); err != nil {
return err return err
} }
wikiWorkingPool.CheckIn(fmt.Sprint(repo.ID)) wikiWorkingPool.CheckIn(fmt.Sprint(repo.ID))
@ -157,24 +112,24 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
if err := git.Clone(ctx, repo.WikiPath(), basePath, cloneOpts); err != nil { if err := git.Clone(ctx, repo.WikiPath(), basePath, cloneOpts); err != nil {
log.Error("Failed to clone repository: %s (%v)", repo.FullName(), err) log.Error("Failed to clone repository: %s (%v)", repo.FullName(), err)
return fmt.Errorf("Failed to clone repository: %s (%w)", repo.FullName(), err) return fmt.Errorf("failed to clone repository: %s (%w)", repo.FullName(), err)
} }
gitRepo, err := git.OpenRepository(ctx, basePath) gitRepo, err := git.OpenRepository(ctx, basePath)
if err != nil { if err != nil {
log.Error("Unable to open temporary repository: %s (%v)", basePath, err) log.Error("Unable to open temporary repository: %s (%v)", basePath, err)
return fmt.Errorf("Failed to open new temporary repository in: %s %w", basePath, err) return fmt.Errorf("failed to open new temporary repository in: %s %w", basePath, err)
} }
defer gitRepo.Close() defer gitRepo.Close()
if hasMasterBranch { if hasMasterBranch {
if err := gitRepo.ReadTreeToIndex("HEAD"); err != nil { if err := gitRepo.ReadTreeToIndex("HEAD"); err != nil {
log.Error("Unable to read HEAD tree to index in: %s %v", basePath, err) log.Error("Unable to read HEAD tree to index in: %s %v", basePath, err)
return fmt.Errorf("Unable to read HEAD tree to index in: %s %w", basePath, err) return fmt.Errorf("fnable to read HEAD tree to index in: %s %w", basePath, err)
} }
} }
isWikiExist, newWikiPath, err := prepareWikiFileName(gitRepo, newWikiName) isWikiExist, newWikiPath, err := prepareGitPath(gitRepo, newWikiName)
if err != nil { if err != nil {
return err return err
} }
@ -190,7 +145,7 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
isOldWikiExist := true isOldWikiExist := true
oldWikiPath := newWikiPath oldWikiPath := newWikiPath
if oldWikiName != newWikiName { if oldWikiName != newWikiName {
isOldWikiExist, oldWikiPath, err = prepareWikiFileName(gitRepo, oldWikiName) isOldWikiExist, oldWikiPath, err = prepareGitPath(gitRepo, oldWikiName)
if err != nil { if err != nil {
return err return err
} }
@ -271,18 +226,18 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
} }
// AddWikiPage adds a new wiki page with a given wikiPath. // AddWikiPage adds a new wiki page with a given wikiPath.
func AddWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, wikiName, content, message string) error { func AddWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, wikiName WebPath, content, message string) error {
return updateWikiPage(ctx, doer, repo, "", wikiName, content, message, true) return updateWikiPage(ctx, doer, repo, "", wikiName, content, message, true)
} }
// EditWikiPage updates a wiki page identified by its wikiPath, // EditWikiPage updates a wiki page identified by its wikiPath,
// optionally also changing wikiPath. // optionally also changing wikiPath.
func EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldWikiName, newWikiName, content, message string) error { func EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldWikiName, newWikiName WebPath, content, message string) error {
return updateWikiPage(ctx, doer, repo, oldWikiName, newWikiName, content, message, false) return updateWikiPage(ctx, doer, repo, oldWikiName, newWikiName, content, message, false)
} }
// DeleteWikiPage deletes a wiki page identified by its path. // DeleteWikiPage deletes a wiki page identified by its path.
func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, wikiName string) (err error) { func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, wikiName WebPath) (err error) {
wikiWorkingPool.CheckIn(fmt.Sprint(repo.ID)) wikiWorkingPool.CheckIn(fmt.Sprint(repo.ID))
defer wikiWorkingPool.CheckOut(fmt.Sprint(repo.ID)) defer wikiWorkingPool.CheckOut(fmt.Sprint(repo.ID))
@ -306,22 +261,22 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
Branch: DefaultBranch, Branch: DefaultBranch,
}); err != nil { }); err != nil {
log.Error("Failed to clone repository: %s (%v)", repo.FullName(), err) log.Error("Failed to clone repository: %s (%v)", repo.FullName(), err)
return fmt.Errorf("Failed to clone repository: %s (%w)", repo.FullName(), err) return fmt.Errorf("failed to clone repository: %s (%w)", repo.FullName(), err)
} }
gitRepo, err := git.OpenRepository(ctx, basePath) gitRepo, err := git.OpenRepository(ctx, basePath)
if err != nil { if err != nil {
log.Error("Unable to open temporary repository: %s (%v)", basePath, err) log.Error("Unable to open temporary repository: %s (%v)", basePath, err)
return fmt.Errorf("Failed to open new temporary repository in: %s %w", basePath, err) return fmt.Errorf("failed to open new temporary repository in: %s %w", basePath, err)
} }
defer gitRepo.Close() defer gitRepo.Close()
if err := gitRepo.ReadTreeToIndex("HEAD"); err != nil { if err := gitRepo.ReadTreeToIndex("HEAD"); err != nil {
log.Error("Unable to read HEAD tree to index in: %s %v", basePath, err) log.Error("Unable to read HEAD tree to index in: %s %v", basePath, err)
return fmt.Errorf("Unable to read HEAD tree to index in: %s %w", basePath, err) return fmt.Errorf("unable to read HEAD tree to index in: %s %w", basePath, err)
} }
found, wikiPath, err := prepareWikiFileName(gitRepo, wikiName) found, wikiPath, err := prepareGitPath(gitRepo, wikiName)
if err != nil { if err != nil {
return err return err
} }
@ -340,7 +295,7 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
if err != nil { if err != nil {
return err return err
} }
message := "Delete page '" + wikiName + "'" message := fmt.Sprintf("Delete page %q", wikiName)
commitTreeOpts := git.CommitTreeOpts{ commitTreeOpts := git.CommitTreeOpts{
Message: message, Message: message,
Parents: []string{"HEAD"}, Parents: []string{"HEAD"},

153
services/wiki/wiki_path.go Normal file
View File

@ -0,0 +1,153 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package wiki
import (
"net/url"
"path"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/util"
)
// To define the wiki related concepts:
// * Display Segment: the text what user see for a wiki page (aka, the title):
// - "Home Page"
// - "100% Free"
// - "2000-01-02 meeting"
// * Web Path:
// - "/wiki/Home-Page"
// - "/wiki/100%25+Free"
// - "/wiki/2000-01-02+meeting.-"
// - If a segment has a suffix "DashMarker(.-)", it means that there is no dash-space conversion for this segment.
// - If a WebPath is a "*.md" pattern, then use it directly as GitPath, to make users can access the raw file.
// * Git Path (only space doesn't need to be escaped):
// - "/.wiki.git/Home-Page.md"
// - "/.wiki.git/100%25 Free.md"
// - "/.wiki.git/2000-01-02 meeting.-.md"
// TODO: support subdirectory in the future
//
// Although this package now has the ablity to support subdirectory, but the route package doesn't:
// * Double-escaping problem: the URL "/wiki/abc%2Fdef" becomes "/wiki/abc/def" by ctx.Params, which is incorrect
// * The old wiki code's behavior is always using %2F, instead of subdirectory, so there are a lot of legacy "%2F" files in user wikis.
type WebPath string
var reservedWikiNames = []string{"_pages", "_new", "_edit", "raw"}
func validateWebPath(name WebPath) error {
for _, s := range WebPathSegments(name) {
if util.SliceContainsString(reservedWikiNames, s) {
return repo_model.ErrWikiReservedName{Title: s}
}
}
return nil
}
func hasDashMarker(s string) bool {
return strings.HasSuffix(s, ".-")
}
func removeDashMarker(s string) string {
return strings.TrimSuffix(s, ".-")
}
func addDashMarker(s string) string {
return s + ".-"
}
func unescapeSegment(s string) (string, error) {
if hasDashMarker(s) {
s = removeDashMarker(s)
} else {
s = strings.ReplaceAll(s, "-", " ")
}
unescaped, err := url.QueryUnescape(s)
if err != nil {
return s, err // un-escaping failed, but it's still safe to return the original string, because it is only a title for end users
}
return unescaped, nil
}
func escapeSegToWeb(s string, hadDashMarker bool) string {
if hadDashMarker || strings.Contains(s, "-") {
s = addDashMarker(s)
} else {
s = strings.ReplaceAll(s, " ", "-")
}
s = url.QueryEscape(s)
return s
}
func WebPathSegments(s WebPath) []string {
a := strings.Split(string(s), "/")
for i := range a {
a[i], _ = unescapeSegment(a[i])
}
return a
}
func WebPathToGitPath(s WebPath) string {
if strings.HasSuffix(string(s), ".md") {
return string(s)
}
a := strings.Split(string(s), "/")
for i := range a {
shouldAddDashMarker := hasDashMarker(a[i])
a[i], _ = unescapeSegment(a[i])
a[i] = escapeSegToWeb(a[i], shouldAddDashMarker)
a[i] = strings.ReplaceAll(a[i], "%20", " ") // space is safe to be kept in git path
a[i] = strings.ReplaceAll(a[i], "+", " ")
}
return strings.Join(a, "/") + ".md"
}
func GitPathToWebPath(s string) (wp WebPath, err error) {
if !strings.HasSuffix(s, ".md") {
return "", repo_model.ErrWikiInvalidFileName{FileName: s}
}
s = strings.TrimSuffix(s, ".md")
a := strings.Split(s, "/")
for i := range a {
shouldAddDashMarker := hasDashMarker(a[i])
if a[i], err = unescapeSegment(a[i]); err != nil {
return "", err
}
a[i] = escapeSegToWeb(a[i], shouldAddDashMarker)
}
return WebPath(strings.Join(a, "/")), nil
}
func WebPathToUserTitle(s WebPath) (dir, display string) {
dir = path.Dir(string(s))
display = path.Base(string(s))
display = strings.TrimSuffix(display, ".md")
display, _ = unescapeSegment(display)
return dir, display
}
func WebPathToURLPath(s WebPath) string {
return string(s)
}
func WebPathFromRequest(s string) WebPath {
s = util.PathJoinRelX(s)
// The old wiki code's behavior is always using %2F, instead of subdirectory.
s = strings.ReplaceAll(s, "/", "%2F")
return WebPath(s)
}
func UserTitleToWebPath(base, title string) WebPath {
// TODO: ctx.Params does un-escaping, so the URL "/wiki/abc%2Fdef" becomes "wiki path = `abc/def`", which is incorrect.
// And the old wiki code's behavior is always using %2F, instead of subdirectory.
// So we do not add the support for writing slashes in title at the moment.
title = strings.TrimSpace(title)
title = util.PathJoinRelX(base, escapeSegToWeb(title, false))
if title == "" || title == "." {
title = "unnamed"
}
return WebPath(title)
}

View File

@ -4,7 +4,9 @@
package wiki package wiki
import ( import (
"math/rand"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
@ -21,91 +23,113 @@ func TestMain(m *testing.M) {
}) })
} }
func TestWikiNameToSubURL(t *testing.T) { func TestWebPathSegments(t *testing.T) {
a := WebPathSegments("a%2Fa/b+c/d-e/f-g.-")
assert.EqualValues(t, []string{"a/a", "b c", "d e", "f-g"}, a)
}
func TestUserTitleToWebPath(t *testing.T) {
type test struct { type test struct {
Expected string Expected string
WikiName string UserTitle string
} }
for _, test := range []test{ for _, test := range []test{
{"wiki-name", "wiki name"}, {"wiki-name", "wiki name"},
{"wiki-name", "wiki-name"}, {"wiki-name.-", "wiki-name"},
{"name-with%2Fslash", "name with/slash"}, {"the+wiki-name.-", "the wiki-name"},
{"name-with%25percent", "name with%percent"}, {"a%2Fb", "a/b"},
{"a%25b", "a%b"},
} { } {
assert.Equal(t, test.Expected, NameToSubURL(test.WikiName)) assert.EqualValues(t, test.Expected, UserTitleToWebPath("", test.UserTitle))
} }
} }
func TestNormalizeWikiName(t *testing.T) { func TestWebPathToDisplayName(t *testing.T) {
type test struct { type test struct {
Expected string Expected string
WikiName string WebPath WebPath
} }
for _, test := range []test{ for _, test := range []test{
{"wiki name", "wiki name"},
{"wiki name", "wiki-name"}, {"wiki name", "wiki-name"},
{"name with/slash", "name with/slash"}, {"wiki-name", "wiki-name.-"},
{"name with%percent", "name-with%percent"}, {"name with / slash", "name-with %2F slash"},
{"%2F", "%2F"}, {"name with % percent", "name-with %25 percent"},
{"2000-01-02 meeting", "2000-01-02+meeting.-.md"},
} { } {
assert.Equal(t, test.Expected, NormalizeWikiName(test.WikiName)) _, displayName := WebPathToUserTitle(test.WebPath)
assert.EqualValues(t, test.Expected, displayName)
} }
} }
func TestWikiNameToFilename(t *testing.T) { func TestWebPathToGitPath(t *testing.T) {
type test struct { type test struct {
Expected string Expected string
WikiName string WikiName WebPath
} }
for _, test := range []test{ for _, test := range []test{
{"wiki-name.md", "wiki name"}, {"wiki-name.md", "wiki%20name"},
{"wiki-name.md", "wiki-name"}, {"wiki-name.md", "wiki+name"},
{"name-with%2Fslash.md", "name with/slash"}, {"wiki%20name.md", "wiki%20name.md"},
{"name-with%25percent.md", "name with%percent"}, {"2000-01-02-meeting.md", "2000-01-02+meeting"},
{"2000-01-02 meeting.-.md", "2000-01-02%20meeting.-"},
} { } {
assert.Equal(t, test.Expected, NameToFilename(test.WikiName)) assert.EqualValues(t, test.Expected, WebPathToGitPath(test.WikiName))
} }
} }
func TestWikiFilenameToName(t *testing.T) { func TestGitPathToWebPath(t *testing.T) {
type test struct { type test struct {
Expected string Expected string
Filename string Filename string
} }
for _, test := range []test{ for _, test := range []test{
{"hello world", "hello-world.md"}, {"hello-world", "hello-world.md"}, // this shouldn't happen, because it should always have a ".-" suffix
{"symbols/?*", "symbols%2F%3F%2A.md"}, {"hello-world", "hello world.md"},
{"hello-world.-", "hello-world.-.md"},
{"hello+world.-", "hello world.-.md"},
{"symbols-%2F", "symbols %2F.md"},
} { } {
name, err := FilenameToName(test.Filename) name, err := GitPathToWebPath(test.Filename)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, test.Expected, name) assert.EqualValues(t, test.Expected, name)
} }
for _, badFilename := range []string{ for _, badFilename := range []string{
"nofileextension", "nofileextension",
"wrongfileextension.txt", "wrongfileextension.txt",
} { } {
_, err := FilenameToName(badFilename) _, err := GitPathToWebPath(badFilename)
assert.Error(t, err) assert.Error(t, err)
assert.True(t, repo_model.IsErrWikiInvalidFileName(err)) assert.True(t, repo_model.IsErrWikiInvalidFileName(err))
} }
_, err := FilenameToName("badescaping%%.md") _, err := GitPathToWebPath("badescaping%%.md")
assert.Error(t, err) assert.Error(t, err)
assert.False(t, repo_model.IsErrWikiInvalidFileName(err)) assert.False(t, repo_model.IsErrWikiInvalidFileName(err))
} }
func TestWikiNameToFilenameToName(t *testing.T) { func TestUserWebGitPathConsistency(t *testing.T) {
// converting from wiki name to filename, then back to wiki name should maxLen := 20
// return the original (normalized) name b := make([]byte, maxLen)
for _, name := range []string{ for i := 0; i < 1000; i++ {
"wiki-name", l := rand.Intn(maxLen)
"wiki name", for j := 0; j < l; j++ {
"wiki name with/slash", r := rand.Intn(0x80-0x20) + 0x20
"$$$%%%^^&&!@#$(),.<>", b[j] = byte(r)
} { }
filename := NameToFilename(name)
resultName, err := FilenameToName(filename) userTitle := strings.TrimSpace(string(b[:l]))
assert.NoError(t, err) if userTitle == "" || userTitle == "." {
assert.Equal(t, NormalizeWikiName(name), resultName) continue
}
webPath := UserTitleToWebPath("", userTitle)
gitPath := WebPathToGitPath(webPath)
webPath1, _ := GitPathToWebPath(gitPath)
_, userTitle1 := WebPathToUserTitle(webPath1)
gitPath1 := WebPathToGitPath(webPath1)
assert.EqualValues(t, userTitle, userTitle1, "UserTitle for userTitle: %q", userTitle)
assert.EqualValues(t, webPath, webPath1, "WebPath for userTitle: %q", userTitle)
assert.EqualValues(t, gitPath, gitPath1, "GitPath for userTitle: %q", userTitle)
} }
} }
@ -127,24 +151,23 @@ func TestRepository_AddWikiPage(t *testing.T) {
const commitMsg = "Commit message" const commitMsg = "Commit message"
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
for _, wikiName := range []string{ for _, userTitle := range []string{
"Another page", "Another page",
"Here's a <tag> and a/slash", "Here's a <tag> and a/slash",
} { } {
wikiName := wikiName t.Run("test wiki exist: "+userTitle, func(t *testing.T) {
t.Run("test wiki exist: "+wikiName, func(t *testing.T) { webPath := UserTitleToWebPath("", userTitle)
t.Parallel() assert.NoError(t, AddWikiPage(git.DefaultContext, doer, repo, webPath, wikiContent, commitMsg))
assert.NoError(t, AddWikiPage(git.DefaultContext, doer, repo, wikiName, wikiContent, commitMsg))
// Now need to show that the page has been added: // Now need to show that the page has been added:
gitRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath()) gitRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath())
assert.NoError(t, err) assert.NoError(t, err)
defer gitRepo.Close() defer gitRepo.Close()
masterTree, err := gitRepo.GetTree(DefaultBranch) masterTree, err := gitRepo.GetTree(DefaultBranch)
assert.NoError(t, err) assert.NoError(t, err)
wikiPath := NameToFilename(wikiName) gitPath := WebPathToGitPath(webPath)
entry, err := masterTree.GetTreeEntryByPath(wikiPath) entry, err := masterTree.GetTreeEntryByPath(gitPath)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, wikiPath, entry.Name(), "%s not added correctly", wikiName) assert.EqualValues(t, gitPath, entry.Name(), "%s not added correctly", userTitle)
}) })
} }
@ -177,18 +200,19 @@ func TestRepository_EditWikiPage(t *testing.T) {
"New home", "New home",
"New/name/with/slashes", "New/name/with/slashes",
} { } {
webPath := UserTitleToWebPath("", newWikiName)
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
assert.NoError(t, EditWikiPage(git.DefaultContext, doer, repo, "Home", newWikiName, newWikiContent, commitMsg)) assert.NoError(t, EditWikiPage(git.DefaultContext, doer, repo, "Home", webPath, newWikiContent, commitMsg))
// Now need to show that the page has been added: // Now need to show that the page has been added:
gitRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath()) gitRepo, err := git.OpenRepository(git.DefaultContext, repo.WikiPath())
assert.NoError(t, err) assert.NoError(t, err)
masterTree, err := gitRepo.GetTree(DefaultBranch) masterTree, err := gitRepo.GetTree(DefaultBranch)
assert.NoError(t, err) assert.NoError(t, err)
wikiPath := NameToFilename(newWikiName) gitPath := WebPathToGitPath(webPath)
entry, err := masterTree.GetTreeEntryByPath(wikiPath) entry, err := masterTree.GetTreeEntryByPath(gitPath)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, wikiPath, entry.Name(), "%s not edited correctly", newWikiName) assert.EqualValues(t, gitPath, entry.Name(), "%s not edited correctly", newWikiName)
if newWikiName != "Home" { if newWikiName != "Home" {
_, err := masterTree.GetTreeEntryByPath("Home.md") _, err := masterTree.GetTreeEntryByPath("Home.md")
@ -210,8 +234,8 @@ func TestRepository_DeleteWikiPage(t *testing.T) {
defer gitRepo.Close() defer gitRepo.Close()
masterTree, err := gitRepo.GetTree(DefaultBranch) masterTree, err := gitRepo.GetTree(DefaultBranch)
assert.NoError(t, err) assert.NoError(t, err)
wikiPath := NameToFilename("Home") gitPath := WebPathToGitPath("Home")
_, err = masterTree.GetTreeEntryByPath(wikiPath) _, err = masterTree.GetTreeEntryByPath(gitPath)
assert.Error(t, err) assert.Error(t, err)
} }
@ -240,16 +264,11 @@ func TestPrepareWikiFileName(t *testing.T) {
existence: false, existence: false,
wikiPath: "home-of-and-%26-or-wiki-page%21.md", wikiPath: "home-of-and-%26-or-wiki-page%21.md",
wantErr: false, wantErr: false,
}, {
name: "found unescaped cases",
arg: "Unescaped File",
existence: true,
wikiPath: "Unescaped File.md",
wantErr: false,
}} }}
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
existence, newWikiPath, err := prepareWikiFileName(gitRepo, tt.arg) webPath := UserTitleToWebPath("", tt.arg)
existence, newWikiPath, err := prepareGitPath(gitRepo, webPath)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
assert.NoError(t, err) assert.NoError(t, err)
return return
@ -261,7 +280,7 @@ func TestPrepareWikiFileName(t *testing.T) {
t.Errorf("expect to find an escaped file but we could not detect one") t.Errorf("expect to find an escaped file but we could not detect one")
} }
} }
assert.Equal(t, tt.wikiPath, newWikiPath) assert.EqualValues(t, tt.wikiPath, newWikiPath)
}) })
} }
} }
@ -279,8 +298,8 @@ func TestPrepareWikiFileName_FirstPage(t *testing.T) {
defer gitRepo.Close() defer gitRepo.Close()
assert.NoError(t, err) assert.NoError(t, err)
existence, newWikiPath, err := prepareWikiFileName(gitRepo, "Home") existence, newWikiPath, err := prepareGitPath(gitRepo, "Home")
assert.False(t, existence) assert.False(t, existence)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "Home.md", newWikiPath) assert.EqualValues(t, "Home.md", newWikiPath)
} }

View File

@ -3,22 +3,21 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
<h2 class="ui header gt-df gt-ac gt-sb"> <h2 class="ui header gt-df gt-ac gt-sb">
<div> <span>{{.locale.Tr "repo.wiki.pages"}}</span>
{{.locale.Tr "repo.wiki.pages"}} <span>
</div>
<div>
{{if and .CanWriteWiki (not .IsRepositoryMirror)}} {{if and .CanWriteWiki (not .IsRepositoryMirror)}}
<a class="ui green small button" href="{{.RepoLink}}/wiki?action=_new">{{.locale.Tr "repo.wiki.new_page_button"}}</a> <a class="ui green small button" href="{{.RepoLink}}/wiki?action=_new">{{.locale.Tr "repo.wiki.new_page_button"}}</a>
{{end}} {{end}}
</div> </span>
</h2> </h2>
<table class="ui table"> <table class="ui table wiki-pages-list">
<tbody> <tbody>
{{range .Pages}} {{range .Pages}}
<tr> <tr>
<td> <td>
{{svg "octicon-file"}} {{svg "octicon-file"}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a> <a href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
<a class="wiki-git-entry" href="{{$.RepoLink}}/wiki/{{.GitEntryName | PathEscape}}" data-tooltip-content="{{$.locale.Tr "repo.wiki.original_git_entry_tooltip"}}">{{svg "octicon-chevron-right"}}</a>
</td> </td>
{{$timeSince := TimeSinceUnix .UpdatedUnix $.locale}} {{$timeSince := TimeSinceUnix .UpdatedUnix $.locale}}
<td class="text right">{{$.locale.Tr "repo.wiki.last_updated" $timeSince | Safe}}</td> <td class="text right">{{$.locale.Tr "repo.wiki.last_updated" $timeSince | Safe}}</td>

View File

@ -5,7 +5,6 @@
<div class="ui container"> <div class="ui container">
<div class="repo-button-row gt-df gt-ac gt-sb gt-fw"> <div class="repo-button-row gt-df gt-ac gt-sb gt-fw">
<div class="gt-df gt-ac"> <div class="gt-df gt-ac">
<div class="choose page">
<div class="ui floating filter dropdown" data-no-results="{{.locale.Tr "repo.pulls.no_results"}}"> <div class="ui floating filter dropdown" data-no-results="{{.locale.Tr "repo.pulls.no_results"}}">
<div class="ui basic small button"> <div class="ui basic small button">
<span class="text"> <span class="text">
@ -20,21 +19,20 @@
<input name="search" placeholder="{{.locale.Tr "repo.wiki.filter_page"}}..."> <input name="search" placeholder="{{.locale.Tr "repo.wiki.filter_page"}}...">
</div> </div>
<div class="scrolling menu"> <div class="scrolling menu">
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{.locale.Tr "repo.wiki.pages"}}</a>
<div class="ui divider"></div>
{{range .Pages}} {{range .Pages}}
<div class="item {{if eq $.Title .Name}}selected{{end}}" data-url="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</div> <a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
{{end}} {{end}}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="ui action small input gt-df gt-ac" id="clone-panel">
<div class="gt-df gt-ac">
<div class="ui action small input" id="clone-panel">
{{template "repo/clone_buttons" .}} {{template "repo/clone_buttons" .}}
{{template "repo/clone_script" .}} {{template "repo/clone_script" .}}
</div> </div>
</div> </div>
</div>
<div class="ui dividing header"> <div class="ui dividing header">
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="eight wide column"> <div class="eight wide column">

View File

@ -2106,6 +2106,14 @@
margin-left: 1em !important; margin-left: 1em !important;
} }
.wiki-pages-list .wiki-git-entry {
margin-left: 10px;
display: none;
}
.wiki-pages-list td:hover .wiki-git-entry {
display: inline-block;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.repository.wiki .dividing.header .stackable.grid .button { .repository.wiki .dividing.header .stackable.grid .button {

View File

@ -463,18 +463,12 @@ export function initRepository() {
return; return;
} }
// File list and commits // File list and commits
if ($('.repository.file.list').length > 0 || $('.branch-dropdown').length > 0 || if ($('.repository.file.list').length > 0 || $('.branch-dropdown').length > 0 ||
$('.repository.commits').length > 0 || $('.repository.release').length > 0) { $('.repository.commits').length > 0 || $('.repository.release').length > 0) {
initRepoBranchTagSelector('.js-branch-tag-selector'); initRepoBranchTagSelector('.js-branch-tag-selector');
} }
// Wiki
if ($('.repository.wiki.view').length > 0) {
initRepoCommonFilterSearchDropdown('.choose.page .dropdown');
}
// Options // Options
if ($('.repository.settings.options').length > 0) { if ($('.repository.settings.options').length > 0) {
// Enable or select internal/external wiki system and issue tracker. // Enable or select internal/external wiki system and issue tracker.