mirror of
https://github.com/go-gitea/gitea.git
synced 2024-11-27 20:49:33 +08:00
Merge branch 'main' into patch-18
This commit is contained in:
commit
1b63310bd1
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
//
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -22,8 +22,6 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type codeSearchResult struct {
|
||||
|
@ -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("</body></html>"),
|
||||
))
|
||||
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
|
||||
|
@ -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()))
|
||||
}
|
||||
|
@ -445,14 +445,14 @@ func Test_ParseClusterFuzz(t *testing.T) {
|
||||
data := "<A><maTH><tr><MN><bodY ÿ><temPlate></template><tH><tr></A><tH><d<bodY "
|
||||
|
||||
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.NotContains(t, res.String(), "<html")
|
||||
|
||||
data = "<!DOCTYPE html>\n<A><maTH><tr><MN><bodY ÿ><temPlate></template><tH><tr></A><tH><d<bodY "
|
||||
|
||||
res.Reset()
|
||||
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.NotContains(t, res.String(), "<html")
|
||||
@ -464,7 +464,7 @@ func TestPostProcess_RenderDocument(t *testing.T) {
|
||||
|
||||
test := func(input, expected string) {
|
||||
var res strings.Builder
|
||||
err := markup.PostProcess(markup.NewTestRenderContext(markup.TestAppURL, map[string]string{"user": "go-gitea", "repo": "gitea"}), strings.NewReader(input), &res)
|
||||
err := markup.PostProcessDefault(markup.NewTestRenderContext(markup.TestAppURL, map[string]string{"user": "go-gitea", "repo": "gitea"}), strings.NewReader(input), &res)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res.String()))
|
||||
}
|
||||
@ -501,7 +501,7 @@ func TestIssue16020(t *testing.T) {
|
||||
data := `<img src="data:image/png;base64,i//V"/>`
|
||||
|
||||
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, `<a href="http://domain/org/repo/compare/783b039...da951ce" class="compare"><code class="nohighlight">783b039...da951ce</code></a>`, res.String())
|
||||
|
@ -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)
|
||||
|
@ -85,94 +85,13 @@ func TestRender_Images(t *testing.T) {
|
||||
`<p><a href="`+href+`" rel="nofollow"><img src="`+result+`" alt="`+title+`"/></a></p>`)
|
||||
}
|
||||
|
||||
func testAnswers(baseURL string) []string {
|
||||
return []string{
|
||||
`<p>Wiki! Enjoy :)</p>
|
||||
<ul>
|
||||
<li><a href="` + baseURL + `/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li>
|
||||
<li><a href="` + baseURL + `/Tips" rel="nofollow">Tips</a></li>
|
||||
</ul>
|
||||
<p>See commit <a href="/` + testRepoOwnerName + `/` + testRepoName + `/commit/65f1bf27bc" rel="nofollow"><code>65f1bf27bc</code></a></p>
|
||||
<p>Ideas and codes</p>
|
||||
<ul>
|
||||
<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/ocornut/imgui/issues/786" class="ref-issue" rel="nofollow">ocornut/imgui#786</a></li>
|
||||
<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="` + FullURL + `issues/786" class="ref-issue" rel="nofollow">#786</a></li>
|
||||
<li>Node graph editors <a href="https://github.com/ocornut/imgui/issues/306" rel="nofollow">https://github.com/ocornut/imgui/issues/306</a></li>
|
||||
<li><a href="` + baseURL + `/memory_editor_example" rel="nofollow">Memory Editor</a></li>
|
||||
<li><a href="` + baseURL + `/plot_var_example" rel="nofollow">Plot var helper</a></li>
|
||||
</ul>
|
||||
`,
|
||||
`<h2 id="user-content-what-is-wine-staging">What is Wine Staging?</h2>
|
||||
<p><strong>Wine Staging</strong> on website <a href="http://wine-staging.com" rel="nofollow">wine-staging.com</a>.</p>
|
||||
<h2 id="user-content-quick-links">Quick Links</h2>
|
||||
<p>Here are some links to the most important topics. You can find the full list of pages at the sidebar.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><a href="` + baseURL + `/images/icon-install.png" rel="nofollow"><img src="` + baseURL + `/images/icon-install.png" title="icon-install.png" alt="images/icon-install.png"/></a></th>
|
||||
<th><a href="` + baseURL + `/Installation" rel="nofollow">Installation</a></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a href="` + baseURL + `/images/icon-usage.png" rel="nofollow"><img src="` + baseURL + `/images/icon-usage.png" title="icon-usage.png" alt="images/icon-usage.png"/></a></td>
|
||||
<td><a href="` + baseURL + `/Usage" rel="nofollow">Usage</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
`<p><a href="http://www.excelsiorjet.com/" rel="nofollow">Excelsior JET</a> allows you to create native executables for Windows, Linux and Mac OS X.</p>
|
||||
<ol>
|
||||
<li><a href="https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop" rel="nofollow">Package your libGDX application</a><br/>
|
||||
<a href="` + baseURL + `/images/1.png" rel="nofollow"><img src="` + baseURL + `/images/1.png" title="1.png" alt="images/1.png"/></a></li>
|
||||
<li>Perform a test run by hitting the Run! button.<br/>
|
||||
<a href="` + baseURL + `/images/2.png" rel="nofollow"><img src="` + baseURL + `/images/2.png" title="2.png" alt="images/2.png"/></a></li>
|
||||
</ol>
|
||||
<h2 id="user-content-custom-id">More tests</h2>
|
||||
<p>(from <a href="https://www.markdownguide.org/extended-syntax/" rel="nofollow">https://www.markdownguide.org/extended-syntax/</a>)</p>
|
||||
<h3 id="user-content-checkboxes">Checkboxes</h3>
|
||||
<ul>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="434"/>unchecked</li>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="450" checked=""/>checked</li>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="464"/>still unchecked</li>
|
||||
</ul>
|
||||
<h3 id="user-content-definition-list">Definition list</h3>
|
||||
<dl>
|
||||
<dt>First Term</dt>
|
||||
<dd>This is the definition of the first term.</dd>
|
||||
<dt>Second Term</dt>
|
||||
<dd>This is one definition of the second term.</dd>
|
||||
<dd>This is another definition of the second term.</dd>
|
||||
</dl>
|
||||
<h3 id="user-content-footnotes">Footnotes</h3>
|
||||
<p>Here is a simple footnote,<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">1</a></sup> and here is a longer one.<sup id="fnref:user-content-bignote"><a href="#fn:user-content-bignote" rel="nofollow">2</a></sup></p>
|
||||
<div>
|
||||
<hr/>
|
||||
<ol>
|
||||
<li id="fn:user-content-1">
|
||||
<p>This is the first footnote. <a href="#fnref:user-content-1" rel="nofollow">↩︎</a></p>
|
||||
</li>
|
||||
<li id="fn:user-content-bignote">
|
||||
<p>Here is one with multiple paragraphs and code.</p>
|
||||
<p>Indent paragraphs to include them in the footnote.</p>
|
||||
<p><code>{ my code }</code></p>
|
||||
<p>Add as many paragraphs as you like. <a href="#fnref:user-content-bignote" rel="nofollow">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
`, `<ul>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="3"/> If you want to rebase/retry this PR, click this checkbox.</li>
|
||||
</ul>
|
||||
<hr/>
|
||||
<p>This PR has been generated by <a href="https://github.com/renovatebot/renovate" rel="nofollow">Renovate Bot</a>.</p>
|
||||
`,
|
||||
}
|
||||
}
|
||||
func TestTotal_RenderString(t *testing.T) {
|
||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
|
||||
// Test cases without ambiguous links
|
||||
var sameCases = []string{
|
||||
// dear imgui wiki markdown extract: special wiki syntax
|
||||
`Wiki! Enjoy :)
|
||||
// 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]]
|
||||
|
||||
@ -185,8 +104,8 @@ Ideas and codes
|
||||
- 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 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
|
||||
@ -196,8 +115,8 @@ Here are some links to the most important topics. You can find the full list of
|
||||
|--------------------------------|----------------------------------------------------------|
|
||||
| [[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.
|
||||
// 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]]
|
||||
@ -237,7 +156,7 @@ Here is a simple footnote,[^1] and here is a longer one.[^bignote]
|
||||
|
||||
Add as many paragraphs as you like.
|
||||
`,
|
||||
`
|
||||
`
|
||||
- [ ] <!-- rebase-check --> If you want to rebase/retry this PR, click this checkbox.
|
||||
|
||||
---
|
||||
@ -245,21 +164,101 @@ Here is a simple footnote,[^1] and here is a longer one.[^bignote]
|
||||
This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
|
||||
|
||||
<!-- test-comment -->`,
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := ""
|
||||
testAnswers := []string{
|
||||
`<p>Wiki! Enjoy :)</p>
|
||||
<ul>
|
||||
<li><a href="` + baseURL + `/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li>
|
||||
<li><a href="` + baseURL + `/Tips" rel="nofollow">Tips</a></li>
|
||||
</ul>
|
||||
<p>See commit <a href="/` + testRepoOwnerName + `/` + testRepoName + `/commit/65f1bf27bc" rel="nofollow"><code>65f1bf27bc</code></a></p>
|
||||
<p>Ideas and codes</p>
|
||||
<ul>
|
||||
<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="http://localhost:3000/ocornut/imgui/issues/786" class="ref-issue" rel="nofollow">ocornut/imgui#786</a></li>
|
||||
<li>Bezier widget (by <a href="/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="` + FullURL + `issues/786" class="ref-issue" rel="nofollow">#786</a></li>
|
||||
<li>Node graph editors <a href="https://github.com/ocornut/imgui/issues/306" rel="nofollow">https://github.com/ocornut/imgui/issues/306</a></li>
|
||||
<li><a href="` + baseURL + `/memory_editor_example" rel="nofollow">Memory Editor</a></li>
|
||||
<li><a href="` + baseURL + `/plot_var_example" rel="nofollow">Plot var helper</a></li>
|
||||
</ul>
|
||||
`,
|
||||
`<h2 id="user-content-what-is-wine-staging">What is Wine Staging?</h2>
|
||||
<p><strong>Wine Staging</strong> on website <a href="http://wine-staging.com" rel="nofollow">wine-staging.com</a>.</p>
|
||||
<h2 id="user-content-quick-links">Quick Links</h2>
|
||||
<p>Here are some links to the most important topics. You can find the full list of pages at the sidebar.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><a href="` + baseURL + `/images/icon-install.png" rel="nofollow"><img src="` + baseURL + `/images/icon-install.png" title="icon-install.png" alt="images/icon-install.png"/></a></th>
|
||||
<th><a href="` + baseURL + `/Installation" rel="nofollow">Installation</a></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a href="` + baseURL + `/images/icon-usage.png" rel="nofollow"><img src="` + baseURL + `/images/icon-usage.png" title="icon-usage.png" alt="images/icon-usage.png"/></a></td>
|
||||
<td><a href="` + baseURL + `/Usage" rel="nofollow">Usage</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
`<p><a href="http://www.excelsiorjet.com/" rel="nofollow">Excelsior JET</a> allows you to create native executables for Windows, Linux and Mac OS X.</p>
|
||||
<ol>
|
||||
<li><a href="https://github.com/libgdx/libgdx/wiki/Gradle-on-the-Commandline#packaging-for-the-desktop" rel="nofollow">Package your libGDX application</a>
|
||||
<a href="` + baseURL + `/images/1.png" rel="nofollow"><img src="` + baseURL + `/images/1.png" title="1.png" alt="images/1.png"/></a></li>
|
||||
<li>Perform a test run by hitting the Run! button.
|
||||
<a href="` + baseURL + `/images/2.png" rel="nofollow"><img src="` + baseURL + `/images/2.png" title="2.png" alt="images/2.png"/></a></li>
|
||||
</ol>
|
||||
<h2 id="user-content-custom-id">More tests</h2>
|
||||
<p>(from <a href="https://www.markdownguide.org/extended-syntax/" rel="nofollow">https://www.markdownguide.org/extended-syntax/</a>)</p>
|
||||
<h3 id="user-content-checkboxes">Checkboxes</h3>
|
||||
<ul>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="434"/>unchecked</li>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="450" checked=""/>checked</li>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="464"/>still unchecked</li>
|
||||
</ul>
|
||||
<h3 id="user-content-definition-list">Definition list</h3>
|
||||
<dl>
|
||||
<dt>First Term</dt>
|
||||
<dd>This is the definition of the first term.</dd>
|
||||
<dt>Second Term</dt>
|
||||
<dd>This is one definition of the second term.</dd>
|
||||
<dd>This is another definition of the second term.</dd>
|
||||
</dl>
|
||||
<h3 id="user-content-footnotes">Footnotes</h3>
|
||||
<p>Here is a simple footnote,<sup id="fnref:user-content-1"><a href="#fn:user-content-1" rel="nofollow">1</a></sup> and here is a longer one.<sup id="fnref:user-content-bignote"><a href="#fn:user-content-bignote" rel="nofollow">2</a></sup></p>
|
||||
<div>
|
||||
<hr/>
|
||||
<ol>
|
||||
<li id="fn:user-content-1">
|
||||
<p>This is the first footnote. <a href="#fnref:user-content-1" rel="nofollow">↩︎</a></p>
|
||||
</li>
|
||||
<li id="fn:user-content-bignote">
|
||||
<p>Here is one with multiple paragraphs and code.</p>
|
||||
<p>Indent paragraphs to include them in the footnote.</p>
|
||||
<p><code>{ my code }</code></p>
|
||||
<p>Add as many paragraphs as you like. <a href="#fnref:user-content-bignote" rel="nofollow">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
`,
|
||||
`<ul>
|
||||
<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="3"/> If you want to rebase/retry this PR, click this checkbox.</li>
|
||||
</ul>
|
||||
<hr/>
|
||||
<p>This PR has been generated by <a href="https://github.com/renovatebot/renovate" rel="nofollow">Renovate Bot</a>.</p>
|
||||
`,
|
||||
}
|
||||
|
||||
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 := `<p><a href="/image1" target="_blank" rel="nofollow noopener"><img src="/image1" alt="image1"></a><br>
|
||||
expected := `<p><a href="/image1" target="_blank" rel="nofollow noopener"><img src="/image1" alt="image1"></a>
|
||||
<a href="/image2" target="_blank" rel="nofollow noopener"><img src="/image2" alt="image2"></a></p>
|
||||
`
|
||||
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: `<p>space @mention-user<br/>
|
||||
/just/a/path.bin<br/>
|
||||
<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a><br/>
|
||||
<a href="/file.bin" rel="nofollow">local link</a><br/>
|
||||
<a href="https://example.com" rel="nofollow">remote link</a><br/>
|
||||
<a href="/file.bin" rel="nofollow">local link</a><br/>
|
||||
<a href="https://example.com" rel="nofollow">remote link</a><br/>
|
||||
<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a><br/>
|
||||
<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a><br/>
|
||||
<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a><br/>
|
||||
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a><br/>
|
||||
<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a><br/>
|
||||
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a><br/>
|
||||
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a><br/>
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare<br/>
|
||||
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a><br/>
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit<br/>
|
||||
<span class="emoji" aria-label="thumbs up">👍</span><br/>
|
||||
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a><br/>
|
||||
@mention-user test<br/>
|
||||
#123<br/>
|
||||
expected := `<p>space @mention-user<br/>
|
||||
/just/a/path.bin
|
||||
<a href="https://example.com/file.bin" rel="nofollow">https://example.com/file.bin</a>
|
||||
<a href="/file.bin" rel="nofollow">local link</a>
|
||||
<a href="https://example.com" rel="nofollow">remote link</a>
|
||||
<a href="/file.bin" rel="nofollow">local link</a>
|
||||
<a href="https://example.com" rel="nofollow">remote link</a>
|
||||
<a href="/image.jpg" target="_blank" rel="nofollow noopener"><img src="/image.jpg" alt="local image"/></a>
|
||||
<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a>
|
||||
<a href="/path/file" target="_blank" rel="nofollow noopener"><img src="/path/file" alt="local image"/></a>
|
||||
<a href="https://example.com/image.jpg" target="_blank" rel="nofollow noopener"><img src="https://example.com/image.jpg" alt="remote image"/></a>
|
||||
<a href="/image.jpg" rel="nofollow"><img src="/image.jpg" title="local image" alt="local image"/></a>
|
||||
<a href="https://example.com/image.jpg" rel="nofollow"><img src="https://example.com/image.jpg" title="remote link" alt="remote link"/></a>
|
||||
<a href="https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash" rel="nofollow"><code>88fc37a3c0...12fc37a3c0 (hash)</code></a>
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare
|
||||
<a href="https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb" rel="nofollow"><code>88fc37a3c0</code></a>
|
||||
com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
|
||||
<span class="emoji" aria-label="thumbs up">👍</span>
|
||||
<a href="mailto:mail@domain.com" rel="nofollow">mail@domain.com</a>
|
||||
@mention-user test
|
||||
#123
|
||||
space</p>
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -28,14 +28,6 @@ const (
|
||||
)
|
||||
|
||||
var RenderBehaviorForTesting struct {
|
||||
// Markdown line break rendering has 2 default behaviors:
|
||||
// * Use hard: replace "\n" with "<br>" 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)
|
||||
}
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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
|
||||
`<p>Wiki! Enjoy :)</p>
|
||||
<ul>
|
||||
<li><a href="http://localhost:3000/gogits/gogs/wiki/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li>
|
||||
<li><a href="http://localhost:3000/gogits/gogs/wiki/Tips" rel="nofollow">Tips</a></li>
|
||||
<li><a href="http://localhost:3000/user2/repo1/wiki/Links" rel="nofollow">Links, Language bindings, Engine bindings</a></li>
|
||||
<li><a href="http://localhost:3000/user2/repo1/wiki/Tips" rel="nofollow">Tips</a></li>
|
||||
<li>Bezier widget (by <a href="http://localhost:3000/r-lyeh" rel="nofollow">@r-lyeh</a>) <a href="https://github.com/ocornut/imgui/issues/786" rel="nofollow">https://github.com/ocornut/imgui/issues/786</a></li>
|
||||
</ul>
|
||||
`,
|
||||
// Guard wiki sidebar: special syntax
|
||||
`[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`,
|
||||
// rendered
|
||||
`<p><a href="http://localhost:3000/gogits/gogs/wiki/Guardfile-DSL---Configuring-Guard" rel="nofollow">Guardfile-DSL / Configuring-Guard</a></p>
|
||||
`<p><a href="http://localhost:3000/user2/repo1/wiki/Guardfile-DSL---Configuring-Guard" rel="nofollow">Guardfile-DSL / Configuring-Guard</a></p>
|
||||
`,
|
||||
// special syntax
|
||||
`[[Name|Link]]`,
|
||||
// rendered
|
||||
`<p><a href="http://localhost:3000/gogits/gogs/wiki/Link" rel="nofollow">Name</a></p>
|
||||
`<p><a href="http://localhost:3000/user2/repo1/wiki/Link" rel="nofollow">Name</a></p>
|
||||
`,
|
||||
// empty
|
||||
``,
|
||||
@ -119,8 +133,8 @@ Here are some links to the most important topics. You can find the full list of
|
||||
<p><strong>Wine Staging</strong> on website <a href="http://wine-staging.com" rel="nofollow">wine-staging.com</a>.</p>
|
||||
<h2 id="user-content-quick-links">Quick Links</h2>
|
||||
<p>Here are some links to the most important topics. You can find the full list of pages at the sidebar.</p>
|
||||
<p><a href="http://localhost:3000/gogits/gogs/wiki/Configuration" rel="nofollow">Configuration</a>
|
||||
<a href="http://localhost:3000/gogits/gogs/wiki/raw/images/icon-bug.png" rel="nofollow"><img src="http://localhost:3000/gogits/gogs/wiki/raw/images/icon-bug.png" title="icon-bug.png" alt="images/icon-bug.png"/></a></p>
|
||||
<p><a href="http://localhost:3000/user2/repo1/wiki/Configuration" rel="nofollow">Configuration</a>
|
||||
<a href="http://localhost:3000/user2/repo1/wiki/raw/images/icon-bug.png" rel="nofollow"><img src="http://localhost:3000/user2/repo1/wiki/raw/images/icon-bug.png" title="icon-bug.png" alt="images/icon-bug.png"/></a></p>
|
||||
`,
|
||||
}
|
||||
|
||||
@ -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, `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/gogits/gogs/media/branch/main/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/gogits/gogs/media/branch/main/image.png" alt="Image"/></a></p>
|
||||
testRenderMarkdown(t, "gfm", false, input, `<p><a href="http://localhost:3000/user2/repo1/src/branch/main/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/user2/repo1/media/branch/main/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/user2/repo1/media/branch/main/image.png" alt="Image"/></a></p>
|
||||
`, http.StatusOK)
|
||||
|
||||
testRenderMarkdown(t, "gfm", false, input, `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/gogits/gogs/media/branch/main/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/gogits/gogs/media/branch/main/image.png" alt="Image"/></a></p>
|
||||
testRenderMarkdown(t, "gfm", false, input, `<p><a href="http://localhost:3000/user2/repo1/src/branch/main/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/user2/repo1/media/branch/main/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/user2/repo1/media/branch/main/image.png" alt="Image"/></a></p>
|
||||
`, http.StatusOK)
|
||||
|
||||
testRenderMarkup(t, "gfm", false, "", input, `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/gogits/gogs/media/branch/main/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/gogits/gogs/media/branch/main/image.png" alt="Image"/></a></p>
|
||||
testRenderMarkup(t, "gfm", false, "", input, `<p><a href="http://localhost:3000/user2/repo1/src/branch/main/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/user2/repo1/media/branch/main/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/user2/repo1/media/branch/main/image.png" alt="Image"/></a></p>
|
||||
`, http.StatusOK)
|
||||
|
||||
testRenderMarkup(t, "file", false, "path/new-file.md", input, `<p><a href="http://localhost:3000/gogits/gogs/src/branch/main/path/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/gogits/gogs/media/branch/main/path/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/gogits/gogs/media/branch/main/path/image.png" alt="Image"/></a></p>
|
||||
testRenderMarkup(t, "file", false, "path/new-file.md", input, `<p><a href="http://localhost:3000/user2/repo1/src/branch/main/path/test.md" rel="nofollow">Link</a>
|
||||
<a href="http://localhost:3000/user2/repo1/media/branch/main/path/image.png" target="_blank" rel="nofollow noopener"><img src="http://localhost:3000/user2/repo1/media/branch/main/path/image.png" alt="Image"/></a></p>
|
||||
`, 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 {
|
||||
|
53
routers/api/v1/repo/download.go
Normal file
53
routers/api/v1/repo/download.go
Normal file
@ -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)
|
||||
}
|
@ -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() {
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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))
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -50,10 +50,10 @@
|
||||
<td>{{svg (Iif .IsPrimary "octicon-check" "octicon-x")}}</td>
|
||||
<td>
|
||||
{{if .CanChange}}
|
||||
<a class="link-email-action" href data-uid="{{.UID}}"
|
||||
data-email="{{.Email}}"
|
||||
data-primary="{{if .IsPrimary}}1{{else}}0{{end}}"
|
||||
data-activate="{{if .IsActivated}}0{{else}}1{{end}}">
|
||||
<a class="show-modal" href data-modal="#change-email-modal" data-modal-uid="{{.UID}}"
|
||||
data-modal-email="{{.Email}}"
|
||||
data-modal-primary="{{if .IsPrimary}}1{{else}}0{{end}}"
|
||||
data-modal-activate="{{if .IsActivated}}0{{else}}1{{end}}">
|
||||
{{svg (Iif .IsActivated "octicon-check" "octicon-x")}}
|
||||
</a>
|
||||
{{else}}
|
||||
@ -61,9 +61,10 @@
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="tw-flex tw-gap-2">
|
||||
<a class="delete-button" href="" data-url="{{$.Link}}/delete" data-id="{{.ID}}" data-data-uid="{{.UID}}">{{svg "octicon-trash"}}</a>
|
||||
</div>
|
||||
<a class="link-action negative" href data-url="{{$.Link}}/delete?id={{.ID}}&uid={{.UID}}"
|
||||
data-modal-confirm-header="{{ctx.Locale.Tr "admin.emails.delete"}}"
|
||||
data-modal-confirm-content="{{ctx.Locale.Tr "admin.emails.delete_desc"}}"
|
||||
>{{svg "octicon-trash"}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@ -77,40 +78,24 @@
|
||||
<div class="header">
|
||||
{{ctx.Locale.Tr "admin.emails.change_email_header"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<form class="content ui form" action="{{AppSubUrl}}/-/admin/emails/activate" method="post">
|
||||
<p class="center">{{ctx.Locale.Tr "admin.emails.change_email_text"}}</p>
|
||||
|
||||
<form class="ui form" id="email-action-form" action="{{AppSubUrl}}/-/admin/emails/activate" method="post">
|
||||
{{$.CsrfTokenHtml}}
|
||||
{{$.CsrfTokenHtml}}
|
||||
|
||||
<input type="hidden" id="query-sort" name="sort" value="{{.SortType}}">
|
||||
<input type="hidden" id="query-keyword" name="q" value="{{.Keyword}}">
|
||||
<input type="hidden" id="query-primary" name="is_primary" value="{{.IsPrimary}}" required>
|
||||
<input type="hidden" id="query-activated" name="is_activated" value="{{.IsActivated}}" required>
|
||||
<input type="hidden" name="sort" value="{{.SortType}}">
|
||||
<input type="hidden" name="q" value="{{.Keyword}}">
|
||||
<input type="hidden" name="is_primary" value="{{.IsPrimary}}">
|
||||
<input type="hidden" name="is_activated" value="{{.IsActivated}}">
|
||||
|
||||
<input type="hidden" id="form-uid" name="uid" value="" required>
|
||||
<input type="hidden" id="form-email" name="email" value="" required>
|
||||
<input type="hidden" id="form-primary" name="primary" value="" required>
|
||||
<input type="hidden" id="form-activate" name="activate" value="" required>
|
||||
<input type="hidden" name="uid">
|
||||
<input type="hidden" name="email">
|
||||
<input type="hidden" name="primary">
|
||||
<input type="hidden" name="activate">
|
||||
|
||||
<div class="center">
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="ui g-modal-confirm delete modal">
|
||||
<div class="header">
|
||||
{{svg "octicon-trash"}}
|
||||
{{ctx.Locale.Tr "admin.emails.delete"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
{{ctx.Locale.Tr "admin.emails.delete_desc"}}
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
||||
|
3
templates/devtest/devtest-footer.tmpl
Normal file
3
templates/devtest/devtest-footer.tmpl
Normal file
@ -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 */}}
|
||||
<script src="{{AssetUrlPrefix}}/js/devtest.js?v={{AssetVersion}}"></script>
|
||||
{{template "base/footer" dict}}
|
2
templates/devtest/devtest-header.tmpl
Normal file
2
templates/devtest/devtest-header.tmpl
Normal file
@ -0,0 +1,2 @@
|
||||
{{template "base/head" dict}}
|
||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}">
|
@ -1,5 +1,4 @@
|
||||
{{template "base/head" .}}
|
||||
|
||||
{{template "devtest/devtest-header"}}
|
||||
<ul>
|
||||
{{range .SubNames}}
|
||||
<li><a href="{{AppSubUrl}}/devtest/{{.}}">{{.}}</a></li>
|
||||
@ -11,5 +10,4 @@ ul {
|
||||
line-height: 2em;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{template "base/footer" .}}
|
||||
{{template "devtest/devtest-footer"}}
|
@ -1,4 +1,4 @@
|
||||
{{template "base/head" .}}
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest ui container">
|
||||
{{template "base/alert" .}}
|
||||
<div>
|
||||
@ -11,6 +11,7 @@
|
||||
<button class="link-action" data-url="fetch-action-test?k=1">test action</button>
|
||||
<button class="link-action" data-url="fetch-action-test?k=1" data-modal-confirm="confirm?">test with confirm</button>
|
||||
<button class="ui red button link-action" data-url="fetch-action-test?k=1" data-modal-confirm="confirm?">test with risky confirm</button>
|
||||
<button class="ui button link-action" data-url="fetch-action-test?k=1" data-modal-confirm-header="confirm header" data-modal-confirm-content="confirm content">test with confirm header</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -41,4 +42,4 @@
|
||||
border: 1px red dashed; /* show the border for demo purpose */
|
||||
}
|
||||
</style>
|
||||
{{template "base/footer" .}}
|
||||
{{template "devtest/devtest-footer"}}
|
||||
|
@ -1,5 +1,4 @@
|
||||
{{template "base/head" .}}
|
||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}">
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest">
|
||||
<div class="ui container">
|
||||
<h1>Flex List (standalone)</h1>
|
||||
@ -112,4 +111,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "devtest/devtest-footer"}}
|
||||
|
@ -1,5 +1,4 @@
|
||||
{{template "base/head" .}}
|
||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}">
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest ui container">
|
||||
<div>
|
||||
<h2>Dropdown</h2>
|
||||
@ -128,4 +127,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "devtest/devtest-footer"}}
|
||||
|
@ -1,9 +1,9 @@
|
||||
{{template "base/head" .}}
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest ui container">
|
||||
{{template "base/alert" .}}
|
||||
<div class="modal-buttons flex-text-block tw-flex-wrap"></div>
|
||||
<script type="module">
|
||||
for (const el of $('.ui.modal')) {
|
||||
for (const el of $('.ui.modal:not([data-skip-button])')) {
|
||||
const $btn = $('<button class="ui button">').text(`${el.id}`).on('click', () => {
|
||||
$(el).modal({onApprove() {alert('confirmed')}}).modal('show');
|
||||
});
|
||||
@ -69,5 +69,27 @@
|
||||
<div class="content">hello, this is the modal dialog content, this is a dangerous operation</div>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" "I know and must do this is dangerous operation")}}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<button class="show-modal" data-modal="#test-modal-fill-values"
|
||||
data-modal-fill-by-id="value for id"
|
||||
data-modal-fill-by-name="value for name"
|
||||
data-modal-fill-by-class="value for class"
|
||||
data-modal-p="value for tag"
|
||||
data-modal-a.text-content="fill with attr"
|
||||
>test-modal-fill-values</button>
|
||||
|
||||
<div id="test-modal-fill-values" class="ui mini modal" data-skip-button>
|
||||
<div class="header">Modal dialog (fill values)</div>
|
||||
<form class="content">
|
||||
<div id="fill-by-id"></div>
|
||||
<input name="fill-by-name">
|
||||
<div class="fill-by-class"></div>
|
||||
<p></p>
|
||||
<a href="#">link</a>
|
||||
{{template "base/modal_actions_confirm" dict}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "devtest/devtest-footer"}}
|
||||
|
@ -1,5 +1,4 @@
|
||||
{{template "base/head" .}}
|
||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}">
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest ui container">
|
||||
<div>
|
||||
<h1>Link</h1>
|
||||
@ -193,4 +192,4 @@
|
||||
<button class="{{if true}}tw-bg-red{{end}} tw-p-5 tw-border tw-rounded hover:tw-bg-blue active:tw-bg-yellow">Button</button>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "devtest/devtest-footer"}}
|
||||
|
16
templates/devtest/global-button.tmpl
Normal file
16
templates/devtest/global-button.tmpl
Normal file
@ -0,0 +1,16 @@
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest ui container">
|
||||
<div>
|
||||
<h1>Show/Hide panel</h1>
|
||||
<div>
|
||||
<!-- to test Space/Enter also works on non-button buttons with children -->
|
||||
<div tabindex="0" class="ui button show-panel toggle" data-panel="#devtest-panel-show-hide"><span>Toggle panel 1</span></div>
|
||||
<span tabindex="0" class="ui button show-panel" data-panel="#devtest-panel-show-hide"><span>Show panel 1</span></span>
|
||||
</div>
|
||||
<div id="devtest-panel-show-hide">
|
||||
<div>Panel 1 content</div>
|
||||
<div class="ui button hide-panel" data-panel-closest="div">Hide panel 1</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "devtest/devtest-footer"}}
|
@ -1,5 +1,4 @@
|
||||
{{template "base/head" .}}
|
||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}">
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest ui container">
|
||||
<div>
|
||||
<h1>Label</h1>
|
||||
@ -24,4 +23,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "devtest/devtest-footer"}}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{{template "base/head" .}}
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content devtest">
|
||||
<div class="tw-flex">
|
||||
<div class="tw-w-4/5">
|
||||
@ -9,4 +9,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
{{template "devtest/devtest-footer"}}
|
||||
|
@ -1,5 +1,4 @@
|
||||
{{template "base/head" .}}
|
||||
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div>
|
||||
<h1>Toast</h1>
|
||||
<div>
|
||||
@ -9,7 +8,4 @@
|
||||
<button class="ui button toast-test-button" data-toast-level="error" data-toast-message="very looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong message">Show Error Toast (long)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{AssetUrlPrefix}}/js/devtest.js?v={{AssetVersion}}"></script>
|
||||
|
||||
{{template "base/footer" .}}
|
||||
{{template "devtest/devtest-footer"}}
|
||||
|
@ -164,24 +164,22 @@
|
||||
<input type="checkbox" name="{{$file.GetDiffFileName}}" autocomplete="off"{{if $file.IsViewed}} checked{{end}}> {{ctx.Locale.Tr "repo.pulls.has_viewed_file"}}
|
||||
</label>
|
||||
{{end}}
|
||||
<div class="ui dropdown basic">
|
||||
{{svg "octicon-kebab-horizontal" 18 "icon tw-mx-2"}}
|
||||
<div class="ui menu">
|
||||
{{if not (or $file.IsIncomplete $file.IsBin $file.IsSubmodule)}}
|
||||
<button class="unescape-button item">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</button>
|
||||
<button class="escape-button tw-hidden item">{{ctx.Locale.Tr "repo.escape_control_characters"}}</button>
|
||||
{{end}}
|
||||
{{if and (not $file.IsSubmodule) (not $.PageIsWiki)}}
|
||||
{{if $file.IsDeleted}}
|
||||
<a class="item" rel="nofollow" href="{{$.BeforeSourcePath}}/{{PathEscapeSegments .Name}}">{{ctx.Locale.Tr "repo.diff.view_file"}}</a>
|
||||
{{else}}
|
||||
<a class="item" rel="nofollow" href="{{$.SourcePath}}/{{PathEscapeSegments .Name}}">{{ctx.Locale.Tr "repo.diff.view_file"}}</a>
|
||||
{{if and $.Repository.CanEnableEditor $.CanEditFile (not $file.IsLFSFile) (not $file.IsBin)}}
|
||||
<a class="item" rel="nofollow" href="{{$.HeadRepoLink}}/_edit/{{PathEscapeSegments $.HeadBranchName}}/{{PathEscapeSegments $file.Name}}?return_uri={{print $.BackToLink "#diff-" $file.NameHash | QueryEscape}}">{{ctx.Locale.Tr "repo.editor.edit_this_file"}}</a>
|
||||
{{end}}
|
||||
<button class="btn diff-header-popup-btn tw-p-1">{{svg "octicon-kebab-horizontal" 18}}</button>
|
||||
<div class="tippy-target">
|
||||
{{if not (or $file.IsIncomplete $file.IsBin $file.IsSubmodule)}}
|
||||
<button class="unescape-button item" data-file-content-elem-id="diff-{{$file.NameHash}}">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</button>
|
||||
<button class="escape-button tw-hidden item" data-file-content-elem-id="diff-{{$file.NameHash}}">{{ctx.Locale.Tr "repo.escape_control_characters"}}</button>
|
||||
{{end}}
|
||||
{{if and (not $file.IsSubmodule) (not $.PageIsWiki)}}
|
||||
{{if $file.IsDeleted}}
|
||||
<a class="item" rel="nofollow" href="{{$.BeforeSourcePath}}/{{PathEscapeSegments .Name}}">{{ctx.Locale.Tr "repo.diff.view_file"}}</a>
|
||||
{{else}}
|
||||
<a class="item" rel="nofollow" href="{{$.SourcePath}}/{{PathEscapeSegments .Name}}">{{ctx.Locale.Tr "repo.diff.view_file"}}</a>
|
||||
{{if and $.Repository.CanEnableEditor $.CanEditFile (not $file.IsLFSFile) (not $file.IsBin)}}
|
||||
<a class="item" rel="nofollow" href="{{$.HeadRepoLink}}/_edit/{{PathEscapeSegments $.HeadBranchName}}/{{PathEscapeSegments $file.Name}}?return_uri={{print $.BackToLink "#diff-" $file.NameHash | QueryEscape}}">{{ctx.Locale.Tr "repo.editor.edit_this_file"}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</h4>
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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})
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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())
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
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 attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
|
||||
const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
|
||||
// try to find target by: "#target" -> "[name=target]" -> ".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;
|
||||
}
|
||||
|
||||
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")
|
||||
$(document).on('click', 'form button.ui.cancel.button', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, 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);
|
||||
}
|
||||
});
|
||||
|
||||
$('.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');
|
||||
});
|
||||
}
|
||||
|
||||
export function initGlobalShowModal() {
|
||||
// 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 '.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 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
|
||||
}
|
||||
}
|
||||
|
||||
$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');
|
||||
});
|
||||
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));
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 ? `<div class="header">${htmlEscape(header)}</div>` : '';
|
||||
const modal = createElementFromHTML(`
|
||||
<div class="ui g-modal-confirm modal">
|
||||
${headerHtml}
|
||||
<div class="content">${htmlEscape(content)}</div>
|
||||
<div class="actions">
|
||||
<button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>
|
||||
|
@ -18,6 +18,7 @@ import {
|
||||
} from '../utils/dom.ts';
|
||||
import {POST, GET} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
|
||||
const {pageData, i18n} = window.config;
|
||||
|
||||
@ -140,12 +141,22 @@ export function initRepoDiffConversationNav() {
|
||||
});
|
||||
}
|
||||
|
||||
function initDiffHeaderPopup() {
|
||||
for (const btn of document.querySelectorAll('.diff-header-popup-btn:not([data-header-popup-initialized])')) {
|
||||
btn.setAttribute('data-header-popup-initialized', '');
|
||||
const popup = btn.nextElementSibling;
|
||||
if (!popup?.matches('.tippy-target')) throw new Error('Popup element not found');
|
||||
createTippy(btn, {content: popup, theme: 'menu', placement: 'bottom', trigger: 'click', interactive: true, hideOnClick: true});
|
||||
}
|
||||
}
|
||||
|
||||
// Will be called when the show more (files) button has been pressed
|
||||
function onShowMoreFiles() {
|
||||
initRepoIssueContentHistory();
|
||||
initViewedCheckboxListenerFor();
|
||||
countAndUpdateViewedFiles();
|
||||
initImageDiff();
|
||||
initDiffHeaderPopup();
|
||||
}
|
||||
|
||||
export async function loadMoreFiles(url) {
|
||||
@ -221,6 +232,7 @@ export function initRepoDiffView() {
|
||||
initDiffFileList();
|
||||
initDiffCommitSelect();
|
||||
initRepoDiffShowMore();
|
||||
initDiffHeaderPopup();
|
||||
initRepoDiffFileViewToggle();
|
||||
initViewedCheckboxListenerFor();
|
||||
initExpandAndCollapseFilesButton();
|
||||
|
@ -76,7 +76,7 @@ function initRepoIssueListCheckboxes() {
|
||||
// for delete
|
||||
if (action === 'delete') {
|
||||
const confirmText = e.target.getAttribute('data-action-delete-confirm');
|
||||
if (!await confirmModal(confirmText, {confirmButtonColor: 'red'})) {
|
||||
if (!await confirmModal({content: confirmText, confirmButtonColor: 'red'})) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import {hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {addDelegatedEventListener, hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.ts';
|
||||
|
||||
export function initUnicodeEscapeButton() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.escape-button, .unescape-button, .toggle-escape-button');
|
||||
if (!btn) return;
|
||||
|
||||
addDelegatedEventListener(document, 'click', '.escape-button, .unescape-button, .toggle-escape-button', (btn, e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const fileContent = btn.closest('.file-content, .non-diff-file-content');
|
||||
const fileContentElemId = btn.getAttribute('data-file-content-elem-id');
|
||||
const fileContent = fileContentElemId ?
|
||||
document.querySelector(`#${fileContentElemId}`) :
|
||||
btn.closest('.file-content, .non-diff-file-content');
|
||||
const fileView = fileContent?.querySelectorAll('.file-code, .file-view');
|
||||
if (btn.matches('.escape-button')) {
|
||||
for (const el of fileView) el.classList.add('unicode-escaped');
|
||||
|
@ -40,14 +40,15 @@ async function loginPasskey() {
|
||||
try {
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: options.publicKey,
|
||||
});
|
||||
}) as PublicKeyCredential;
|
||||
const credResp = credential.response as AuthenticatorAssertionResponse;
|
||||
|
||||
// Move data into Arrays in case it is super long
|
||||
const authData = new Uint8Array(credential.response.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(credential.response.clientDataJSON);
|
||||
const authData = new Uint8Array(credResp.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(credResp.clientDataJSON);
|
||||
const rawId = new Uint8Array(credential.rawId);
|
||||
const sig = new Uint8Array(credential.response.signature);
|
||||
const userHandle = new Uint8Array(credential.response.userHandle);
|
||||
const sig = new Uint8Array(credResp.signature);
|
||||
const userHandle = new Uint8Array(credResp.userHandle);
|
||||
|
||||
const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, {
|
||||
data: {
|
||||
@ -175,7 +176,7 @@ async function webauthnRegistered(newCredential) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function webAuthnError(errorType, message) {
|
||||
function webAuthnError(errorType: string, message:string = '') {
|
||||
const elErrorMsg = document.querySelector(`#webauthn-error-msg`);
|
||||
|
||||
if (errorType === 'general') {
|
||||
@ -207,10 +208,9 @@ function detectWebAuthnSupport() {
|
||||
}
|
||||
|
||||
export function initUserAuthWebAuthnRegister() {
|
||||
const elRegister = document.querySelector('#register-webauthn');
|
||||
if (!elRegister) {
|
||||
return;
|
||||
}
|
||||
const elRegister = document.querySelector<HTMLInputElement>('#register-webauthn');
|
||||
if (!elRegister) return;
|
||||
|
||||
if (!detectWebAuthnSupport()) {
|
||||
elRegister.disabled = true;
|
||||
return;
|
||||
@ -222,7 +222,7 @@ export function initUserAuthWebAuthnRegister() {
|
||||
}
|
||||
|
||||
async function webAuthnRegisterRequest() {
|
||||
const elNickname = document.querySelector('#nickname');
|
||||
const elNickname = document.querySelector<HTMLInputElement>('#nickname');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('name', elNickname.value);
|
||||
|
@ -34,7 +34,6 @@ import {
|
||||
} from './features/repo-issue.ts';
|
||||
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
|
||||
import {initRepoTopicBar} from './features/repo-home.ts';
|
||||
import {initAdminEmails} from './features/admin/emails.ts';
|
||||
import {initAdminCommon} from './features/admin/common.ts';
|
||||
import {initRepoTemplateSearch} from './features/repo-template.ts';
|
||||
import {initRepoCodeView} from './features/repo-code.ts';
|
||||
@ -83,7 +82,6 @@ import {
|
||||
initGlobalButtonClickOnEnter,
|
||||
initGlobalButtons,
|
||||
initGlobalDeleteButton,
|
||||
initGlobalShowModal,
|
||||
} from './features/common-button.ts';
|
||||
import {initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
|
||||
|
||||
@ -122,7 +120,6 @@ onDomReady(() => {
|
||||
callInitFunctions([
|
||||
initGlobalDropdown,
|
||||
initGlobalTabularMenu,
|
||||
initGlobalShowModal,
|
||||
initGlobalFetchAction,
|
||||
initGlobalTooltips,
|
||||
initGlobalButtonClickOnEnter,
|
||||
@ -157,7 +154,6 @@ onDomReady(() => {
|
||||
initCopyContent,
|
||||
|
||||
initAdminCommon,
|
||||
initAdminEmails,
|
||||
initAdminUserListSearchForm,
|
||||
initAdminConfigs,
|
||||
initAdminSelfCheck,
|
||||
|
@ -58,16 +58,12 @@ export async function renderMermaid(): Promise<void> {
|
||||
mermaidBlock.append(btn);
|
||||
|
||||
const updateIframeHeight = () => {
|
||||
iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`;
|
||||
const body = iframe.contentWindow?.document?.body;
|
||||
if (body) {
|
||||
iframe.style.height = `${body.clientHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
// update height when element's visibility state changes, for example when the diagram is inside
|
||||
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
|
||||
// would initially set a incorrect height and the correct height is set during this callback.
|
||||
(new IntersectionObserver(() => {
|
||||
updateIframeHeight();
|
||||
}, {root: document.documentElement})).observe(iframe);
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
pre.replaceWith(mermaidBlock);
|
||||
mermaidBlock.classList.remove('tw-hidden');
|
||||
@ -76,6 +72,13 @@ export async function renderMermaid(): Promise<void> {
|
||||
mermaidBlock.classList.remove('is-loading');
|
||||
iframe.classList.remove('tw-invisible');
|
||||
}, 0);
|
||||
|
||||
// update height when element's visibility state changes, for example when the diagram is inside
|
||||
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
|
||||
// would initially set a incorrect height and the correct height is set during this callback.
|
||||
(new IntersectionObserver(() => {
|
||||
updateIframeHeight();
|
||||
}, {root: document.documentElement})).observe(iframe);
|
||||
});
|
||||
|
||||
document.body.append(mermaidBlock);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {isObject} from '../utils.ts';
|
||||
import type {RequestData, RequestOpts} from '../types.ts';
|
||||
import type {RequestOpts} from '../types.ts';
|
||||
|
||||
const {csrfToken} = window.config;
|
||||
|
||||
@ -10,7 +10,7 @@ const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
// which will automatically set an appropriate headers. For json content, only object
|
||||
// and array types are currently supported.
|
||||
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
|
||||
let body: RequestData;
|
||||
let body: string | FormData | URLSearchParams;
|
||||
let contentType: string;
|
||||
if (data instanceof FormData || data instanceof URLSearchParams) {
|
||||
body = data;
|
||||
|
@ -24,7 +24,7 @@ export type Config = {
|
||||
|
||||
export type Intent = 'error' | 'warning' | 'info';
|
||||
|
||||
export type RequestData = string | FormData | URLSearchParams;
|
||||
export type RequestData = string | FormData | URLSearchParams | Record<string, any>;
|
||||
|
||||
export type RequestOpts = {
|
||||
data?: RequestData,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createElementFromAttrs, createElementFromHTML, querySingleVisibleElem} from './dom.ts';
|
||||
import {createElementFromAttrs, createElementFromHTML, queryElemChildren, querySingleVisibleElem} from './dom.ts';
|
||||
|
||||
test('createElementFromHTML', () => {
|
||||
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
|
||||
@ -26,3 +26,9 @@ test('querySingleVisibleElem', () => {
|
||||
el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>');
|
||||
expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element');
|
||||
});
|
||||
|
||||
test('queryElemChildren', () => {
|
||||
const el = createElementFromHTML('<div><span class="a">a</span><span class="b">b</span></div>');
|
||||
const children = queryElemChildren(el, '.a');
|
||||
expect(children.length).toEqual(1);
|
||||
});
|
||||
|
@ -2,10 +2,10 @@ import {debounce} from 'throttle-debounce';
|
||||
import type {Promisable} from 'type-fest';
|
||||
import type $ from 'jquery';
|
||||
|
||||
type ElementArg = Element | string | NodeListOf<Element> | Array<Element> | ReturnType<typeof $>;
|
||||
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
|
||||
type ElementArg = Element | string | ArrayLikeIterable<Element> | ReturnType<typeof $>;
|
||||
type ElementsCallback<T extends Element> = (el: T) => Promisable<any>;
|
||||
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
|
||||
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
|
||||
|
||||
function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) {
|
||||
if (typeof el === 'string' || el instanceof String) {
|
||||
@ -76,6 +76,11 @@ export function queryElemSiblings<T extends Element>(el: Element, selector = '*'
|
||||
|
||||
// it works like jQuery.children: only the direct children are selected
|
||||
export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
|
||||
if (window.vitest) {
|
||||
// bypass the vitest bug: it doesn't support ":scope >"
|
||||
const selected = Array.from<T>(parent.children as any).filter((child) => child.matches(selector));
|
||||
return applyElemsCallback<T>(selected, fn);
|
||||
}
|
||||
return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn);
|
||||
}
|
||||
|
||||
@ -348,7 +353,7 @@ export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, s
|
||||
return candidates.length ? candidates[0] as T : null;
|
||||
}
|
||||
|
||||
export function addDelegatedEventListener<T extends HTMLElement>(parent: Node, type: string, selector: string, listener: (elem: T, e: Event) => void | Promise<any>, options?: boolean | AddEventListenerOptions) {
|
||||
export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => void | Promise<any>, options?: boolean | AddEventListenerOptions) {
|
||||
parent.addEventListener(type, (e: Event) => {
|
||||
const elem = (e.target as HTMLElement).closest(selector);
|
||||
if (!elem) return;
|
||||
|
Loading…
Reference in New Issue
Block a user