diff --git a/models/activities/action.go b/models/activities/action.go index e74deef1df4cf..546d4340aedca 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -770,7 +770,7 @@ func DeleteIssueActions(ctx context.Context, repoID, issueID, issueIndex int64) // CountActionCreatedUnixString count actions where created_unix is an empty string func CountActionCreatedUnixString(ctx context.Context) (int64, error) { if setting.Database.Type.IsSQLite3() { - return db.GetEngine(ctx).Where(`created_unix = ""`).Count(new(Action)) + return db.GetEngine(ctx).Where(`created_unix = ''`).Count(new(Action)) } return 0, nil } @@ -778,7 +778,7 @@ func CountActionCreatedUnixString(ctx context.Context) (int64, error) { // FixActionCreatedUnixString set created_unix to zero if it is an empty string func FixActionCreatedUnixString(ctx context.Context) (int64, error) { if setting.Database.Type.IsSQLite3() { - res, err := db.GetEngine(ctx).Exec(`UPDATE action SET created_unix = 0 WHERE created_unix = ""`) + res, err := db.GetEngine(ctx).Exec(`UPDATE action SET created_unix = 0 WHERE created_unix = ''`) if err != nil { return 0, err } diff --git a/models/activities/action_test.go b/models/activities/action_test.go index e5dee33ae0224..64330ebbb3e9a 100644 --- a/models/activities/action_test.go +++ b/models/activities/action_test.go @@ -256,7 +256,7 @@ func TestConsistencyUpdateAction(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &activities_model.Action{ ID: int64(id), }) - _, err := db.GetEngine(db.DefaultContext).Exec(`UPDATE action SET created_unix = "" WHERE id = ?`, id) + _, err := db.GetEngine(db.DefaultContext).Exec(`UPDATE action SET created_unix = '' WHERE id = ?`, id) assert.NoError(t, err) actions := make([]*activities_model.Action, 0, 1) // diff --git a/models/auth/webauthn.go b/models/auth/webauthn.go index 553130ee2e9ee..6d8b54295790e 100644 --- a/models/auth/webauthn.go +++ b/models/auth/webauthn.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" ) @@ -89,14 +90,33 @@ func (cred *WebAuthnCredential) AfterLoad() { // WebAuthnCredentialList is a list of *WebAuthnCredential type WebAuthnCredentialList []*WebAuthnCredential +// newCredentialFlagsFromAuthenticatorFlags is copied from https://github.com/go-webauthn/webauthn/pull/337 +// to convert protocol.AuthenticatorFlags to webauthn.CredentialFlags +func newCredentialFlagsFromAuthenticatorFlags(flags protocol.AuthenticatorFlags) webauthn.CredentialFlags { + return webauthn.CredentialFlags{ + UserPresent: flags.HasUserPresent(), + UserVerified: flags.HasUserVerified(), + BackupEligible: flags.HasBackupEligible(), + BackupState: flags.HasBackupState(), + } +} + // ToCredentials will convert all WebAuthnCredentials to webauthn.Credentials -func (list WebAuthnCredentialList) ToCredentials() []webauthn.Credential { +func (list WebAuthnCredentialList) ToCredentials(defaultAuthFlags ...protocol.AuthenticatorFlags) []webauthn.Credential { + // TODO: at the moment, Gitea doesn't store or check the flags + // so we need to use the default flags from the authenticator to make the login validation pass + // In the future, we should: + // 1. store the flags when registering the credential + // 2. provide the stored flags when converting the credentials (for login) + // 3. for old users, still use this fallback to the default flags + defAuthFlags := util.OptionalArg(defaultAuthFlags) creds := make([]webauthn.Credential, 0, len(list)) for _, cred := range list { creds = append(creds, webauthn.Credential{ ID: cred.CredentialID, PublicKey: cred.PublicKey, AttestationType: cred.AttestationType, + Flags: newCredentialFlagsFromAuthenticatorFlags(defAuthFlags), Authenticator: webauthn.Authenticator{ AAGUID: cred.AAGUID, SignCount: cred.SignCount, diff --git a/models/db/engine.go b/models/db/engine.go index e50a8580bf0b7..b17188945a458 100755 --- a/models/db/engine.go +++ b/models/db/engine.go @@ -134,6 +134,9 @@ func SyncAllTables() error { func InitEngine(ctx context.Context) error { xormEngine, err := newXORMEngine() if err != nil { + if strings.Contains(err.Error(), "SQLite3 support") { + return fmt.Errorf(`sqlite3 requires: -tags sqlite,sqlite_unlock_notify%s%w`, "\n", err) + } return fmt.Errorf("failed to connect to database: %w", err) } diff --git a/models/issues/comment.go b/models/issues/comment.go index 48b8e335d48ef..6650817e5062f 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -1108,7 +1108,7 @@ func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, sess.Join("INNER", "issue", "issue.id = comment.issue_id") } - if opts.Page != 0 { + if opts.Page > 0 { sess = db.SetSessionPagination(sess, opts) } diff --git a/models/issues/issue.go b/models/issues/issue.go index 9ccf2859ea413..fd89267b84d58 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -641,7 +641,7 @@ func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptio Where("issue_id = ?", issue.ID). // sort by repo id then created date, with the issues of the same repo at the beginning of the list OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID) - if opts.Page != 0 { + if opts.Page > 0 { sess = db.SetSessionPagination(sess, &opts) } err = sess.Find(&issueDeps) diff --git a/models/issues/issue_watch.go b/models/issues/issue_watch.go index 9e616a0eb1319..560be17eb6275 100644 --- a/models/issues/issue_watch.go +++ b/models/issues/issue_watch.go @@ -105,7 +105,7 @@ func GetIssueWatchers(ctx context.Context, issueID int64, listOptions db.ListOpt And("`user`.prohibit_login = ?", false). Join("INNER", "`user`", "`user`.id = `issue_watch`.user_id") - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) watches := make([]*IssueWatch, 0, listOptions.PageSize) return watches, sess.Find(&watches) diff --git a/models/issues/label.go b/models/issues/label.go index 2530f71004de9..d80578193e545 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -390,7 +390,7 @@ func GetLabelsByRepoID(ctx context.Context, repoID int64, sortType string, listO sess.Asc("name") } - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) } @@ -462,7 +462,7 @@ func GetLabelsByOrgID(ctx context.Context, orgID int64, sortType string, listOpt sess.Asc("name") } - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) } diff --git a/models/issues/label_test.go b/models/issues/label_test.go index a0cc8e6d75640..c2ff084c23632 100644 --- a/models/issues/label_test.go +++ b/models/issues/label_test.go @@ -406,7 +406,7 @@ func TestDeleteIssueLabel(t *testing.T) { PosterID: doerID, IssueID: issueID, LabelID: labelID, - }, `content=""`) + }, `content=''`) label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID}) assert.EqualValues(t, expectedNumIssues, label.NumIssues) assert.EqualValues(t, expectedNumClosedIssues, label.NumClosedIssues) diff --git a/models/issues/reaction.go b/models/issues/reaction.go index eb7faefc796b9..11b3c6be20371 100644 --- a/models/issues/reaction.go +++ b/models/issues/reaction.go @@ -163,7 +163,7 @@ func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList Where(opts.toConds()). In("reaction.`type`", setting.UI.Reactions). Asc("reaction.issue_id", "reaction.comment_id", "reaction.created_unix", "reaction.id") - if opts.Page != 0 { + if opts.Page > 0 { sess = db.SetSessionPagination(sess, &opts) reactions := make([]*Reaction, 0, opts.PageSize) diff --git a/models/issues/stopwatch.go b/models/issues/stopwatch.go index fd9c7d7875524..629af95b5774f 100644 --- a/models/issues/stopwatch.go +++ b/models/issues/stopwatch.go @@ -96,7 +96,7 @@ func GetUIDsAndStopwatch(ctx context.Context) ([]*UserStopwatch, error) { func GetUserStopwatches(ctx context.Context, userID int64, listOptions db.ListOptions) ([]*Stopwatch, error) { sws := make([]*Stopwatch, 0, 8) sess := db.GetEngine(ctx).Where("stopwatch.user_id = ?", userID) - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) } diff --git a/models/issues/tracked_time.go b/models/issues/tracked_time.go index caa582a9fcab6..ea404d36cd162 100644 --- a/models/issues/tracked_time.go +++ b/models/issues/tracked_time.go @@ -139,7 +139,7 @@ func (opts *FindTrackedTimesOptions) toSession(e db.Engine) db.Engine { sess = sess.Where(opts.ToConds()) - if opts.Page != 0 { + if opts.Page > 0 { sess = db.SetSessionPagination(sess, opts) } diff --git a/models/migrations/base/tests.go b/models/migrations/base/tests.go index ddf9a544daabf..c2134f702a51a 100644 --- a/models/migrations/base/tests.go +++ b/models/migrations/base/tests.go @@ -18,7 +18,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/testlogger" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "xorm.io/xorm" ) @@ -33,15 +33,15 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu ourSkip := 2 ourSkip += skip deferFn := testlogger.PrintCurrentTest(t, ourSkip) - assert.NoError(t, unittest.SyncDirs(filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath)) + require.NoError(t, unittest.SyncDirs(filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath)) if err := deleteDB(); err != nil { - t.Errorf("unable to reset database: %v", err) + t.Fatalf("unable to reset database: %v", err) return nil, deferFn } x, err := newXORMEngine() - assert.NoError(t, err) + require.NoError(t, err) if x != nil { oldDefer := deferFn deferFn = func() { diff --git a/models/organization/org_list.go b/models/organization/org_list.go index 72ebf6f17820f..4c4168af1f826 100644 --- a/models/organization/org_list.go +++ b/models/organization/org_list.go @@ -16,6 +16,31 @@ import ( "xorm.io/builder" ) +type OrgList []*Organization + +func (orgs OrgList) LoadTeams(ctx context.Context) (map[int64]TeamList, error) { + if len(orgs) == 0 { + return map[int64]TeamList{}, nil + } + + orgIDs := make([]int64, len(orgs)) + for i, org := range orgs { + orgIDs[i] = org.ID + } + + teams, err := GetTeamsByOrgIDs(ctx, orgIDs) + if err != nil { + return nil, err + } + + teamMap := make(map[int64]TeamList, len(orgs)) + for _, team := range teams { + teamMap[team.OrgID] = append(teamMap[team.OrgID], team) + } + + return teamMap, nil +} + // SearchOrganizationsOptions options to filter organizations type SearchOrganizationsOptions struct { db.ListOptions diff --git a/models/organization/org_list_test.go b/models/organization/org_list_test.go index fc8d148a1d7c0..edc8996f3ec86 100644 --- a/models/organization/org_list_test.go +++ b/models/organization/org_list_test.go @@ -60,3 +60,14 @@ func TestGetUserOrgsList(t *testing.T) { assert.EqualValues(t, 2, orgs[0].NumRepos) } } + +func TestLoadOrgListTeams(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + orgs, err := organization.GetUserOrgsList(db.DefaultContext, &user_model.User{ID: 4}) + assert.NoError(t, err) + assert.Len(t, orgs, 1) + teamsMap, err := organization.OrgList(orgs).LoadTeams(db.DefaultContext) + assert.NoError(t, err) + assert.Len(t, teamsMap, 1) + assert.Len(t, teamsMap[3], 5) +} diff --git a/models/organization/team_list.go b/models/organization/team_list.go index 5b45429acf310..cc2a50236a447 100644 --- a/models/organization/team_list.go +++ b/models/organization/team_list.go @@ -126,3 +126,8 @@ func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams T And("team_repo.repo_id=?", repoID). Find(&teams) } + +func GetTeamsByOrgIDs(ctx context.Context, orgIDs []int64) (TeamList, error) { + teams := make([]*Team, 0, 10) + return teams, db.GetEngine(ctx).Where(builder.In("org_id", orgIDs)).Find(&teams) +} diff --git a/models/user/search.go b/models/user/search.go index 382b6fac2b08e..6af33892373ad 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -152,7 +152,7 @@ func SearchUsers(ctx context.Context, opts *SearchUserOptions) (users []*User, _ sessQuery := opts.toSearchQueryBase(ctx).OrderBy(opts.OrderBy.String()) defer sessQuery.Close() - if opts.Page != 0 { + if opts.Page > 0 { sessQuery = db.SetSessionPagination(sessQuery, opts) } diff --git a/models/user/user.go b/models/user/user.go index e8a87ef5ede19..a33276d951901 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -330,7 +330,7 @@ func GetUserFollowers(ctx context.Context, u, viewer *User, listOptions db.ListO And("`user`.type=?", UserTypeIndividual). And(isUserVisibleToViewerCond(viewer)) - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) users := make([]*User, 0, listOptions.PageSize) @@ -352,7 +352,7 @@ func GetUserFollowing(ctx context.Context, u, viewer *User, listOptions db.ListO And("`user`.type IN (?, ?)", UserTypeIndividual, UserTypeOrganization). And(isUserVisibleToViewerCond(viewer)) - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) users := make([]*User, 0, listOptions.PageSize) diff --git a/modules/auth/webauthn/webauthn.go b/modules/auth/webauthn/webauthn.go index 790006ee567cf..cbf5279c651fd 100644 --- a/modules/auth/webauthn/webauthn.go +++ b/modules/auth/webauthn/webauthn.go @@ -4,13 +4,14 @@ package webauthn import ( + "context" "encoding/binary" "encoding/gob" "code.gitea.io/gitea/models/auth" - "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" @@ -38,40 +39,42 @@ func Init() { } } -// User represents an implementation of webauthn.User based on User model -type User user_model.User +// user represents an implementation of webauthn.User based on User model +type user struct { + ctx context.Context + User *user_model.User + + defaultAuthFlags protocol.AuthenticatorFlags +} + +var _ webauthn.User = (*user)(nil) + +func NewWebAuthnUser(ctx context.Context, u *user_model.User, defaultAuthFlags ...protocol.AuthenticatorFlags) webauthn.User { + return &user{ctx: ctx, User: u, defaultAuthFlags: util.OptionalArg(defaultAuthFlags)} +} // WebAuthnID implements the webauthn.User interface -func (u *User) WebAuthnID() []byte { +func (u *user) WebAuthnID() []byte { id := make([]byte, 8) - binary.PutVarint(id, u.ID) + binary.PutVarint(id, u.User.ID) return id } // WebAuthnName implements the webauthn.User interface -func (u *User) WebAuthnName() string { - if u.LoginName == "" { - return u.Name - } - return u.LoginName +func (u *user) WebAuthnName() string { + return util.IfZero(u.User.LoginName, u.User.Name) } // WebAuthnDisplayName implements the webauthn.User interface -func (u *User) WebAuthnDisplayName() string { - return (*user_model.User)(u).DisplayName() -} - -// WebAuthnIcon implements the webauthn.User interface -func (u *User) WebAuthnIcon() string { - return (*user_model.User)(u).AvatarLink(db.DefaultContext) +func (u *user) WebAuthnDisplayName() string { + return u.User.DisplayName() } // WebAuthnCredentials implements the webauthn.User interface -func (u *User) WebAuthnCredentials() []webauthn.Credential { - dbCreds, err := auth.GetWebAuthnCredentialsByUID(db.DefaultContext, u.ID) +func (u *user) WebAuthnCredentials() []webauthn.Credential { + dbCreds, err := auth.GetWebAuthnCredentialsByUID(u.ctx, u.User.ID) if err != nil { return nil } - - return dbCreds.ToCredentials() + return dbCreds.ToCredentials(u.defaultAuthFlags) } diff --git a/modules/indexer/code/indexer_test.go b/modules/indexer/code/indexer_test.go index 78fbe7f79247b..d04088531ac28 100644 --- a/modules/indexer/code/indexer_test.go +++ b/modules/indexer/code/indexer_test.go @@ -22,8 +22,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - _ "github.com/mattn/go-sqlite3" ) type codeSearchResult struct { diff --git a/modules/markup/html.go b/modules/markup/html.go index 0b1e9b32242c2..04b768bb8ec4d 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -5,9 +5,9 @@ package markup import ( "bytes" + "fmt" "io" "regexp" - "slices" "strings" "sync" @@ -133,75 +133,49 @@ func CustomLinkURLSchemes(schemes []string) { common.GlobalVars().LinkRegex, _ = xurls.StrictMatchingScheme(strings.Join(withAuth, "|")) } -type postProcessError struct { - context string - err error -} - -func (p *postProcessError) Error() string { - return "PostProcess: " + p.context + ", " + p.err.Error() -} - type processor func(ctx *RenderContext, node *html.Node) -var defaultProcessors = []processor{ - fullIssuePatternProcessor, - comparePatternProcessor, - codePreviewPatternProcessor, - fullHashPatternProcessor, - shortLinkProcessor, - linkProcessor, - mentionProcessor, - issueIndexPatternProcessor, - commitCrossReferencePatternProcessor, - hashCurrentPatternProcessor, - emailAddressProcessor, - emojiProcessor, - emojiShortCodeProcessor, -} - -// PostProcess does the final required transformations to the passed raw HTML +// PostProcessDefault does the final required transformations to the passed raw HTML // data, and ensures its validity. Transformations include: replacing links and // emails with HTML links, parsing shortlinks in the format of [[Link]], like // MediaWiki, linking issues in the format #ID, and mentions in the format // @user, and others. -func PostProcess(ctx *RenderContext, input io.Reader, output io.Writer) error { - return postProcess(ctx, defaultProcessors, input, output) -} - -var commitMessageProcessors = []processor{ - fullIssuePatternProcessor, - comparePatternProcessor, - fullHashPatternProcessor, - linkProcessor, - mentionProcessor, - issueIndexPatternProcessor, - commitCrossReferencePatternProcessor, - hashCurrentPatternProcessor, - emailAddressProcessor, - emojiProcessor, - emojiShortCodeProcessor, +func PostProcessDefault(ctx *RenderContext, input io.Reader, output io.Writer) error { + procs := []processor{ + fullIssuePatternProcessor, + comparePatternProcessor, + codePreviewPatternProcessor, + fullHashPatternProcessor, + shortLinkProcessor, + linkProcessor, + mentionProcessor, + issueIndexPatternProcessor, + commitCrossReferencePatternProcessor, + hashCurrentPatternProcessor, + emailAddressProcessor, + emojiProcessor, + emojiShortCodeProcessor, + } + return postProcess(ctx, procs, input, output) } // RenderCommitMessage will use the same logic as PostProcess, but will disable -// the shortLinkProcessor and will add a defaultLinkProcessor if defaultLink is -// set, which changes every text node into a link to the passed default link. +// the shortLinkProcessor. func RenderCommitMessage(ctx *RenderContext, content string) (string, error) { - procs := commitMessageProcessors - return renderProcessString(ctx, procs, content) -} - -var commitMessageSubjectProcessors = []processor{ - fullIssuePatternProcessor, - comparePatternProcessor, - fullHashPatternProcessor, - linkProcessor, - mentionProcessor, - issueIndexPatternProcessor, - commitCrossReferencePatternProcessor, - hashCurrentPatternProcessor, - emojiShortCodeProcessor, - emojiProcessor, + procs := []processor{ + fullIssuePatternProcessor, + comparePatternProcessor, + fullHashPatternProcessor, + linkProcessor, + mentionProcessor, + issueIndexPatternProcessor, + commitCrossReferencePatternProcessor, + hashCurrentPatternProcessor, + emailAddressProcessor, + emojiProcessor, + emojiShortCodeProcessor, + } + return postProcessString(ctx, procs, content) } var emojiProcessors = []processor{ @@ -214,7 +188,18 @@ var emojiProcessors = []processor{ // emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set, // which changes every text node into a link to the passed default link. func RenderCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) { - procs := slices.Clone(commitMessageSubjectProcessors) + procs := []processor{ + fullIssuePatternProcessor, + comparePatternProcessor, + fullHashPatternProcessor, + linkProcessor, + mentionProcessor, + issueIndexPatternProcessor, + commitCrossReferencePatternProcessor, + hashCurrentPatternProcessor, + emojiShortCodeProcessor, + emojiProcessor, + } procs = append(procs, func(ctx *RenderContext, node *html.Node) { ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data} node.Type = html.ElementNode @@ -223,19 +208,19 @@ func RenderCommitMessageSubject(ctx *RenderContext, defaultLink, content string) node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted"}} node.FirstChild, node.LastChild = ch, ch }) - return renderProcessString(ctx, procs, content) + return postProcessString(ctx, procs, content) } // RenderIssueTitle to process title on individual issue/pull page func RenderIssueTitle(ctx *RenderContext, title string) (string, error) { // do not render other issue/commit links in an issue's title - which in most cases is already a link. - return renderProcessString(ctx, []processor{ + return postProcessString(ctx, []processor{ emojiShortCodeProcessor, emojiProcessor, }, title) } -func renderProcessString(ctx *RenderContext, procs []processor, content string) (string, error) { +func postProcessString(ctx *RenderContext, procs []processor, content string) (string, error) { var buf strings.Builder if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil { return "", err @@ -246,7 +231,7 @@ func renderProcessString(ctx *RenderContext, procs []processor, content string) // RenderDescriptionHTML will use similar logic as PostProcess, but will // use a single special linkProcessor. func RenderDescriptionHTML(ctx *RenderContext, content string) (string, error) { - return renderProcessString(ctx, []processor{ + return postProcessString(ctx, []processor{ descriptionLinkProcessor, emojiShortCodeProcessor, emojiProcessor, @@ -256,7 +241,7 @@ func RenderDescriptionHTML(ctx *RenderContext, content string) (string, error) { // RenderEmoji for when we want to just process emoji and shortcodes // in various places it isn't already run through the normal markdown processor func RenderEmoji(ctx *RenderContext, content string) (string, error) { - return renderProcessString(ctx, emojiProcessors, content) + return postProcessString(ctx, emojiProcessors, content) } func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error { @@ -276,7 +261,7 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output strings.NewReader(""), )) if err != nil { - return &postProcessError{"invalid HTML", err} + return fmt.Errorf("markup.postProcess: invalid HTML: %w", err) } if node.Type == html.DocumentNode { @@ -308,7 +293,7 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output // Render everything to buf. for _, node := range newNodes { if err := html.Render(output, node); err != nil { - return &postProcessError{"error rendering processed HTML", err} + return fmt.Errorf("markup.postProcess: html.Render: %w", err) } } return nil diff --git a/modules/markup/html_internal_test.go b/modules/markup/html_internal_test.go index 7f2057a3438eb..651e674108c90 100644 --- a/modules/markup/html_internal_test.go +++ b/modules/markup/html_internal_test.go @@ -277,12 +277,12 @@ func TestRender_AutoLink(t *testing.T) { test := func(input, expected string) { var buffer strings.Builder - err := PostProcess(NewTestRenderContext(localMetas), strings.NewReader(input), &buffer) + err := PostProcessDefault(NewTestRenderContext(localMetas), strings.NewReader(input), &buffer) assert.Equal(t, err, nil) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String())) buffer.Reset() - err = PostProcess(NewTestRenderContext(localMetas), strings.NewReader(input), &buffer) + err = PostProcessDefault(NewTestRenderContext(localMetas), strings.NewReader(input), &buffer) assert.Equal(t, err, nil) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer.String())) } diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index f806f66d11200..54bd91f3b3b11 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -445,14 +445,14 @@ func Test_ParseClusterFuzz(t *testing.T) { data := "
` var res strings.Builder - err := markup.PostProcess(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res) + err := markup.PostProcessDefault(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res) assert.NoError(t, err) assert.Equal(t, data, res.String()) } @@ -514,7 +514,7 @@ func BenchmarkEmojiPostprocess(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { var res strings.Builder - err := markup.PostProcess(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res) + err := markup.PostProcessDefault(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res) assert.NoError(b, err) } } @@ -522,7 +522,7 @@ func BenchmarkEmojiPostprocess(b *testing.B) { func TestFuzz(t *testing.T) { s := "t/l/issues/8#/../../a" renderContext := markup.NewTestRenderContext() - err := markup.PostProcess(renderContext, strings.NewReader(s), io.Discard) + err := markup.PostProcessDefault(renderContext, strings.NewReader(s), io.Discard) assert.NoError(t, err) } @@ -530,7 +530,7 @@ func TestIssue18471(t *testing.T) { data := `http://domain/org/repo/compare/783b039...da951ce` var res strings.Builder - err := markup.PostProcess(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res) + err := markup.PostProcessDefault(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res) assert.NoError(t, err) assert.Equal(t, `783b039...da951ce`, res.String()) diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index ed95cecf8b007..620a39ebfdec9 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -80,9 +80,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa // many places render non-comment contents with no mode=document, then these contents also use comment's hard line break setting // especially in many tests. markdownLineBreakStyle := ctx.RenderOptions.Metas["markdownLineBreakStyle"] - if markup.RenderBehaviorForTesting.ForceHardLineBreak { - v.SetHardLineBreak(true) - } else if markdownLineBreakStyle == "comment" { + if markdownLineBreakStyle == "comment" { v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInComments) } else if markdownLineBreakStyle == "document" { v.SetHardLineBreak(setting.Markdown.EnableHardLineBreakInDocuments) diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index aac5ccbb1f861..22ab39ebfa929 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -85,8 +85,89 @@ func TestRender_Images(t *testing.T) { `

`+title+`

`) } -func testAnswers(baseURL string) []string { - return []string{ +func TestTotal_RenderString(t *testing.T) { + defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() + + // Test cases without ambiguous links (It is not right to copy a whole file here, instead it should clearly test what is being tested) + sameCases := []string{ + // dear imgui wiki markdown extract: special wiki syntax + `Wiki! Enjoy :) +- [[Links, Language bindings, Engine bindings|Links]] +- [[Tips]] + +See commit 65f1bf27bc + +Ideas and codes + +- Bezier widget (by @r-lyeh) ` + AppURL + `ocornut/imgui/issues/786 +- Bezier widget (by @r-lyeh) ` + FullURL + `issues/786 +- Node graph editors https://github.com/ocornut/imgui/issues/306 +- [[Memory Editor|memory_editor_example]] +- [[Plot var helper|plot_var_example]]`, + // wine-staging wiki home extract: tables, special wiki syntax, images + `## What is Wine Staging? +**Wine Staging** on website [wine-staging.com](http://wine-staging.com). + +## Quick Links +Here are some links to the most important topics. You can find the full list of pages at the sidebar. + +| [[images/icon-install.png]] | [[Installation]] | +|--------------------------------|----------------------------------------------------------| +| [[images/icon-usage.png]] | [[Usage]] | +`, + // libgdx wiki page: inline images with special syntax + `[Excelsior JET](http://www.excelsiorjet.com/) allows you to create native executables for Windows, Linux and Mac OS X. + +1. [Package your libGDX application](https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop) +[[images/1.png]] +2. Perform a test run by hitting the Run! button. +[[images/2.png]] + +## More tests {#custom-id} + +(from https://www.markdownguide.org/extended-syntax/) + +### Checkboxes + +- [ ] unchecked +- [x] checked +- [ ] still unchecked + +### Definition list + +First Term +: This is the definition of the first term. + +Second Term +: This is one definition of the second term. +: This is another definition of the second term. + +### Footnotes + +Here is a simple footnote,[^1] and here is a longer one.[^bignote] + +[^1]: This is the first footnote. + +[^bignote]: Here is one with multiple paragraphs and code. + + Indent paragraphs to include them in the footnote. + + ` + "`{ my code }`" + ` + + Add as many paragraphs as you like. +`, + ` +- [ ] If you want to rebase/retry this PR, click this checkbox. + +--- + +This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). + +`, + } + + baseURL := "" + testAnswers := []string{ `

Wiki! Enjoy :)

  • Links, Language bindings, Engine bindings
  • @@ -123,9 +204,9 @@ func testAnswers(baseURL string) []string { `, `

    Excelsior JET allows you to create native executables for Windows, Linux and Mac OS X.

      -
    1. Package your libGDX application
      +
    2. Package your libGDX application images/1.png
    3. -
    4. Perform a test run by hitting the Run! button.
      +
    5. Perform a test run by hitting the Run! button. images/2.png

    More tests

    @@ -160,106 +241,24 @@ func testAnswers(baseURL string) []string { -`, `
      +`, + `
      • If you want to rebase/retry this PR, click this checkbox.

      This PR has been generated by Renovate Bot.

      `, } -} - -// Test cases without ambiguous links -var sameCases = []string{ - // dear imgui wiki markdown extract: special wiki syntax - `Wiki! Enjoy :) -- [[Links, Language bindings, Engine bindings|Links]] -- [[Tips]] -See commit 65f1bf27bc - -Ideas and codes - -- Bezier widget (by @r-lyeh) ` + AppURL + `ocornut/imgui/issues/786 -- Bezier widget (by @r-lyeh) ` + FullURL + `issues/786 -- Node graph editors https://github.com/ocornut/imgui/issues/306 -- [[Memory Editor|memory_editor_example]] -- [[Plot var helper|plot_var_example]]`, - // wine-staging wiki home extract: tables, special wiki syntax, images - `## What is Wine Staging? -**Wine Staging** on website [wine-staging.com](http://wine-staging.com). - -## Quick Links -Here are some links to the most important topics. You can find the full list of pages at the sidebar. - -| [[images/icon-install.png]] | [[Installation]] | -|--------------------------------|----------------------------------------------------------| -| [[images/icon-usage.png]] | [[Usage]] | -`, - // libgdx wiki page: inline images with special syntax - `[Excelsior JET](http://www.excelsiorjet.com/) allows you to create native executables for Windows, Linux and Mac OS X. - -1. [Package your libGDX application](https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop) -[[images/1.png]] -2. Perform a test run by hitting the Run! button. -[[images/2.png]] - -## More tests {#custom-id} - -(from https://www.markdownguide.org/extended-syntax/) - -### Checkboxes - -- [ ] unchecked -- [x] checked -- [ ] still unchecked - -### Definition list - -First Term -: This is the definition of the first term. - -Second Term -: This is one definition of the second term. -: This is another definition of the second term. - -### Footnotes - -Here is a simple footnote,[^1] and here is a longer one.[^bignote] - -[^1]: This is the first footnote. - -[^bignote]: Here is one with multiple paragraphs and code. - - Indent paragraphs to include them in the footnote. - - ` + "`{ my code }`" + ` - - Add as many paragraphs as you like. -`, - ` -- [ ] If you want to rebase/retry this PR, click this checkbox. - ---- - -This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). - -`, -} - -func TestTotal_RenderString(t *testing.T) { - defer test.MockVariableValue(&markup.RenderBehaviorForTesting.ForceHardLineBreak, true)() - defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() markup.Init(&markup.RenderHelperFuncs{ IsUsernameMentionable: func(ctx context.Context, username string) bool { return username == "r-lyeh" }, }) - answers := testAnswers("") for i := 0; i < len(sameCases); i++ { line, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), sameCases[i]) assert.NoError(t, err) - assert.Equal(t, answers[i], string(line)) + assert.Equal(t, testAnswers[i], string(line)) } } @@ -312,10 +311,9 @@ func TestRenderSiblingImages_Issue12925(t *testing.T) { testcase := `![image1](/image1) ![image2](/image2) ` - expected := `

      image1
      + expected := `

      image1 image2

      ` - defer test.MockVariableValue(&markup.RenderBehaviorForTesting.ForceHardLineBreak, true)() res, err := markdown.RenderRawString(markup.NewTestRenderContext(), testcase) assert.NoError(t, err) assert.Equal(t, expected, res) @@ -525,43 +523,33 @@ mail@domain.com space${SPACE}${SPACE} ` input = strings.ReplaceAll(input, "${SPACE}", " ") // replace ${SPACE} with " ", to avoid some editor's auto-trimming - cases := []struct { - Expected string - }{ - { - Expected: `

      space @mention-user
      -/just/a/path.bin
      -https://example.com/file.bin
      -local link
      -remote link
      -local link
      -remote link
      -local image
      -local image
      -local image
      -remote image
      -local image
      -remote link
      -88fc37a3c0...12fc37a3c0 (hash)
      -com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
      -88fc37a3c0
      -com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
      -👍
      -mail@domain.com
      -@mention-user test
      -#123
      + expected := `

      space @mention-user
      +/just/a/path.bin +https://example.com/file.bin +local link +remote link +local link +remote link +local image +local image +local image +remote image +local image +remote link +88fc37a3c0...12fc37a3c0 (hash) +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare +88fc37a3c0 +com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit +👍 +mail@domain.com +@mention-user test +#123 space

      -`, - }, - } - - defer test.MockVariableValue(&markup.RenderBehaviorForTesting.ForceHardLineBreak, true)() +` defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() - for i, c := range cases { - result, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), input) - assert.NoError(t, err, "Unexpected error in testcase: %v", i) - assert.Equal(t, c.Expected, string(result), "Unexpected result in testcase %v", i) - } + result, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), input) + assert.NoError(t, err) + assert.Equal(t, expected, string(result)) } func TestAttention(t *testing.T) { diff --git a/modules/markup/render.go b/modules/markup/render.go index be75d08c8c323..3b112b1a142fc 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -28,14 +28,6 @@ const ( ) var RenderBehaviorForTesting struct { - // Markdown line break rendering has 2 default behaviors: - // * Use hard: replace "\n" with "
      " for comments, setting.Markdown.EnableHardLineBreakInComments=true - // * Keep soft: "\n" for non-comments (a.k.a. documents), setting.Markdown.EnableHardLineBreakInDocuments=false - // In history, there was a mess: - // * The behavior was controlled by `Metas["mode"] != "document", - // * However, many places render the content without setting "mode" in Metas, all these places used comment line break setting incorrectly - ForceHardLineBreak bool - // Gitea will emit some additional attributes for various purposes, these attributes don't affect rendering. // But there are too many hard-coded test cases, to avoid changing all of them again and again, we can disable emitting these internal attributes. DisableAdditionalAttributes bool @@ -218,7 +210,7 @@ func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Wr eg.Go(func() (err error) { if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() { - err = PostProcess(ctx, pr1, pw2) + err = PostProcessDefault(ctx, pr1, pw2) } else { _, err = io.Copy(pw2, pr1) } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 23f466873bad9..aee76325a861b 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -321,7 +321,7 @@ func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeC } if !allow { - ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s): %v", requiredScopes)) + ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s), required=%v, token scope=%v", requiredScopes, scope)) return } @@ -1377,6 +1377,8 @@ func Routes() *web.Router { m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) m.Delete("", repo.DeleteAvatar) }, reqAdmin(), reqToken()) + + m.Get("/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive) }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go index 921e7b2750e77..6063e54cdcb26 100644 --- a/routers/api/v1/misc/markup_test.go +++ b/routers/api/v1/misc/markup_test.go @@ -7,15 +7,19 @@ import ( go_context "context" "io" "net/http" + "os" "path" "strings" "testing" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/web" + context_service "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" @@ -23,10 +27,17 @@ import ( const AppURL = "http://localhost:3000/" +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + FixtureFiles: []string{"repository.yml", "user.yml"}, + }) + os.Exit(m.Run()) +} + func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) { setting.AppURL = AppURL defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() - context := "/gogits/gogs" + context := "/user2/repo1" if !wiki { context += path.Join("/src/branch/main", path.Dir(filePath)) } @@ -38,6 +49,8 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe FilePath: filePath, } ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup") + ctx.Repo = &context_service.Repository{} + ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) web.SetForm(ctx, &options) Markup(ctx) assert.Equal(t, expectedBody, resp.Body.String()) @@ -48,7 +61,7 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) { defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() setting.AppURL = AppURL - context := "/gogits/gogs" + context := "/user2/repo1" if !wiki { context += "/src/branch/main" } @@ -67,6 +80,7 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody } func TestAPI_RenderGFM(t *testing.T) { + unittest.PrepareTestEnv(t) markup.Init(&markup.RenderHelperFuncs{ IsUsernameMentionable: func(ctx go_context.Context, username string) bool { return username == "r-lyeh" @@ -82,20 +96,20 @@ func TestAPI_RenderGFM(t *testing.T) { // rendered `

      Wiki! Enjoy :)

      `, // Guard wiki sidebar: special syntax `[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`, // rendered - `

      Guardfile-DSL / Configuring-Guard

      + `

      Guardfile-DSL / Configuring-Guard

      `, // special syntax `[[Name|Link]]`, // rendered - `

      Name

      + `

      Name

      `, // empty ``, @@ -119,8 +133,8 @@ Here are some links to the most important topics. You can find the full list of

      Wine Staging on website wine-staging.com.

      Here are some links to the most important topics. You can find the full list of pages at the sidebar.

      -

      Configuration -images/icon-bug.png

      +

      Configuration +images/icon-bug.png

      `, } @@ -143,20 +157,20 @@ Here are some links to the most important topics. You can find the full list of } input := "[Link](test.md)\n![Image](image.png)" - testRenderMarkdown(t, "gfm", false, input, `

      Link -Image

      + testRenderMarkdown(t, "gfm", false, input, `

      Link +Image

      `, http.StatusOK) - testRenderMarkdown(t, "gfm", false, input, `

      Link -Image

      + testRenderMarkdown(t, "gfm", false, input, `

      Link +Image

      `, http.StatusOK) - testRenderMarkup(t, "gfm", false, "", input, `

      Link -Image

      + testRenderMarkup(t, "gfm", false, "", input, `

      Link +Image

      `, http.StatusOK) - testRenderMarkup(t, "file", false, "path/new-file.md", input, `

      Link -Image

      + testRenderMarkup(t, "file", false, "path/new-file.md", input, `

      Link +Image

      `, http.StatusOK) testRenderMarkup(t, "file", false, "path/test.unknown", "## Test", "unsupported file to render: \"path/test.unknown\"\n", http.StatusUnprocessableEntity) @@ -186,7 +200,7 @@ func TestAPI_RenderSimple(t *testing.T) { options := api.MarkdownOption{ Mode: "markdown", Text: "", - Context: "/gogits/gogs", + Context: "/user2/repo1", } ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") for i := 0; i < len(simpleCases); i += 2 { diff --git a/routers/api/v1/repo/download.go b/routers/api/v1/repo/download.go new file mode 100644 index 0000000000000..3620c1465fe9c --- /dev/null +++ b/routers/api/v1/repo/download.go @@ -0,0 +1,53 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/services/context" + archiver_service "code.gitea.io/gitea/services/repository/archiver" +) + +func DownloadArchive(ctx *context.APIContext) { + var tp git.ArchiveType + switch ballType := ctx.PathParam("ball_type"); ballType { + case "tarball": + tp = git.TARGZ + case "zipball": + tp = git.ZIP + case "bundle": + tp = git.BUNDLE + default: + ctx.Error(http.StatusBadRequest, "", fmt.Sprintf("Unknown archive type: %s", ballType)) + return + } + + if ctx.Repo.GitRepo == nil { + gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "OpenRepository", err) + return + } + ctx.Repo.GitRepo = gitRepo + defer gitRepo.Close() + } + + r, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, ctx.PathParam("*"), tp) + if err != nil { + ctx.ServerError("NewRequest", err) + return + } + + archive, err := r.Await(ctx) + if err != nil { + ctx.ServerError("archive.Await", err) + return + } + + download(ctx, r.GetArchiveName(), archive) +} diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 05650cc9bed23..959a4b952a18b 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -301,7 +301,13 @@ func GetArchive(ctx *context.APIContext) { func archiveDownload(ctx *context.APIContext) { uri := ctx.PathParam("*") - aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + ext, tp, err := archiver_service.ParseFileName(uri) + if err != nil { + ctx.Error(http.StatusBadRequest, "ParseFileName", err) + return + } + + aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp) if err != nil { if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { ctx.Error(http.StatusBadRequest, "unknown archive format", err) @@ -327,9 +333,12 @@ func download(ctx *context.APIContext, archiveName string, archiver *repo_model. // Add nix format link header so tarballs lock correctly: // https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md - ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.tar.gz?rev=%s>; rel="immutable"`, + ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.%s?rev=%s>; rel="immutable"`, ctx.Repo.Repository.APIURL(), - archiver.CommitID, archiver.CommitID)) + archiver.CommitID, + archiver.Type.String(), + archiver.CommitID, + )) rPath := archiver.RelativePath() if setting.RepoArchive.Storage.ServeDirect() { diff --git a/routers/common/markup.go b/routers/common/markup.go index e3e6d9cfcf86b..533b546a2a106 100644 --- a/routers/common/markup.go +++ b/routers/common/markup.go @@ -77,8 +77,10 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur rctx = rctx.WithMarkupType(markdown.MarkupName) case "comment": rctx = renderhelper.NewRenderContextRepoComment(ctx, repoModel, renderhelper.RepoCommentOptions{DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName}) + rctx = rctx.WithMarkupType(markdown.MarkupName) case "wiki": rctx = renderhelper.NewRenderContextRepoWiki(ctx, repoModel, renderhelper.RepoWikiOptions{DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName}) + rctx = rctx.WithMarkupType(markdown.MarkupName) case "file": rctx = renderhelper.NewRenderContextRepoFile(ctx, repoModel, renderhelper.RepoFileOptions{ DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName, diff --git a/routers/web/admin/emails.go b/routers/web/admin/emails.go index 49338fbd7c448..e9c97d8b8f914 100644 --- a/routers/web/admin/emails.go +++ b/routers/web/admin/emails.go @@ -154,7 +154,7 @@ func ActivateEmail(ctx *context.Context) { // DeleteEmail serves a POST request for delete a user's email func DeleteEmail(ctx *context.Context) { - u, err := user_model.GetUserByID(ctx, ctx.FormInt64("Uid")) + u, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid")) if err != nil || u == nil { ctx.ServerError("GetUserByID", err) return diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go index 3160c5e23f03c..ba25d4507052e 100644 --- a/routers/web/auth/webauthn.go +++ b/routers/web/auth/webauthn.go @@ -76,8 +76,17 @@ func WebAuthnPasskeyLogin(ctx *context.Context) { }() // Validate the parsed response. + + // ParseCredentialRequestResponse+ValidateDiscoverableLogin equals to FinishDiscoverableLogin, but we need to ParseCredentialRequestResponse first to get flags var user *user_model.User - cred, err := wa.WebAuthn.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) { + parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req) + if err != nil { + // Failed authentication attempt. + log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err) + ctx.Status(http.StatusForbidden) + return + } + cred, err := wa.WebAuthn.ValidateDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) { userID, n := binary.Varint(userHandle) if n <= 0 { return nil, errors.New("invalid rawID") @@ -89,8 +98,8 @@ func WebAuthnPasskeyLogin(ctx *context.Context) { return nil, err } - return (*wa.User)(user), nil - }, *sessionData, ctx.Req) + return wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags), nil + }, *sessionData, parsedResponse) if err != nil { // Failed authentication attempt. log.Info("Failed authentication attempt for passkey from %s: %v", ctx.RemoteAddr(), err) @@ -171,7 +180,8 @@ func WebAuthnLoginAssertion(ctx *context.Context) { return } - assertion, sessionData, err := wa.WebAuthn.BeginLogin((*wa.User)(user)) + webAuthnUser := wa.NewWebAuthnUser(ctx, user) + assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser) if err != nil { ctx.ServerError("webauthn.BeginLogin", err) return @@ -216,7 +226,8 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) { } // Validate the parsed response. - cred, err := wa.WebAuthn.ValidateLogin((*wa.User)(user), *sessionData, parsedResponse) + webAuthnUser := wa.NewWebAuthnUser(ctx, user, parsedResponse.Response.AuthenticatorData.Flags) + cred, err := wa.WebAuthn.ValidateLogin(webAuthnUser, *sessionData, parsedResponse) if err != nil { // Failed authentication attempt. log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err) diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go index 8c343197d91b9..0068c9fe88771 100644 --- a/routers/web/devtest/devtest.go +++ b/routers/web/devtest/devtest.go @@ -24,12 +24,12 @@ func List(ctx *context.Context) { var subNames []string for _, tmplName := range templateNames { subName := strings.TrimSuffix(tmplName, ".tmpl") - if subName != "list" { + if !strings.HasPrefix(subName, "devtest-") { subNames = append(subNames, subName) } } ctx.Data["SubNames"] = subNames - ctx.HTML(http.StatusOK, "devtest/list") + ctx.HTML(http.StatusOK, "devtest/devtest-list") } func FetchActionTest(ctx *context.Context) { diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index b62fd21585184..f5e59b0357b02 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -464,7 +464,12 @@ func RedirectDownload(ctx *context.Context) { // Download an archive of a repository func Download(ctx *context.Context) { uri := ctx.PathParam("*") - aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + ext, tp, err := archiver_service.ParseFileName(uri) + if err != nil { + ctx.ServerError("ParseFileName", err) + return + } + aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp) if err != nil { if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { ctx.Error(http.StatusBadRequest, err.Error()) @@ -523,7 +528,12 @@ func download(ctx *context.Context, archiveName string, archiver *repo_model.Rep // kind of drop it on the floor if this is the case. func InitiateDownload(ctx *context.Context) { uri := ctx.PathParam("*") - aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + ext, tp, err := archiver_service.ParseFileName(uri) + if err != nil { + ctx.ServerError("ParseFileName", err) + return + } + aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp) if err != nil { ctx.ServerError("archiver_service.NewRequest", err) return diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index aafc2f2a648ca..70bfaac6e0943 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -51,7 +51,8 @@ func WebAuthnRegister(ctx *context.Context) { return } - credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer), webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ + webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer) + credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration(webAuthnUser, webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ ResidentKey: protocol.ResidentKeyRequirementRequired, })) if err != nil { @@ -92,7 +93,8 @@ func WebauthnRegisterPost(ctx *context.Context) { }() // Verify that the challenge succeeded - cred, err := wa.WebAuthn.FinishRegistration((*wa.User)(ctx.Doer), *sessionData, ctx.Req) + webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer) + cred, err := wa.WebAuthn.FinishRegistration(webAuthnUser, *sessionData, ctx.Req) if err != nil { if pErr, ok := err.(*protocol.Error); ok { log.Error("Unable to finish registration due to error: %v\nDevInfo: %s", pErr, pErr.DevInfo) diff --git a/services/oauth2_provider/access_token.go b/services/oauth2_provider/access_token.go index d94e15d5f2597..5cb6fb64c5e32 100644 --- a/services/oauth2_provider/access_token.go +++ b/services/oauth2_provider/access_token.go @@ -74,26 +74,32 @@ type AccessTokenResponse struct { // GrantAdditionalScopes returns valid scopes coming from grant func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope { // scopes_supported from templates/user/auth/oidc_wellknown.tmpl - scopesSupported := []string{ + generalScopesSupported := []string{ "openid", "profile", "email", "groups", } - var tokenScopes []string - for _, tokenScope := range strings.Split(grantScopes, " ") { - if slices.Index(scopesSupported, tokenScope) == -1 { - tokenScopes = append(tokenScopes, tokenScope) + var accessScopes []string // the scopes for access control, but not for general information + for _, scope := range strings.Split(grantScopes, " ") { + if scope != "" && !slices.Contains(generalScopesSupported, scope) { + accessScopes = append(accessScopes, scope) } } // since version 1.22, access tokens grant full access to the API // with this access is reduced only if additional scopes are provided - accessTokenScope := auth.AccessTokenScope(strings.Join(tokenScopes, ",")) - if accessTokenWithAdditionalScopes, err := accessTokenScope.Normalize(); err == nil && len(tokenScopes) > 0 { - return accessTokenWithAdditionalScopes + if len(accessScopes) > 0 { + accessTokenScope := auth.AccessTokenScope(strings.Join(accessScopes, ",")) + if normalizedAccessTokenScope, err := accessTokenScope.Normalize(); err == nil { + return normalizedAccessTokenScope + } + // TODO: if there are invalid access scopes (err != nil), + // then it is treated as "all", maybe in the future we should make it stricter to return an error + // at the moment, to avoid breaking 1.22 behavior, invalid tokens are also treated as "all" } + // fallback, empty access scope is treated as "all" access return auth.AccessTokenScopeAll } @@ -235,14 +241,15 @@ func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User, onlyPubli return nil, fmt.Errorf("GetUserOrgList: %w", err) } + orgTeams, err := org_model.OrgList(orgs).LoadTeams(ctx) + if err != nil { + return nil, fmt.Errorf("LoadTeams: %w", err) + } + var groups []string for _, org := range orgs { groups = append(groups, org.Name) - teams, err := org.LoadTeams(ctx) - if err != nil { - return nil, fmt.Errorf("LoadTeams: %w", err) - } - for _, team := range teams { + for _, team := range orgTeams[org.ID] { if team.IsMember(ctx, user.ID) { groups = append(groups, org.Name+":"+team.LowerName) } diff --git a/services/oauth2_provider/additional_scopes_test.go b/services/oauth2_provider/additional_scopes_test.go index d239229f4be78..2d4df7aea2b26 100644 --- a/services/oauth2_provider/additional_scopes_test.go +++ b/services/oauth2_provider/additional_scopes_test.go @@ -14,6 +14,7 @@ func TestGrantAdditionalScopes(t *testing.T) { grantScopes string expectedScopes string }{ + {"", "all"}, // for old tokens without scope, treat it as "all" {"openid profile email", "all"}, {"openid profile email groups", "all"}, {"openid profile email all", "all"}, @@ -22,12 +23,14 @@ func TestGrantAdditionalScopes(t *testing.T) { {"read:user read:repository", "read:repository,read:user"}, {"read:user write:issue public-only", "public-only,write:issue,read:user"}, {"openid profile email read:user", "read:user"}, + + // TODO: at the moment invalid tokens are treated as "all" to avoid breaking 1.22 behavior (more details are in GrantAdditionalScopes) {"read:invalid_scope", "all"}, {"read:invalid_scope,write:scope_invalid,just-plain-wrong", "all"}, } for _, test := range tests { - t.Run(test.grantScopes, func(t *testing.T) { + t.Run("scope:"+test.grantScopes, func(t *testing.T) { result := GrantAdditionalScopes(test.grantScopes) assert.Equal(t, test.expectedScopes, string(result)) }) diff --git a/services/repository/archiver/archiver.go b/services/repository/archiver/archiver.go index c33369d047e8e..e1addbed335cb 100644 --- a/services/repository/archiver/archiver.go +++ b/services/repository/archiver/archiver.go @@ -67,30 +67,36 @@ func (e RepoRefNotFoundError) Is(err error) bool { return ok } -// NewRequest creates an archival request, based on the URI. The -// resulting ArchiveRequest is suitable for being passed to Await() -// if it's determined that the request still needs to be satisfied. -func NewRequest(repoID int64, repo *git.Repository, uri string) (*ArchiveRequest, error) { - r := &ArchiveRequest{ - RepoID: repoID, - } - - var ext string +func ParseFileName(uri string) (ext string, tp git.ArchiveType, err error) { switch { case strings.HasSuffix(uri, ".zip"): ext = ".zip" - r.Type = git.ZIP + tp = git.ZIP case strings.HasSuffix(uri, ".tar.gz"): ext = ".tar.gz" - r.Type = git.TARGZ + tp = git.TARGZ case strings.HasSuffix(uri, ".bundle"): ext = ".bundle" - r.Type = git.BUNDLE + tp = git.BUNDLE default: - return nil, ErrUnknownArchiveFormat{RequestFormat: uri} + return "", 0, ErrUnknownArchiveFormat{RequestFormat: uri} + } + return ext, tp, nil +} + +// NewRequest creates an archival request, based on the URI. The +// resulting ArchiveRequest is suitable for being passed to Await() +// if it's determined that the request still needs to be satisfied. +func NewRequest(repoID int64, repo *git.Repository, refName string, fileType git.ArchiveType) (*ArchiveRequest, error) { + if fileType < git.ZIP || fileType > git.BUNDLE { + return nil, ErrUnknownArchiveFormat{RequestFormat: fileType.String()} } - r.refName = strings.TrimSuffix(uri, ext) + r := &ArchiveRequest{ + RepoID: repoID, + refName: refName, + Type: fileType, + } // Get corresponding commit. commitID, err := repo.ConvertToGitID(r.refName) diff --git a/services/repository/archiver/archiver_test.go b/services/repository/archiver/archiver_test.go index b3f3ed7bf3e68..2ab18edf4910e 100644 --- a/services/repository/archiver/archiver_test.go +++ b/services/repository/archiver/archiver_test.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/services/contexttest" _ "code.gitea.io/gitea/models/actions" @@ -31,47 +32,47 @@ func TestArchive_Basic(t *testing.T) { contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() - bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") + bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP) assert.NoError(t, err) assert.NotNil(t, bogusReq) assert.EqualValues(t, firstCommit+".zip", bogusReq.GetArchiveName()) // Check a series of bogus requests. // Step 1, valid commit with a bad extension. - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".dilbert") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, 100) assert.Error(t, err) assert.Nil(t, bogusReq) // Step 2, missing commit. - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff.zip") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff", git.ZIP) assert.Error(t, err) assert.Nil(t, bogusReq) // Step 3, doesn't look like branch/tag/commit. - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db.zip") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db", git.ZIP) assert.Error(t, err) assert.Nil(t, bogusReq) - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master.zip") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master", git.ZIP) assert.NoError(t, err) assert.NotNil(t, bogusReq) assert.EqualValues(t, "master.zip", bogusReq.GetArchiveName()) - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive.zip") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive", git.ZIP) assert.NoError(t, err) assert.NotNil(t, bogusReq) assert.EqualValues(t, "test-archive.zip", bogusReq.GetArchiveName()) // Now two valid requests, firstCommit with valid extensions. - zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") + zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP) assert.NoError(t, err) assert.NotNil(t, zipReq) - tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".tar.gz") + tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.TARGZ) assert.NoError(t, err) assert.NotNil(t, tgzReq) - secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".zip") + secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.ZIP) assert.NoError(t, err) assert.NotNil(t, secondReq) @@ -91,7 +92,7 @@ func TestArchive_Basic(t *testing.T) { // Sleep two seconds to make sure the queue doesn't change. time.Sleep(2 * time.Second) - zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") + zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP) assert.NoError(t, err) // This zipReq should match what's sitting in the queue, as we haven't // let it release yet. From the consumer's point of view, this looks like @@ -106,12 +107,12 @@ func TestArchive_Basic(t *testing.T) { // Now we'll submit a request and TimedWaitForCompletion twice, before and // after we release it. We should trigger both the timeout and non-timeout // cases. - timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".tar.gz") + timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.TARGZ) assert.NoError(t, err) assert.NotNil(t, timedReq) doArchive(db.DefaultContext, timedReq) - zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") + zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP) assert.NoError(t, err) // Now, we're guaranteed to have released the original zipReq from the queue. // Ensure that we don't get handed back the released entry somehow, but they diff --git a/templates/admin/emails/list.tmpl b/templates/admin/emails/list.tmpl index 835b77ea176aa..0dc1fb9d03fe3 100644 --- a/templates/admin/emails/list.tmpl +++ b/templates/admin/emails/list.tmpl @@ -50,10 +50,10 @@ {{svg (Iif .IsPrimary "octicon-check" "octicon-x")}} {{if .CanChange}} - + {{svg (Iif .IsActivated "octicon-check" "octicon-x")}} {{else}} @@ -61,9 +61,10 @@ {{end}} - + {{svg "octicon-trash"}} {{end}} @@ -77,40 +78,24 @@
      {{ctx.Locale.Tr "admin.emails.change_email_header"}}
      -
      +

      {{ctx.Locale.Tr "admin.emails.change_email_text"}}

      - - {{$.CsrfTokenHtml}} + {{$.CsrfTokenHtml}} - - - - + + + + - - - - + + + + -
      - {{template "base/modal_actions_confirm" .}} -
      -
      -
      + {{template "base/modal_actions_confirm" .}} + - - - - {{template "admin/layout_footer" .}} diff --git a/templates/devtest/devtest-footer.tmpl b/templates/devtest/devtest-footer.tmpl new file mode 100644 index 0000000000000..1c755508a5ec1 --- /dev/null +++ b/templates/devtest/devtest-footer.tmpl @@ -0,0 +1,3 @@ +{{/* TODO: the devtest.js is isolated from index.js, so no module is shared and many index.js functions do not work in devtest.ts */}} + +{{template "base/footer" dict}} diff --git a/templates/devtest/devtest-header.tmpl b/templates/devtest/devtest-header.tmpl new file mode 100644 index 0000000000000..a5910b96e6f46 --- /dev/null +++ b/templates/devtest/devtest-header.tmpl @@ -0,0 +1,2 @@ +{{template "base/head" dict}} + diff --git a/templates/devtest/list.tmpl b/templates/devtest/devtest-list.tmpl similarity index 65% rename from templates/devtest/list.tmpl rename to templates/devtest/devtest-list.tmpl index 90b1fcc9d0411..71ee6807f0d36 100644 --- a/templates/devtest/list.tmpl +++ b/templates/devtest/devtest-list.tmpl @@ -1,5 +1,4 @@ -{{template "base/head" .}} - +{{template "devtest/devtest-header"}}
        {{range .SubNames}}
      • {{.}}
      • @@ -11,5 +10,4 @@ ul { line-height: 2em; } - -{{template "base/footer" .}} +{{template "devtest/devtest-footer"}} diff --git a/templates/devtest/fetch-action.tmpl b/templates/devtest/fetch-action.tmpl index 66f41fc6de0b4..4ee824f04bef1 100644 --- a/templates/devtest/fetch-action.tmpl +++ b/templates/devtest/fetch-action.tmpl @@ -1,4 +1,4 @@ -{{template "base/head" .}} +{{template "devtest/devtest-header"}}
        {{template "base/alert" .}}
        @@ -11,6 +11,7 @@ +
        @@ -41,4 +42,4 @@ border: 1px red dashed; /* show the border for demo purpose */ } -{{template "base/footer" .}} +{{template "devtest/devtest-footer"}} diff --git a/templates/devtest/flex-list.tmpl b/templates/devtest/flex-list.tmpl index 015ab1e154631..11d71d56a948f 100644 --- a/templates/devtest/flex-list.tmpl +++ b/templates/devtest/flex-list.tmpl @@ -1,5 +1,4 @@ -{{template "base/head" .}} - +{{template "devtest/devtest-header"}}

        Flex List (standalone)

        @@ -112,4 +111,4 @@
        -{{template "base/footer" .}} +{{template "devtest/devtest-footer"}} diff --git a/templates/devtest/fomantic-dropdown.tmpl b/templates/devtest/fomantic-dropdown.tmpl index 0b9d227220bdb..d41a161e86cd6 100644 --- a/templates/devtest/fomantic-dropdown.tmpl +++ b/templates/devtest/fomantic-dropdown.tmpl @@ -1,5 +1,4 @@ -{{template "base/head" .}} - +{{template "devtest/devtest-header"}}

        Dropdown

        @@ -128,4 +127,4 @@
        -{{template "base/footer" .}} +{{template "devtest/devtest-footer"}} diff --git a/templates/devtest/fomantic-modal.tmpl b/templates/devtest/fomantic-modal.tmpl index f31cdc1983e62..2fbe2bd97dcfa 100644 --- a/templates/devtest/fomantic-modal.tmpl +++ b/templates/devtest/fomantic-modal.tmpl @@ -1,9 +1,9 @@ -{{template "base/head" .}} +{{template "devtest/devtest-header"}}
        {{template "base/alert" .}} - -{{template "base/footer" .}} +{{template "devtest/devtest-footer"}} diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 26737f110e41e..20e0c9db668c9 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -164,24 +164,22 @@ {{ctx.Locale.Tr "repo.pulls.has_viewed_file"}} {{end}} - diff --git a/tests/fuzz/fuzz_test.go b/tests/fuzz/fuzz_test.go index 946f7c46f1122..01d562d995c90 100644 --- a/tests/fuzz/fuzz_test.go +++ b/tests/fuzz/fuzz_test.go @@ -27,6 +27,6 @@ func FuzzMarkdownRenderRaw(f *testing.F) { func FuzzMarkupPostProcess(f *testing.F) { f.Fuzz(func(t *testing.T, data []byte) { setting.AppURL = "http://localhost:3000/" - markup.PostProcess(newFuzzRenderContext(), bytes.NewReader(data), io.Discard) + markup.PostProcessDefault(newFuzzRenderContext(), bytes.NewReader(data), io.Discard) }) } diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 5b9f16ef96dc7..9f75478ebfc4f 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -144,6 +144,18 @@ func TestAPICreateIssue(t *testing.T) { func TestAPICreateIssueParallel(t *testing.T) { defer tests.PrepareTestEnv(t)() + + // FIXME: There seems to be a bug in github.com/mattn/go-sqlite3 with sqlite_unlock_notify, when doing concurrent writes to the same database, + // some requests may get stuck in "go-sqlite3.(*SQLiteRows).Next", "go-sqlite3.(*SQLiteStmt).exec" and "go-sqlite3.unlock_notify_wait", + // because the "unlock_notify_wait" never returns and the internal lock never gets releases. + // + // The trigger is: a previous test created issues and made the real issue indexer queue start processing, then this test does concurrent writing. + // Adding this "Sleep" makes go-sqlite3 "finish" some internal operations before concurrent writes and then won't get stuck. + // To reproduce: make a new test run these 2 tests enough times: + // > func TestBug() { for i := 0; i < 100; i++ { testAPICreateIssue(t); testAPICreateIssueParallel(t) } } + // Usually the test gets stuck in fewer than 10 iterations without this "sleep". + time.Sleep(time.Second) + const body, title = "apiTestBody", "apiTestTitle" repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) diff --git a/tests/integration/api_repo_archive_test.go b/tests/integration/api_repo_archive_test.go index eecb84d5d1b89..8589199da39df 100644 --- a/tests/integration/api_repo_archive_test.go +++ b/tests/integration/api_repo_archive_test.go @@ -59,3 +59,43 @@ func TestAPIDownloadArchive(t *testing.T) { link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name)) MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest) } + +func TestAPIDownloadArchive2(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/zipball/master", user2.Name, repo.Name)) + resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Len(t, bs, 320) + + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/tarball/master", user2.Name, repo.Name)) + resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err = io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Len(t, bs, 266) + + // Must return a link to a commit ID as the "immutable" archive link + linkHeaderRe := regexp.MustCompile(`^<(https?://.*/api/v1/repos/user2/repo1/archive/[a-f0-9]+\.tar\.gz.*)>; rel="immutable"$`) + m := linkHeaderRe.FindStringSubmatch(resp.Header().Get("Link")) + assert.NotEmpty(t, m[1]) + resp = MakeRequest(t, NewRequest(t, "GET", m[1]).AddTokenAuth(token), http.StatusOK) + bs2, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + // The locked URL should give the same bytes as the non-locked one + assert.EqualValues(t, bs, bs2) + + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/bundle/master", user2.Name, repo.Name)) + resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err = io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Len(t, bs, 382) + + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name)) + MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest) +} diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index feb262b50e2cf..f177bd3a23bac 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -565,7 +565,7 @@ func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) { errorParsed := new(errorResponse) require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed)) - assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:repository]") + assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s), required=[read:repository]") } func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) { @@ -708,7 +708,7 @@ func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) { errorParsed := new(errorResponse) require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed)) - assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:user read:organization]") + assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s), required=[read:user read:organization]") } func TestOAuth_GrantScopesClaimPublicOnlyGroups(t *testing.T) { diff --git a/tests/integration/pull_compare_test.go b/tests/integration/pull_compare_test.go index def6506253f90..ad0be72dcbd51 100644 --- a/tests/integration/pull_compare_test.go +++ b/tests/integration/pull_compare_test.go @@ -97,7 +97,7 @@ func TestPullCompare_EnableAllowEditsFromMaintainer(t *testing.T) { user2Session := loginUser(t, "user2") resp = user2Session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("%s/files", prURL)), http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - nodes := htmlDoc.doc.Find(".diff-file-box[data-new-filename=\"README.md\"] .diff-file-header-actions .dropdown .menu a") + nodes := htmlDoc.doc.Find(".diff-file-box[data-new-filename=\"README.md\"] .diff-file-header-actions .tippy-target a") if assert.Equal(t, 1, nodes.Length()) { // there is only "View File" button, no "Edit File" button assert.Equal(t, "View File", nodes.First().Text()) @@ -121,7 +121,7 @@ func TestPullCompare_EnableAllowEditsFromMaintainer(t *testing.T) { // user2 (admin of repo3) goes to the PR files page again resp = user2Session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("%s/files", prURL)), http.StatusOK) htmlDoc = NewHTMLParser(t, resp.Body) - nodes = htmlDoc.doc.Find(".diff-file-box[data-new-filename=\"README.md\"] .diff-file-header-actions .dropdown .menu a") + nodes = htmlDoc.doc.Find(".diff-file-box[data-new-filename=\"README.md\"] .diff-file-header-actions .tippy-target a") if assert.Equal(t, 2, nodes.Length()) { // there are "View File" button and "Edit File" button assert.Equal(t, "View File", nodes.First().Text()) diff --git a/web_src/css/modules/tippy.css b/web_src/css/modules/tippy.css index 53c3d5aaeac6e..55b9751cc635a 100644 --- a/web_src/css/modules/tippy.css +++ b/web_src/css/modules/tippy.css @@ -77,8 +77,10 @@ align-items: center; padding: 9px 18px; color: inherit; + background: inherit; text-decoration: none; gap: 10px; + width: 100%; } .tippy-box[data-theme="menu"] .item:hover { diff --git a/web_src/js/features/admin/emails.ts b/web_src/js/features/admin/emails.ts deleted file mode 100644 index 8e97b67bf930c..0000000000000 --- a/web_src/js/features/admin/emails.ts +++ /dev/null @@ -1,13 +0,0 @@ -import $ from 'jquery'; - -export function initAdminEmails(): void { - $('.link-email-action').on('click', (e) => { - const $this = $(this); - $('#form-uid').val($this.data('uid')); - $('#form-email').val($this.data('email')); - $('#form-primary').val($this.data('primary')); - $('#form-activate').val($this.data('activate')); - $('#change-email-modal').modal('show'); - e.preventDefault(); - }); -} diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index 4aca5ef8f5b58..acce992b902d3 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -1,13 +1,13 @@ -import $ from 'jquery'; import {POST} from '../modules/fetch.ts'; -import {hideElem, showElem, toggleElem} from '../utils/dom.ts'; -import {showErrorToast} from '../modules/toast.ts'; +import {addDelegatedEventListener, hideElem, queryElems, showElem, toggleElem} from '../utils/dom.ts'; +import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {camelize} from 'vue'; export function initGlobalButtonClickOnEnter(): void { - $(document).on('keypress', 'div.ui.button,span.ui.button', (e) => { - if (e.code === ' ' || e.code === 'Enter') { - $(e.target).trigger('click'); + addDelegatedEventListener(document, 'keypress', 'div.ui.button, span.ui.button', (el, e: KeyboardEvent) => { + if (e.code === 'Space' || e.code === 'Enter') { e.preventDefault(); + el.click(); } }); } @@ -40,7 +40,7 @@ export function initGlobalDeleteButton(): void { } } - $(modal).modal({ + fomanticQuery(modal).modal({ closable: false, onApprove: async () => { // if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."` @@ -73,87 +73,93 @@ export function initGlobalDeleteButton(): void { } } -export function initGlobalButtons(): void { - // There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form. - // However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission. - // There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content") - $(document).on('click', 'form button.ui.cancel.button', (e) => { - e.preventDefault(); - }); - - $('.show-panel').on('click', function (e) { - // a '.show-panel' element can show a panel, by `data-panel="selector"` - // if it has "toggle" class, it toggles the panel - e.preventDefault(); - const sel = this.getAttribute('data-panel'); - if (this.classList.contains('toggle')) { - toggleElem(sel); - } else { - showElem(sel); - } - }); +function onShowPanelClick(e) { + // a '.show-panel' element can show a panel, by `data-panel="selector"` + // if it has "toggle" class, it toggles the panel + const el = e.currentTarget; + e.preventDefault(); + const sel = el.getAttribute('data-panel'); + if (el.classList.contains('toggle')) { + toggleElem(sel); + } else { + showElem(sel); + } +} - $('.hide-panel').on('click', function (e) { - // a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"` - e.preventDefault(); - let sel = this.getAttribute('data-panel'); - if (sel) { - hideElem($(sel)); - return; - } - sel = this.getAttribute('data-panel-closest'); - if (sel) { - hideElem($(this).closest(sel)); - return; - } - // should never happen, otherwise there is a bug in code - showErrorToast('Nothing to hide'); - }); +function onHidePanelClick(e) { + // a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"` + const el = e.currentTarget; + e.preventDefault(); + let sel = el.getAttribute('data-panel'); + if (sel) { + hideElem(sel); + return; + } + sel = el.getAttribute('data-panel-closest'); + if (sel) { + hideElem(el.parentNode.closest(sel)); + return; + } + throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code } -export function initGlobalShowModal() { +function onShowModalClick(e) { // A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute. // Each "data-modal-{target}" attribute will be filled to target element's value or text-content. // * First, try to query '#target' + // * Then, try to query '[name=target]' // * Then, try to query '.target' // * Then, try to query 'target' as HTML tag // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set. - $('.show-modal').on('click', function (e) { - e.preventDefault(); - const modalSelector = this.getAttribute('data-modal'); - const $modal = $(modalSelector); - if (!$modal.length) { - throw new Error('no modal for this action'); + const el = e.currentTarget; + e.preventDefault(); + const modalSelector = el.getAttribute('data-modal'); + const elModal = document.querySelector(modalSelector); + if (!elModal) throw new Error('no modal for this action'); + + const modalAttrPrefix = 'data-modal-'; + for (const attrib of el.attributes) { + if (!attrib.name.startsWith(modalAttrPrefix)) { + continue; } - const modalAttrPrefix = 'data-modal-'; - for (const attrib of this.attributes) { - if (!attrib.name.startsWith(modalAttrPrefix)) { - continue; - } - const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length); - const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.'); - // try to find target by: "#target" -> ".target" -> "target tag" - let $attrTarget = $modal.find(`#${attrTargetName}`); - if (!$attrTarget.length) $attrTarget = $modal.find(`.${attrTargetName}`); - if (!$attrTarget.length) $attrTarget = $modal.find(`${attrTargetName}`); - if (!$attrTarget.length) continue; // TODO: show errors in dev mode to remind developers that there is a bug - - if (attrTargetAttr) { - $attrTarget[0][attrTargetAttr] = attrib.value; - } else if ($attrTarget[0].matches('input, textarea')) { - $attrTarget.val(attrib.value); // FIXME: add more supports like checkbox - } else { - $attrTarget[0].textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p - } + const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length); + const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.'); + // try to find target by: "#target" -> "[name=target]" -> ".target" -> " tag" + const attrTarget = elModal.querySelector(`#${attrTargetName}`) || + elModal.querySelector(`[name=${attrTargetName}]`) || + elModal.querySelector(`.${attrTargetName}`) || + elModal.querySelector(`${attrTargetName}`); + if (!attrTarget) { + if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`); + continue; } - $modal.modal('setting', { - onApprove: () => { - // "form-fetch-action" can handle network errors gracefully, - // so keep the modal dialog to make users can re-submit the form if anything wrong happens. - if ($modal.find('.form-fetch-action').length) return false; - }, - }).modal('show'); - }); + if (attrTargetAttr) { + attrTarget[camelize(attrTargetAttr)] = attrib.value; + } else if (attrTarget.matches('input, textarea')) { + attrTarget.value = attrib.value; // FIXME: add more supports like checkbox + } else { + attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p + } + } + + fomanticQuery(elModal).modal('setting', { + onApprove: () => { + // "form-fetch-action" can handle network errors gracefully, + // so keep the modal dialog to make users can re-submit the form if anything wrong happens. + if (elModal.querySelector('.form-fetch-action')) return false; + }, + }).modal('show'); +} + +export function initGlobalButtons(): void { + // There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form. + // However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission. + // There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content") + addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault()); + + queryElems(document, '.show-panel', (el) => el.addEventListener('click', onShowPanelClick)); + queryElems(document, '.hide-panel', (el) => el.addEventListener('click', onHidePanelClick)); + queryElems(document, '.show-modal', (el) => el.addEventListener('click', onShowModalClick)); } diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts index 76973d8ce7e7f..0caa27c0e257f 100644 --- a/web_src/js/features/common-fetch-action.ts +++ b/web_src/js/features/common-fetch-action.ts @@ -1,14 +1,14 @@ import {request} from '../modules/fetch.ts'; import {showErrorToast} from '../modules/toast.ts'; -import {submitEventSubmitter} from '../utils/dom.ts'; -import {htmlEscape} from 'escape-goat'; +import {addDelegatedEventListener, submitEventSubmitter} from '../utils/dom.ts'; import {confirmModal} from './comp/ConfirmModal.ts'; +import type {RequestOpts} from '../types.ts'; const {appSubUrl, i18n} = window.config; // fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location" // more details are in the backend's fetch-redirect handler -function fetchActionDoRedirect(redirect) { +function fetchActionDoRedirect(redirect: string) { const form = document.createElement('form'); const input = document.createElement('input'); form.method = 'post'; @@ -21,7 +21,7 @@ function fetchActionDoRedirect(redirect) { form.submit(); } -async function fetchActionDoRequest(actionElem, url, opt) { +async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) { try { const resp = await request(url, opt); if (resp.status === 200) { @@ -55,11 +55,8 @@ async function fetchActionDoRequest(actionElem, url, opt) { actionElem.classList.remove('is-loading', 'loading-icon-2px'); } -async function formFetchAction(e) { - if (!e.target.classList.contains('form-fetch-action')) return; - +async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) { e.preventDefault(); - const formEl = e.target; if (formEl.classList.contains('is-loading')) return; formEl.classList.add('is-loading'); @@ -77,7 +74,7 @@ async function formFetchAction(e) { } let reqUrl = formActionUrl; - const reqOpt = {method: formMethod.toUpperCase()}; + const reqOpt = {method: formMethod.toUpperCase(), body: null}; if (formMethod.toLowerCase() === 'get') { const params = new URLSearchParams(); for (const [key, value] of formData) { @@ -95,34 +92,36 @@ async function formFetchAction(e) { await fetchActionDoRequest(formEl, reqUrl, reqOpt); } -async function linkAction(e) { +async function linkAction(el: HTMLElement, e: Event) { // A "link-action" can post AJAX request to its "data-url" // Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading. // If the "link-action" has "data-modal-confirm" attribute, a confirm modal dialog will be shown before taking action. - const el = e.target.closest('.link-action'); - if (!el) return; - e.preventDefault(); const url = el.getAttribute('data-url'); const doRequest = async () => { - el.disabled = true; + if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but A doesn't have disabled attribute await fetchActionDoRequest(el, url, {method: 'POST'}); - el.disabled = false; + if ('disabled' in el) el.disabled = false; }; - const modalConfirmContent = htmlEscape(el.getAttribute('data-modal-confirm') || ''); + const modalConfirmContent = el.getAttribute('data-modal-confirm') || + el.getAttribute('data-modal-confirm-content') || ''; if (!modalConfirmContent) { await doRequest(); return; } const isRisky = el.classList.contains('red') || el.classList.contains('negative'); - if (await confirmModal(modalConfirmContent, {confirmButtonColor: isRisky ? 'red' : 'primary'})) { + if (await confirmModal({ + header: el.getAttribute('data-modal-confirm-header') || '', + content: modalConfirmContent, + confirmButtonColor: isRisky ? 'red' : 'primary', + })) { await doRequest(); } } export function initGlobalFetchAction() { - document.addEventListener('submit', formFetchAction); - document.addEventListener('click', linkAction); + addDelegatedEventListener(document, 'click', '.form-fetch-action', formFetchAction); + addDelegatedEventListener(document, 'click', '.link-action', linkAction); } diff --git a/web_src/js/features/comp/ConfirmModal.ts b/web_src/js/features/comp/ConfirmModal.ts index 93459ae1a0f9b..bf645cdbdb420 100644 --- a/web_src/js/features/comp/ConfirmModal.ts +++ b/web_src/js/features/comp/ConfirmModal.ts @@ -5,10 +5,12 @@ import {fomanticQuery} from '../../modules/fomantic/base.ts'; const {i18n} = window.config; -export function confirmModal(content, {confirmButtonColor = 'primary'} = {}) { +export function confirmModal({header = '', content = '', confirmButtonColor = 'primary'} = {}) { return new Promise((resolve) => { + const headerHtml = header ? `
        ${htmlEscape(header)}
        ` : ''; const modal = createElementFromHTML(`