Compare commits

..

13 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 6a0e125018 feat(acme): support EAB credentials
Co-authored-by: techknowlogick <164197+techknowlogick@users.noreply.github.com>
2026-09-30 23:11:43 +00:00
copilot-swe-agent[bot] 1c19e2ba43 Initial plan 2026-09-30 23:02:33 +00:00
Zain Qureshi b0d6cc1d22 docs: correct three stale defaults in app.example.ini (#39500)
Three commented defaults in `custom/conf/app.example.ini` differ from
what Gitea actually uses, so the file says they default to something
else:

- `MINIMUM_KEY_SIZE_CHECK`: example `false`, code `true`
(`modules/setting/ssh.go:55`, read at `:152`).
- `SSH_SERVER_HOST_KEYS`: example lists `ssh/gitea.rsa, ssh/gogs.rsa`,
code also loads `ssh/gitea.ed25519` and `ssh/gitea.ecdsa`
(`modules/setting/ssh.go:57`).
- `[queue] DATADIR`: example `queues/`, code `queues/common`
(`modules/setting/queue.go:33`), which the comment above it already
states.

Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 15:49:22 -07:00
silverwind 8936303510 fix: add missing checks to several API and web handlers (#39501)
Several handlers skipped checks that their sibling routes or settings already enforce. This brings them in line.

- Push mirror API honors `DISABLE_NEW_PUSH` and checks the caller's permission
- Media API serves small files with the usual content headers
- Issue attachment API ignores comment attachments
- Push-to-create respects `FORCE_PRIVATE`
- Profile feeds and follow actions respect `ENABLE_FEED` and owner visibility
- Tag delete route refuses release tags
- Refresh token grant only accepts refresh tokens
- Gitea migrations bound the source's page size

Co-authored-by: bircni <bircni@icloud.com>
2026-09-30 20:25:14 +02:00
Zettat123 3d085dbaf1 feat(admin): show and filter users by authentication source (#38900) 2026-09-30 11:28:35 -06:00
Nico Schlömer 9b2a3c267b fix(markup): don't escape ambiguous characters in MathML (#39493)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 16:37:30 +00:00
silverwind 590d2984d9 fix(ui): ignore code line anchors below 1, show JS errors in vite dev mode (#39432) 2026-09-30 15:03:40 +00:00
Nico Schlömer 61e0343580 fix(markup): skip post-processing inside MathML (#39497)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 16:41:41 +02:00
wxiaoguang 0b43bde974 fix: copy new access token to clipboard (#39496)
Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 21:41:55 +08:00
wxiaoguang cc95f141f8 fix: npm route (#39488) 2026-09-30 19:49:50 +08:00
Roshan Ramani a25fbd43c4 docs: update app.example.ini for defaults changed in #39400 (#39456)
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-30 11:28:59 +00:00
JerryLien f365a6b9c8 fix(actions): keep runs order after auto refresh (#39479)
On a repository's Actions tab, runs are sorted newest first on initial
page load. After the first auto refresh (added in #38329, every 3
seconds while runs are active and every 12 seconds otherwise), the same
runs may appear in a different order and move again as their status
changes.

Example with four runs (Gitea 28.0.0, SQLite):

```
page load:     #10 success, #9 failure, #8 success, #7 running
after refresh: #8 success, #10 success, #9 failure, #7 running
```

To reproduce, open the Actions tab of a repository with runs in
different statuses and wait for an auto refresh. On SQLite, the runs may
be regrouped by status, with each group ordered oldest first.

`preparePartialRefreshRuns` reloads the runs currently shown on the page
using `GetRunsByRepoAndID`. That query has no `ORDER BY`, while the
initial page load uses `FindRunOptions.ToOrders` and sorts by index
descending.

With SQLite, the query planner used the `(repo_id, status)` index, so
the returned row order differed from the original page order. Since the
query has no explicit ordering, this behavior is database-dependent. I
have not tested MySQL or PostgreSQL.

This change orders `GetRunsByRepoAndID` by index descending, the same
order `FindRunOptions.ToOrders` uses for the initial page load. The
refresh only reloads the runs already on the page, so they come back in
the original order, with or without filters and on any page.

The other caller of `GetRunsByRepoAndID`, run approval, does not depend
on result ordering.

Testing:

- Added `TestPreparePartialRefreshRunsKeepsRequestedOrder`. Without the
fix, runs 794, 793, 792, 791 are returned as 791, 792, 794, 793; with
the fix, the test passes.
- `go test` passes for `./routers/web/repo/actions/`,
`./models/actions/` and `./services/actions/`.
- `go vet` and `golangci-lint v2.13.2` pass for the changed packages.
- Manually tested by building Gitea 28.0.0 with this patch and running
it on our SQLite instance. The runs list keeps its newest-first order
across auto refreshes. The official 28.0.0 binary reproduces the
reordering.

AI-assisted: drafted with Claude Code (claude-opus-5-5), reviewed by me.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-30 09:17:04 +02:00
bircni e0095af8c3 ci: Also release for other versions than 1 majors (#39475) 2026-09-29 21:47:12 +02:00
50 changed files with 500 additions and 117 deletions
+17 -1
View File
@@ -21,8 +21,19 @@ import (
"gitea.dev/modules/util" "gitea.dev/modules/util"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/mholt/acmez/v3/acme"
) )
func acmeExternalAccountBinding() (*acme.EAB, error) {
if setting.AcmeEABKID == "" && setting.AcmeEABHMAC == "" {
return nil, nil
}
if setting.AcmeEABKID == "" || setting.AcmeEABHMAC == "" {
return nil, errors.New("both ACME_EAB_KID and ACME_EAB_HMAC must be set")
}
return &acme.EAB{KeyID: setting.AcmeEABKID, MACKey: setting.AcmeEABHMAC}, nil
}
func getCARoot(path string) (*x509.CertPool, error) { func getCARoot(path string) (*x509.CertPool, error) {
r, err := os.ReadFile(path) r, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -66,6 +77,10 @@ func runACME(listenAddr string, m http.Handler) error {
log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err) log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err)
} }
} }
externalAccount, err := acmeExternalAccountBinding()
if err != nil {
return err
}
// FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https" // FIXME: this path is not right, it uses "AppWorkPath" incorrectly, and writes the data into "AppWorkPath/https"
// Ideally it should migrate to AppDataPath write to "AppDataPath/https" // Ideally it should migrate to AppDataPath write to "AppDataPath/https"
// And one more thing, no idea why we should set the global default variables here // And one more thing, no idea why we should set the global default variables here
@@ -84,6 +99,7 @@ func runACME(listenAddr string, m http.Handler) error {
Email: setting.AcmeEmail, Email: setting.AcmeEmail,
Agreed: setting.AcmeTOS, Agreed: setting.AcmeTOS,
Profile: setting.AcmeProfile, Profile: setting.AcmeProfile,
ExternalAccount: externalAccount,
DisableHTTPChallenge: !enableHTTPChallenge, DisableHTTPChallenge: !enableHTTPChallenge,
DisableTLSALPNChallenge: !enableTLSALPNChallenge, DisableTLSALPNChallenge: !enableTLSALPNChallenge,
ListenHost: setting.HTTPAddr, ListenHost: setting.HTTPAddr,
@@ -100,7 +116,7 @@ func runACME(listenAddr string, m http.Handler) error {
// takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519). // takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519).
// Prefer keeping the existing cert and retrying renewals asynchronously. // Prefer keeping the existing cert and retrying renewals asynchronously.
ctx := graceful.GetManager().ShutdownContext() ctx := graceful.GetManager().ShutdownContext()
err := magic.ManageSync(ctx, []string{setting.AppDomain}) err = magic.ManageSync(ctx, []string{setting.AppDomain})
if err != nil { if err != nil {
cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.AppDomain) cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.AppDomain)
if cacheErr != nil || cert.Expired() { if cacheErr != nil || cert.Expired() {
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/mholt/acmez/v3/acme"
"github.com/stretchr/testify/assert"
)
func TestAcmeExternalAccountBinding(t *testing.T) {
t.Cleanup(test.MockVariableValue(&setting.AcmeEABKID, ""))
t.Cleanup(test.MockVariableValue(&setting.AcmeEABHMAC, ""))
binding, err := acmeExternalAccountBinding()
assert.NoError(t, err)
assert.Nil(t, binding)
setting.AcmeEABKID = "kid"
_, err = acmeExternalAccountBinding()
assert.ErrorContains(t, err, "both ACME_EAB_KID and ACME_EAB_HMAC must be set")
setting.AcmeEABHMAC = "hmac"
binding, err = acmeExternalAccountBinding()
assert.NoError(t, err)
assert.Equal(t, &acme.EAB{KeyID: "kid", MACKey: "hmac"}, binding)
}
+21 -18
View File
@@ -155,7 +155,7 @@
;; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER. ;; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER.
;BUILTIN_SSH_SERVER_USER = ;BUILTIN_SSH_SERVER_USER =
;; ;;
;; Domain name to be exposed in clone URL, defaults to DOMAIN or the domain part of ROOT_URL ;; Domain name to be exposed in clone URL, defaults to the domain part of ROOT_URL
;SSH_DOMAIN = ;SSH_DOMAIN =
;; ;;
;; Port number to be exposed in clone URL. ;; Port number to be exposed in clone URL.
@@ -198,7 +198,7 @@
;; For the built-in SSH server, choose the keypair to offer as the host key ;; For the built-in SSH server, choose the keypair to offer as the host key
;; The private key should be at SSH_SERVER_HOST_KEY and the public SSH_SERVER_HOST_KEY.pub ;; The private key should be at SSH_SERVER_HOST_KEY and the public SSH_SERVER_HOST_KEY.pub
;; relative paths are made absolute relative to the APP_DATA_PATH ;; relative paths are made absolute relative to the APP_DATA_PATH
;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gogs.rsa ;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gitea.ed25519, ssh/gitea.ecdsa, ssh/gogs.rsa
;; ;;
;; Enable SSH Authorized Key Backup when rewriting all keys, default is false ;; Enable SSH Authorized Key Backup when rewriting all keys, default is false
;SSH_AUTHORIZED_KEYS_BACKUP = false ;SSH_AUTHORIZED_KEYS_BACKUP = false
@@ -237,7 +237,7 @@
;SSH_PER_WRITE_PER_KB_TIMEOUT = 30s ;SSH_PER_WRITE_PER_KB_TIMEOUT = 30s
;; ;;
;; Indicate whether to check minimum key size with corresponding type ;; Indicate whether to check minimum key size with corresponding type
;MINIMUM_KEY_SIZE_CHECK = false ;MINIMUM_KEY_SIZE_CHECK = true
;; ;;
;; TLS Settings: Either ACME or manual ;; TLS Settings: Either ACME or manual
;; (Other common TLS configuration are found before) ;; (Other common TLS configuration are found before)
@@ -264,6 +264,11 @@
;; ACME profile to request from the CA (e.g. "shortlived" for raw-IP certificates) ;; ACME profile to request from the CA (e.g. "shortlived" for raw-IP certificates)
;ACME_PROFILE = ;ACME_PROFILE =
;; ;;
;; External account binding credentials; set both to enable EAB
;; ACME_EAB_HMAC should be a base64url-encoded MAC key
;ACME_EAB_KID =
;ACME_EAB_HMAC =
;;
;; ACME live directory (not to be confused with ACME directory URL: ACME_URL) ;; ACME live directory (not to be confused with ACME directory URL: ACME_URL)
;; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic) ;; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic)
;ACME_DIRECTORY = https ;ACME_DIRECTORY = https
@@ -836,7 +841,7 @@ LEVEL = Info
;EMAIL_DOMAIN_BLOCKLIST = ;EMAIL_DOMAIN_BLOCKLIST =
;; ;;
;; Disallow registration, only allow admins to create accounts. ;; Disallow registration, only allow admins to create accounts.
;DISABLE_REGISTRATION = false ;DISABLE_REGISTRATION = true
;; ;;
;; Allow registration only using gitea itself, it works only when DISABLE_REGISTRATION is false ;; Allow registration only using gitea itself, it works only when DISABLE_REGISTRATION is false
;ALLOW_ONLY_INTERNAL_REGISTRATION = false ;ALLOW_ONLY_INTERNAL_REGISTRATION = false
@@ -968,12 +973,11 @@ LEVEL = Info
;; Value for the domain part of the user's email address in the git log if user ;; Value for the domain part of the user's email address in the git log if user
;; has set KeepEmailPrivate to true. The user's email will be replaced with a ;; has set KeepEmailPrivate to true. The user's email will be replaced with a
;; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS. Default ;; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS. Default
;; value is "noreply." + DOMAIN, where DOMAIN resolves to the value from server.DOMAIN ;; value is "noreply." + the domain part of ROOT_URL
;; Note: do not use the <DOMAIN> notation below ;NO_REPLY_ADDRESS =
;NO_REPLY_ADDRESS = ; noreply.<DOMAIN>
;; ;;
;; Show Registration button ;; Show Registration button, defaults to true only if both DISABLE_REGISTRATION and ALLOW_ONLY_EXTERNAL_REGISTRATION are false
;SHOW_REGISTRATION_BUTTON = true ;SHOW_REGISTRATION_BUTTON = false
;; ;;
;; Show milestones dashboard page - a view of all the user's milestones ;; Show milestones dashboard page - a view of all the user's milestones
;SHOW_MILESTONES_DASHBOARD_PAGE = true ;SHOW_MILESTONES_DASHBOARD_PAGE = true
@@ -1670,13 +1674,13 @@ LEVEL = Info
;; ;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ;;
;; General queue queue type, currently support: persistable-channel, channel, level, redis, dummy ;; General queue type, currently support: level, channel, redis, dummy
;; default to persistable-channel ;; default to level
;TYPE = persistable-channel ;TYPE = level
;; ;;
;; data-dir for storing persistable queues and level queues, individual queues will default to `queues/common` meaning the queue is shared. ;; data-dir for storing level queues, individual queues will default to `queues/common` meaning the queue is shared.
;; Relative paths will be made absolute against "APP_DATA_PATH" ;; Relative paths will be made absolute against "APP_DATA_PATH"
;DATADIR = queues/ ;DATADIR = queues/common
;; ;;
;; Default queue length before a channel queue will block ;; Default queue length before a channel queue will block
;LENGTH = 100000 ;LENGTH = 100000
@@ -1684,7 +1688,7 @@ LEVEL = Info
;; Batch size to send for batched queues ;; Batch size to send for batched queues
;BATCH_LENGTH = 20 ;BATCH_LENGTH = 20
;; ;;
;; When `TYPE` is `persistable-channel`, this provides a directory for the underlying leveldb ;; When `TYPE` is `level`, this provides a directory for the underlying leveldb
;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`. ;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`.
;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR. ;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR.
;CONN_STR = ;CONN_STR =
@@ -1752,7 +1756,6 @@ LEVEL = Info
;ENABLE_OPENID_SIGNIN = false ;ENABLE_OPENID_SIGNIN = false
;; ;;
;; Whether to allow registering via OpenID ;; Whether to allow registering via OpenID
;; Do not include to rely on rhw DISABLE_REGISTRATION setting
;;ENABLE_OPENID_SIGNUP = false ;;ENABLE_OPENID_SIGNUP = false
;; ;;
;; Allowed URI patterns (POSIX regexp). ;; Allowed URI patterns (POSIX regexp).
@@ -2015,8 +2018,8 @@ LEVEL = Info
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ;;
;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres" ;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres"
;; Default is "memory". "db" will reuse the configuration in [database] ;; Default is "file". "db" will reuse the configuration in [database]
;PROVIDER = memory ;PROVIDER = file
;; ;;
;; Provider config options ;; Provider config options
;; memory: doesn't have any config yet ;; memory: doesn't have any config yet
+1 -1
View File
@@ -68,6 +68,7 @@ require (
github.com/mattn/go-isatty v0.0.24 github.com/mattn/go-isatty v0.0.24
github.com/mattn/go-sqlite3 v1.14.52 github.com/mattn/go-sqlite3 v1.14.52
github.com/meilisearch/meilisearch-go v0.36.3 github.com/meilisearch/meilisearch-go v0.36.3
github.com/mholt/acmez/v3 v3.1.6
github.com/mholt/archives v0.1.5 github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/microsoft/go-mssqldb v1.11.2 github.com/microsoft/go-mssqldb v1.11.2
@@ -199,7 +200,6 @@ require (
github.com/markbates/going v1.0.3 // indirect github.com/markbates/going v1.0.3 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mholt/acmez/v3 v3.1.6 // indirect
github.com/miekg/dns v1.1.72 // indirect github.com/miekg/dns v1.1.72 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
+2 -3
View File
@@ -295,9 +295,8 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er
return &run, nil return &run, nil
} }
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) ([]*ActionRun, error) { func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) (runs []*ActionRun, err error) {
var runs []*ActionRun err = db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).OrderBy("id").Find(&runs)
err := db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).Find(&runs)
return runs, err return runs, err
} }
+4 -4
View File
@@ -40,8 +40,8 @@ type SearchUserOptions struct {
Keyword string Keyword string
Types []UserType Types []UserType
UID int64 UID int64
LoginName string // this option should be used only for admin user LoginName string // this option should be used only for admin user
SourceID int64 // this option should be used only for admin user SourceID optional.Option[int64] // this option should be used only for admin user, Some(0) means local users
OrderBy db.SearchOrderBy OrderBy db.SearchOrderBy
Visible []structs.VisibleType Visible []structs.VisibleType
Actor *User // The user doing the search Actor *User // The user doing the search
@@ -106,8 +106,8 @@ func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) db.Session
cond = cond.And(builder.Eq{"id": opts.UID}) cond = cond.And(builder.Eq{"id": opts.UID})
} }
if opts.SourceID > 0 { if opts.SourceID.Has() {
cond = cond.And(builder.Eq{"login_source": opts.SourceID}) cond = cond.And(builder.Eq{"login_source": opts.SourceID.Value()})
} }
if opts.LoginName != "" { if opts.LoginName != "" {
cond = cond.And(builder.Eq{"login_name": opts.LoginName}) cond = cond.And(builder.Eq{"login_name": opts.LoginName})
+40 -1
View File
@@ -8,11 +8,13 @@ import (
"fmt" "fmt"
"html" "html"
"io" "io"
"strings"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/translation" "gitea.dev/modules/translation"
"gitea.dev/modules/util"
) )
type htmlChunkReader struct { type htmlChunkReader struct {
@@ -30,6 +32,10 @@ type escapeStreamer struct {
ambiguousTables []*AmbiguousTable ambiguousTables []*AmbiguousTable
allowed map[rune]bool allowed map[rune]bool
tagPartial []byte // partial tag content, used to detect if we are in some tags
inTagMath bool // MathML operators like U+2212 are intended and wrapping them breaks the math layout
out io.Writer out io.Writer
} }
@@ -62,6 +68,7 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
for i, part := range parts { for i, part := range parts {
if partInTag[i] { if partInTag[i] {
lastIsTag = true lastIsTag = true
es.trackHtmlTag(part)
if _, err := out.Write(part); err != nil { if _, err := out.Write(part); err != nil {
return nil, err return nil, err
} }
@@ -75,7 +82,11 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
return nil, err return nil, err
} }
} }
if err = es.detectAndWriteRunes(part); err != nil { if es.inTagMath {
if _, err := out.Write(part); err != nil {
return nil, err
}
} else if err = es.detectAndWriteRunes(part); err != nil {
return nil, err return nil, err
} }
} }
@@ -83,6 +94,34 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
} }
} }
// trackHtmlTag receives tag parts, a tag might be split into multiple parts
func (e *escapeStreamer) trackHtmlTag(part []byte) {
const maxHeadLen = 100 // only read the first N bytes of the tag for detection purpose
if part[0] == '<' {
// start a new tag
e.tagPartial = e.tagPartial[:0]
}
if len(e.tagPartial) >= maxHeadLen {
return
}
e.tagPartial = append(e.tagPartial, part[:min(len(part), maxHeadLen-len(e.tagPartial))]...)
isTag := func(prefix string) bool {
if len(e.tagPartial) < len(prefix)+1 {
return false
}
if !util.AsciiEqualFold(e.tagPartial[:len(prefix)], []byte(prefix)) {
return false
}
return strings.IndexByte(" \t\n\r\f>", e.tagPartial[len(prefix)]) != -1
}
if isTag("<math") {
e.inTagMath = true
} else if isTag("</math") {
e.inTagMath = false
}
}
func (e *escapeStreamer) trimAndWriteBom(part []byte) ([]byte, error) { func (e *escapeStreamer) trimAndWriteBom(part []byte) ([]byte, error) {
remaining, ok := bytes.CutPrefix(part, globalVars().utf8Bom) remaining, ok := bytes.CutPrefix(part, globalVars().utf8Bom)
if ok { if ok {
+24
View File
@@ -141,6 +141,12 @@ then resh (ר), and finally heh (ה) (which should appear leftmost).`,
result: `O<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:𝐾 [U+1D43E],K [U+004B]"><span class="char">𝐾</span></span>`, result: `O<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:𝐾 [U+1D43E],K [U+004B]"><span class="char">𝐾</span></span>`,
status: EscapeStatus{Escaped: true, HasAmbiguous: true}, status: EscapeStatus{Escaped: true, HasAmbiguous: true},
}, },
{
name: "ambiguous in math",
text: "<math><mo>−</mo><mi>b</mi></math> −",
result: `<math><mo>−</mo><mi>b</mi></math> <span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:− [U+2212],- [U+002D]"><span class="char">−</span></span>`,
status: EscapeStatus{Escaped: true, HasAmbiguous: true},
},
} }
func TestEscapeControlReader(t *testing.T) { func TestEscapeControlReader(t *testing.T) {
@@ -156,6 +162,24 @@ func TestEscapeControlReader(t *testing.T) {
} }
} }
func TestTrackHtmlTag(t *testing.T) {
e := &escapeStreamer{}
for _, tt := range []struct {
parts []string
inMath bool
}{
{[]string{"<ma", `TH display="block">`}, true},
{[]string{"<mo>"}, true},
{[]string{"</MA", "th>"}, false},
{[]string{"<mathx>"}, false},
} {
for _, part := range tt.parts {
e.trackHtmlTag([]byte(part))
}
assert.Equal(t, tt.inMath, e.inTagMath, "%v", tt.parts)
}
}
func TestSettingAmbiguousUnicodeDetection(t *testing.T) { func TestSettingAmbiguousUnicodeDetection(t *testing.T) {
defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, true)() defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, true)()
_, out := EscapeControlHTML("a test", &translation.MockLocale{}) _, out := EscapeControlHTML("a test", &translation.MockLocale{})
+2 -2
View File
@@ -349,8 +349,8 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod
// TextNode emoji will be converted to `<span class="emoji">`, then the next iteration will visit the "span" // TextNode emoji will be converted to `<span class="emoji">`, then the next iteration will visit the "span"
// if we don't stop it, it will go into the TextNode again and create an infinite recursion // if we don't stop it, it will go into the TextNode again and create an infinite recursion
return node.NextSibling return node.NextSibling
} else if node.Data == "code" || node.Data == "pre" { } else if node.Data == "code" || node.Data == "pre" || node.Data == "math" {
return node.NextSibling // ignore code and pre nodes return node.NextSibling // ignore code, pre and math nodes
} else if node.Data == "img" { } else if node.Data == "img" {
return visitNodeImg(ctx, node) return visitNodeImg(ctx, node)
} else if node.Data == "video" { } else if node.Data == "video" {
+3
View File
@@ -543,6 +543,9 @@ func TestPostProcess(t *testing.T) {
`Some text with <span class="emoji" data-alias="smile">😄</span> in the middle`) `Some text with <span class="emoji" data-alias="smile">😄</span> in the middle`)
test("http://localhost:3000/person/repo/issues/4#issuecomment-1234", test("http://localhost:3000/person/repo/issues/4#issuecomment-1234",
`<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`) `<a href="http://localhost:3000/person/repo/issues/4#issuecomment-1234" class="ref-issue">person/repo#4 (comment)</a>`)
test(
"<math><mtext>:gitea: go-gitea/gitea#12345</mtext></math>",
"<math><mtext>:gitea: go-gitea/gitea#12345</mtext></math>")
// special tags, GitHub's behavior, and for unclosed tags, output as text content as much as possible // special tags, GitHub's behavior, and for unclosed tags, output as text content as much as possible
test("<script>a", `&lt;script&gt;a`) test("<script>a", `&lt;script&gt;a`)
+9
View File
@@ -102,6 +102,8 @@ var (
AcmeEmail string AcmeEmail string
AcmeURL string AcmeURL string
AcmeProfile string AcmeProfile string
AcmeEABKID string
AcmeEABHMAC string
AcmeCARoot string AcmeCARoot string
SSLMinimumVersion string SSLMinimumVersion string
SSLMaximumVersion string SSLMaximumVersion string
@@ -144,6 +146,11 @@ func loadServerDomainAndURL(sec ConfigSection, protocol string) {
AppDomain = appURL.Hostname() AppDomain = appURL.Hostname()
} }
func loadAcmeEABFrom(sec ConfigSection) {
AcmeEABKID = sec.Key("ACME_EAB_KID").MustString("")
AcmeEABHMAC = sec.Key("ACME_EAB_HMAC").MustString("")
}
func loadServerFrom(rootCfg ConfigProvider) { func loadServerFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("server") sec := rootCfg.Section("server")
AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea") AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea")
@@ -173,6 +180,7 @@ func loadServerFrom(rootCfg ConfigProvider) {
if EnableAcme { if EnableAcme {
AcmeURL = sec.Key("ACME_URL").MustString("") AcmeURL = sec.Key("ACME_URL").MustString("")
AcmeProfile = sec.Key("ACME_PROFILE").MustString("") AcmeProfile = sec.Key("ACME_PROFILE").MustString("")
loadAcmeEABFrom(sec)
AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("") AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("")
if sec.HasKey("ACME_ACCEPTTOS") { if sec.HasKey("ACME_ACCEPTTOS") {
@@ -208,6 +216,7 @@ func loadServerFrom(rootCfg ConfigProvider) {
KeyFile = filepath.Join(CustomPath, KeyFile) KeyFile = filepath.Join(CustomPath, KeyFile)
} }
} }
SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("") SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("")
SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("") SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("")
SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",") SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",")
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestLoadAcmeEABFrom(t *testing.T) {
t.Cleanup(test.MockVariableValue(&AcmeEABKID, ""))
t.Cleanup(test.MockVariableValue(&AcmeEABHMAC, ""))
cfg, err := NewConfigProviderFromData(`
[server]
ACME_EAB_KID = kid
ACME_EAB_HMAC = hmac
`)
assert.NoError(t, err)
loadAcmeEABFrom(cfg.Section("server"))
assert.Equal(t, "kid", AcmeEABKID)
assert.Equal(t, "hmac", AcmeEABHMAC)
}
+1 -1
View File
@@ -121,7 +121,7 @@ func asciiLower(b byte) byte {
// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go // AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go
// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold] // ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold]
func AsciiEqualFold(s, t string) bool { func AsciiEqualFold[T string | []byte](s, t T) bool {
if len(s) != len(t) { if len(s) != len(t) {
return false return false
} }
+14 -2
View File
@@ -5,6 +5,7 @@ package web
import ( import (
"net/http" "net/http"
"net/url"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
@@ -19,13 +20,17 @@ type RouterPathGroup struct {
r *Router r *Router
pathParam string pathParam string
matchers []*routerPathMatcher matchers []*routerPathMatcher
unescape bool
} }
func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) { func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
chiCtx := chi.RouteContext(req.Context()) chiCtx := chi.RouteContext(req.Context())
path := chiCtx.URLParam(g.pathParam) path := chiCtx.URLParam(g.pathParam)
if g.unescape {
path, _ = url.PathUnescape(path)
}
for _, m := range g.matchers { for _, m := range g.matchers {
if m.matchPath(chiCtx, path) { if m.matchPath(chiCtx, path, g.unescape) {
chiCtx.RoutePatterns = append(chiCtx.RoutePatterns, m.pattern) chiCtx.RoutePatterns = append(chiCtx.RoutePatterns, m.pattern)
executeMiddlewaresHandler(resp, req, m.middlewares, m.handlerFunc) executeMiddlewaresHandler(resp, req, m.middlewares, m.handlerFunc)
return return
@@ -53,6 +58,10 @@ func (g *RouterPathGroup) MatchPattern(methods string, pattern *RouterPathGroupP
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...)) g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...))
} }
func (g *RouterPathGroup) UseUnescapedPath() {
g.unescape = true
}
type routerPathParam struct { type routerPathParam struct {
name string name string
pathSepEnd bool pathSepEnd bool
@@ -68,7 +77,7 @@ type routerPathMatcher struct {
handlerFunc http.HandlerFunc handlerFunc http.HandlerFunc
} }
func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool { func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string, unescaped bool) bool {
if !p.methods.Contains(chiCtx.RouteMethod) { if !p.methods.Contains(chiCtx.RouteMethod) {
return false return false
} }
@@ -102,6 +111,9 @@ func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool {
if p.params[i].pathSepEnd { if p.params[i].pathSepEnd {
val = strings.TrimSuffix(val, "/") val = strings.TrimSuffix(val, "/")
} }
if unescaped {
val = url.PathEscape(val)
}
chiCtx.URLParams.Add(p.params[i].name, val) chiCtx.URLParams.Add(p.params[i].name, val)
} }
return true return true
+9 -1
View File
@@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
@@ -97,12 +98,16 @@ func (r *testRecorder) test(t *testing.T, rt *Router, methodPath string, expecte
} }
func TestPathProcessor(t *testing.T) { func TestPathProcessor(t *testing.T) {
unescape := false
testProcess := func(pattern, uri string, expectedPathParams map[string]string) { testProcess := func(pattern, uri string, expectedPathParams map[string]string) {
chiCtx := chi.NewRouteContext() chiCtx := chi.NewRouteContext()
chiCtx.RouteMethod = "GET" chiCtx.RouteMethod = "GET"
p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound) p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound)
shouldProcess := expectedPathParams != nil shouldProcess := expectedPathParams != nil
assert.Equal(t, shouldProcess, p.matchPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri) if unescape {
uri, _ = url.PathUnescape(uri)
}
assert.Equal(t, shouldProcess, p.matchPath(chiCtx, uri, unescape), "use pattern %s to process uri %s", pattern, uri)
assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri) assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri)
} }
@@ -119,6 +124,9 @@ func TestPathProcessor(t *testing.T) {
testProcess("/<p1:*>/part/<p2>", "/part/c", map[string]string{"p1": "", "p2": "c"}) testProcess("/<p1:*>/part/<p2>", "/part/c", map[string]string{"p1": "", "p2": "c"})
testProcess("/<p1:*>/part/<p2>", "/a/other-part/c", nil) testProcess("/<p1:*>/part/<p2>", "/a/other-part/c", nil)
testProcess("/<p1:*>-part/<p2>", "/a-other-part/c", map[string]string{"p1": "a-other", "p2": "c"}) testProcess("/<p1:*>-part/<p2>", "/a-other-part/c", map[string]string{"p1": "a-other", "p2": "c"})
unescape = true
testProcess("/<p1:@/x>", "/%40%2fx", map[string]string{"p1": "@%2Fx"})
} }
func TestRouter(t *testing.T) { func TestRouter(t *testing.T) {
+17 -30
View File
@@ -405,37 +405,24 @@ func CommonRoutes() *web.Router {
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
}) })
r.Group("/npm", func() { r.Group("/npm", func() {
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details r.Get("/-/v1/search", npm.PackageSearch)
scopeRegexp := `^@` + npm_module.RegexpNamePart + `$` r.PathGroup("/*", func(g *web.RouterPathGroup) {
idRegexp := `^(@` + npm_module.RegexpNamePart + `%2[fF])?` + npm_module.RegexpNamePart + `$` // HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
addPackageHandlers := func() { packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">"
r.Get("", npm.PackageMetadata) g.UseUnescapedPath()
r.Put("", reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage) g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
r.Get("/{version}", npm.PackageVersionMetadata) g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFile)
r.Group("/-/{version}/{filename}", func() { g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName)
r.Get("", npm.DownloadPackageFile) g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage)
r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview)
}) g.MatchPath("GET", packageId+"/<version>", npm.PackageVersionMetadata)
r.Get("/-/{filename}", npm.DownloadPackageFileByName) g.MatchPath("GET", packageId, npm.PackageMetadata)
r.Group("/-rev/{revision}", func() { g.MatchPath("PUT", packageId, reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage)
r.Delete("", npm.DeletePackage)
r.Put("", npm.DeletePreview)
}, reqPackageAccess(perm.AccessModeWrite))
}
r.Group("/{scope:"+scopeRegexp+"}/{id:"+idRegexp+"}", addPackageHandlers)
r.Group("/{id:"+idRegexp+"}", addPackageHandlers)
addPackageDistTagsHandlers := func() { packageDistTags := "/-/package" + packageId + "/dist-tags"
r.Get("", npm.ListPackageTags) g.MatchPath("GET", packageDistTags, npm.ListPackageTags)
r.Group("/{tag}", func() { g.MatchPath("PUT", packageDistTags+"/<tag>", reqPackageAccess(perm.AccessModeWrite), npm.AddPackageTag)
r.Put("", npm.AddPackageTag) g.MatchPath("DELETE", packageDistTags+"/<tag>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageTag)
r.Delete("", npm.DeletePackageTag)
}, reqPackageAccess(perm.AccessModeWrite))
}
r.Group("/-/package/{scope:"+scopeRegexp+"}/{id:"+idRegexp+"}/dist-tags", addPackageDistTagsHandlers)
r.Group("/-/package/{id:"+idRegexp+"}/dist-tags", addPackageDistTagsHandlers)
r.Group("/-/v1/search", func() {
r.Get("", npm.PackageSearch)
}) })
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/pub", func() { r.Group("/pub", func() {
+3 -13
View File
@@ -7,7 +7,6 @@ import (
"bytes" "bytes"
std_ctx "context" std_ctx "context"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -44,21 +43,12 @@ func apiError(ctx *context.Context, status int, obj any) {
// packageNameFromParams gets the package name from the url parameters // packageNameFromParams gets the package name from the url parameters
func packageNameFromParams(ctx *context.Context) string { func packageNameFromParams(ctx *context.Context) string {
// Real examples: these 2 both should work: // HINT: NPM-ROUTE-PATH-PATTERN: real examples: these cases all should work:
// * "https://registry.npmjs.org/@angular/core" // * "https://registry.npmjs.org/@angular/core"
// * "https://registry.npmjs.org/@angular%2Fcore" // * "https://registry.npmjs.org/@angular%2Fcore"
// * "https://registry.npmjs.org/%40angular%2Fcore"
// //
// HINT: NPM-ROUTE-PATH-PATTERN: The cases for the path parameters: return ctx.PathParam("id") // id is the full package name, e.g.: "@angular/core" or "lodash"
// * ".../TheName/...": id="TheName"
// * ".../@TheScope/TheName/...": scope="@TheScope", id="TheName"
// * ".../@TheScope%2FTheName/...": id="@TheScope/TheName"
scope := ctx.PathParam("scope")
fullOrSub := ctx.PathParam("id") // may be a full name or a subpath of the full package name
if scope != "" {
// now id is the subpath of the full package name, e.g. "core" in "@angular/core"
return fmt.Sprintf("%s/%s", scope, fullOrSub)
}
return fullOrSub // id is the full package name, e.g.: "@angular/core" or "lodash"
} }
func buildNpmRegistryURL(ctx std_ctx.Context, owner *user_model.User) string { func buildNpmRegistryURL(ctx std_ctx.Context, owner *user_model.User) string {
+2 -2
View File
@@ -400,7 +400,7 @@ func SearchUsers(ctx *context.APIContext) {
// parameters: // parameters:
// - name: source_id // - name: source_id
// in: query // in: query
// description: ID of the user's login source to search for // description: ID of the user's login source to search for, 0 means the local users
// type: integer // type: integer
// format: int64 // format: int64
// - name: login_name // - name: login_name
@@ -483,7 +483,7 @@ func SearchUsers(ctx *context.APIContext) {
Actor: ctx.Doer, Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeIndividual}, Types: []user_model.UserType{user_model.UserTypeIndividual},
LoginName: ctx.FormTrim("login_name"), LoginName: ctx.FormTrim("login_name"),
SourceID: ctx.FormInt64("source_id"), SourceID: ctx.FormOptionalInt64("source_id"),
Keyword: ctx.FormTrim("q"), Keyword: ctx.FormTrim("q"),
Visible: visible, Visible: visible,
OrderBy: orderBy, OrderBy: orderBy,
+3 -3
View File
@@ -162,7 +162,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
// if it's not a pointer, just serve the data directly // if it's not a pointer, just serve the data directly
if !pointer.IsValid() { if !pointer.IsValid() {
_, _ = ctx.Resp.Write(lfsPointerBuf) httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()})
return return
} }
@@ -171,7 +171,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
// If there isn't one, just serve the data directly // If there isn't one, just serve the data directly
if errors.Is(err, git_model.ErrLFSObjectNotExist) { if errors.Is(err, git_model.ErrLFSObjectNotExist) {
_, _ = ctx.Resp.Write(lfsPointerBuf) httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()})
return return
} else if err != nil { } else if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -198,7 +198,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
return return
} }
defer lfsDataFile.Close() defer lfsDataFile.Close()
httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: ctx.Repo.TreePath}) httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: blob.Name()})
} }
func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) { func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) {
+2 -2
View File
@@ -386,8 +386,8 @@ func attachmentBelongsToRepoOrIssue(ctx *context.APIContext, attachment *repo_mo
ctx.APIErrorNotFound("no such attachment in repo") ctx.APIErrorNotFound("no such attachment in repo")
return false return false
} }
if attachment.IssueID == 0 { if attachment.IssueID == 0 || attachment.CommentID != 0 {
log.Debug("Requested attachment[%d] is not in an issue.", attachment.ID) log.Debug("Requested attachment[%d] is not an issue attachment.", attachment.ID)
ctx.APIErrorNotFound("no such attachment in issue") ctx.APIErrorNotFound("no such attachment in issue")
return false return false
} else if issue != nil && attachment.IssueID != issue.ID { } else if issue != nil && attachment.IssueID != issue.ID {
+6 -1
View File
@@ -291,6 +291,11 @@ func AddPushMirror(ctx *context.APIContext) {
return return
} }
if setting.Mirror.DisableNewPush {
ctx.APIError(http.StatusForbidden, "the site administrator has disabled the creation of new push mirrors")
return
}
pushMirror := web.GetForm[*api.CreatePushMirrorOption](ctx) pushMirror := web.GetForm[*api.CreatePushMirrorOption](ctx)
CreatePushMirror(ctx, pushMirror) CreatePushMirror(ctx, pushMirror)
} }
@@ -356,7 +361,7 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro
address, err := git.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword) address, err := git.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword)
if err == nil { if err == nil {
err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser) err = migrations.IsMigrateURLAllowed(address, ctx.Doer)
} }
if err != nil { if err != nil {
HandleRemoteAddressError(ctx, err) HandleRemoteAddressError(ctx, err)
+23
View File
@@ -10,13 +10,36 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test" "gitea.dev/modules/test"
"gitea.dev/services/contexttest" "gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestCreatePushMirrorUsesCallerPermission(t *testing.T) {
defer test.MockVariableValue(&setting.ImportLocalPaths, true)()
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
ctx.Doer = &user_model.User{}
ctx.ContextUser = &user_model.User{AllowImportLocal: true}
CreatePushMirror(ctx, &api.CreatePushMirrorOption{RemoteAddress: "local-mirror", Interval: "0"})
assert.Equal(t, http.StatusUnauthorized, resp.Code)
}
func TestAddPushMirrorDisabled(t *testing.T) {
defer test.MockVariableValue(&setting.Mirror.DisableNewPush, true)()
ctx, resp := contexttest.MockAPIContext(t, "user2/repo1")
AddPushMirror(ctx)
assert.Equal(t, http.StatusForbidden, resp.Code)
assert.Contains(t, resp.Body.String(), "the site administrator has disabled the creation of new push mirrors")
}
// TestPushMirrorSync verifies the endpoint attempts every push mirror instead // TestPushMirrorSync verifies the endpoint attempts every push mirror instead
// of aborting on the first failure, reporting all failed remotes with a 422. // of aborting on the first failure, reporting all failed remotes with a 422.
// Each remote name is not a configured git remote, so SyncPushMirror fails fast // Each remote name is not a configured git remote, so SyncPushMirror fails fast
+2 -1
View File
@@ -139,7 +139,8 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
// The repo is empty and being initialized by this push, so there is no // The repo is empty and being initialized by this push, so there is no
// dependent state (webhooks, notifications, visibility fan-out) to reconcile // dependent state (webhooks, notifications, visibility fan-out) to reconcile
// yet; setting the flags directly is sufficient in this push-to-create case. // yet; setting the flags directly is sufficient in this push-to-create case.
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() { if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() &&
(isPrivate.Value() || !setting.Repository.ForcePrivate || ctx.Doer.IsAdmin) {
repo.IsPrivate = isPrivate.Value() repo.IsPrivate = isPrivate.Value()
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil { if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
log.Error("failed to update repo is_private: %v", err) log.Error("failed to update repo is_private: %v", err)
+32
View File
@@ -48,6 +48,13 @@ const (
// UserSearchDefaultAdminSort is the default sort type for admin view // UserSearchDefaultAdminSort is the default sort type for admin view
const UserSearchDefaultAdminSort = "alphabetically" const UserSearchDefaultAdminSort = "alphabetically"
// authSourceFilterOption is one radio item of the authentication source filter dropdown
type authSourceFilterOption struct {
Value string
Label string
Selected bool
}
// Users show all the users // Users show all the users
func Users(ctx *context.Context) { func Users(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users") ctx.Data["Title"] = ctx.Tr("admin.users")
@@ -76,6 +83,30 @@ func Users(ctx *context.Context) {
"SortType": sortType, "SortType": sortType,
} }
// inactive sources are listed too, users stay attached to a source after it is deactivated
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{})
if err != nil {
ctx.ServerError("auth.Sources", err)
return
}
sourceIDFilter := ctx.FormOptionalInt64("source_id")
sourceNames := make(map[int64]string, len(sources))
authSourceFilterOptions := []*authSourceFilterOption{
{Value: "", Label: ctx.Locale.TrString("all"), Selected: !sourceIDFilter.Has()},
{Value: "0", Label: ctx.Locale.TrString("admin.users.local"), Selected: sourceIDFilter.Has() && sourceIDFilter.Value() == 0},
}
for _, source := range sources {
sourceNames[source.ID] = source.Name
authSourceFilterOptions = append(authSourceFilterOptions, &authSourceFilterOption{
Value: strconv.FormatInt(source.ID, 10),
Label: source.Name,
Selected: sourceIDFilter.Has() && sourceIDFilter.Value() == source.ID,
})
}
ctx.Data["HasAuthSources"] = len(sources) > 0
ctx.Data["SourceNames"] = sourceNames
ctx.Data["AuthSourceFilterOptions"] = authSourceFilterOptions
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
Types: types, Types: types,
@@ -88,6 +119,7 @@ func Users(ctx *context.Context) {
IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]), IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]),
IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]), IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]),
IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]), IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]),
SourceID: sourceIDFilter,
OrderBy: db.SearchOrderBy(sortType), OrderBy: db.SearchOrderBy(sortType),
}, tplUsers) }, tplUsers)
} }
+1 -1
View File
@@ -576,7 +576,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server
} }
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey) token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
if err != nil { if err != nil || token.Kind != oauth2_provider.KindRefreshToken {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient, ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unable to parse refresh token", ErrorDescription: "unable to parse refresh token",
+10 -1
View File
@@ -9,7 +9,9 @@ import (
activities_model "gitea.dev/models/activities" activities_model "gitea.dev/models/activities"
"gitea.dev/models/organization" "gitea.dev/models/organization"
"gitea.dev/models/renderhelper" "gitea.dev/models/renderhelper"
user_model "gitea.dev/models/user"
"gitea.dev/modules/markup/markdown" "gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
"gitea.dev/services/context" "gitea.dev/services/context"
feed_service "gitea.dev/services/feed" feed_service "gitea.dev/services/feed"
@@ -28,8 +30,15 @@ func ShowUserFeedAtom(ctx *context.Context) {
// showUserFeed show user activity as RSS / Atom feed // showUserFeed show user activity as RSS / Atom feed
func showUserFeed(ctx *context.Context, formatType string) { func showUserFeed(ctx *context.Context, formatType string) {
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
isOrganisation := ctx.ContextUser.IsOrganization() isOrganisation := ctx.ContextUser.IsOrganization()
if !setting.Other.EnableFeed ||
isOrganisation && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) ||
!isOrganisation && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
ctx.NotFound(nil)
return
}
includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
if ctx.IsSigned && isOrganisation && !includePrivate { if ctx.IsSigned && isOrganisation && !includePrivate {
// When feed is requested by a member of the organization, // When feed is requested by a member of the organization,
// include the private repo's the member has access to. // include the private repo's the member has access to.
+9 -1
View File
@@ -642,7 +642,15 @@ func (data *actionRunListData) preparePartialRefreshRuns(ctx *context.Context) b
ctx.ServerError("GetRunsByRepoAndID", err) ctx.ServerError("GetRunsByRepoAndID", err)
return false return false
} }
data.ActionRuns = runs runsMap := make(map[int64]*actions_model.ActionRun, len(runs))
for _, run := range runs {
runsMap[run.ID] = run
}
for _, id := range data.refreshRunIDs {
if run, ok := runsMap[id]; ok {
data.ActionRuns = append(data.ActionRuns, run)
}
}
return true return true
} }
+15
View File
@@ -15,6 +15,7 @@ import (
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/test" "gitea.dev/modules/test"
web_context "gitea.dev/services/context" web_context "gitea.dev/services/context"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -74,3 +75,17 @@ func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
} }
return ctx return ctx
} }
func TestActionRunListData(t *testing.T) {
unittest.PrepareTestEnv(t)
t.Run("preparePartialRefreshRuns", func(t *testing.T) {
ctx, _ := contexttest.MockContext(t, "user5/repo4/actions")
contexttest.LoadRepo(t, ctx, 4)
d := &actionRunListData{refreshRunIDs: []int64{791, 792}}
d.preparePartialRefreshRuns(ctx)
assert.Equal(t, []int64{791, 792}, []int64{d.ActionRuns[0].ID, d.ActionRuns[1].ID})
d = &actionRunListData{refreshRunIDs: []int64{792, 791}}
d.preparePartialRefreshRuns(ctx)
assert.Equal(t, []int64{792, 791}, []int64{d.ActionRuns[0].ID, d.ActionRuns[1].ID})
})
}
+5
View File
@@ -660,6 +660,11 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) {
return return
} }
if isDelTag && !rel.IsTag {
ctx.HTTPError(http.StatusConflict, "a tag attached to a release cannot be deleted directly")
return
}
if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil { if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil {
if release_service.IsErrProtectedTagName(err) { if release_service.IsErrProtectedTagName(err) {
ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected"))
+16
View File
@@ -4,6 +4,7 @@
package repo package repo
import ( import (
"net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -21,6 +22,21 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestDeleteTagRetainsReleaseAndAttachments(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, resp := contexttest.MockContext(t, "POST user2/repo1/tags/delete?id=1")
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadRepo(t, ctx, 1)
release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1})
attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9, ReleaseID: 1})
DeleteTag(ctx)
assert.Equal(t, http.StatusConflict, resp.Code)
assert.Equal(t, release, unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1}))
assert.Equal(t, attachment, unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9}))
}
func TestNewReleasePost(t *testing.T) { func TestNewReleasePost(t *testing.T) {
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
-8
View File
@@ -734,18 +734,10 @@ func UsernameSubRoute(ctx *context.Context) {
ShowGPGKeys(ctx) ShowGPGKeys(ctx)
} }
case strings.HasSuffix(username, ".rss"): case strings.HasSuffix(username, ".rss"):
if !setting.Other.EnableFeed {
ctx.HTTPError(http.StatusNotFound)
return
}
if reloadParam(".rss") { if reloadParam(".rss") {
feed.ShowUserFeedRSS(ctx) feed.ShowUserFeedRSS(ctx)
} }
case strings.HasSuffix(username, ".atom"): case strings.HasSuffix(username, ".atom"):
if !setting.Other.EnableFeed {
ctx.HTTPError(http.StatusNotFound)
return
}
if reloadParam(".atom") { if reloadParam(".atom") {
feed.ShowUserFeedAtom(ctx) feed.ShowUserFeedAtom(ctx)
} }
+7
View File
@@ -322,6 +322,13 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
// ActionUserFollow is for follow/unfollow user request // ActionUserFollow is for follow/unfollow user request
func ActionUserFollow(ctx *context.Context) { func ActionUserFollow(ctx *context.Context) {
isOrg := ctx.ContextUser.IsOrganization()
if isOrg && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) ||
!isOrg && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
ctx.NotFound(nil)
return
}
var err error var err error
switch ctx.FormString("action") { switch ctx.FormString("action") {
case "follow": case "follow":
+2 -3
View File
@@ -107,9 +107,8 @@ func NewGiteaDownloader(ctx context.Context, baseURL, repoPath, username, passwo
if err != nil { if err != nil {
log.Info("Unable to get global API settings. Ignoring these.") log.Info("Unable to get global API settings. Ignoring these.")
log.Debug("giteaClient.GetGlobalAPISettings. Error: %v", err) log.Debug("giteaClient.GetGlobalAPISettings. Error: %v", err)
} } else if apiConf != nil && apiConf.MaxResponseItems > 0 {
if apiConf != nil { maxPerPage = min(apiConf.MaxResponseItems, 100)
maxPerPage = apiConf.MaxResponseItems
} }
return &GiteaDownloader{ return &GiteaDownloader{
+10 -7
View File
@@ -5,6 +5,7 @@ package migrations
import ( import (
"fmt" "fmt"
"math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -317,15 +318,16 @@ func TestGiteaDownloadRepo(t *testing.T) {
func TestGiteaDownloadCommentsPaging(t *testing.T) { func TestGiteaDownloadCommentsPaging(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
maxResponseItems, commentCount, requests int maxResponseItems, pageSize, commentCount, requests int
paginated bool paginated bool
}{ }{
{maxResponseItems: 2, commentCount: 2, requests: 2}, {maxResponseItems: 2, pageSize: 2, commentCount: 2, requests: 2},
{maxResponseItems: 2, commentCount: 3, requests: 1}, {maxResponseItems: 2, pageSize: 2, commentCount: 3, requests: 1},
{maxResponseItems: 2, commentCount: 4, requests: 3, paginated: true}, {maxResponseItems: 2, pageSize: 2, commentCount: 4, requests: 3, paginated: true},
{maxResponseItems: 0, commentCount: 0, requests: 1}, {maxResponseItems: 0, pageSize: 10, commentCount: 0, requests: 1},
{maxResponseItems: math.MaxInt, pageSize: 100, commentCount: 0, requests: 1},
} { } {
t.Run(strconv.Itoa(tc.commentCount), func(t *testing.T) { t.Run(fmt.Sprintf("maxResponseItems=%d/comments=%d", tc.maxResponseItems, tc.commentCount), func(t *testing.T) {
commentRequests := 0 commentRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
@@ -352,6 +354,7 @@ func TestGiteaDownloadCommentsPaging(t *testing.T) {
downloader, err := NewGiteaDownloader(t.Context(), server.URL, "o/r", "", "", "") downloader, err := NewGiteaDownloader(t.Context(), server.URL, "o/r", "", "", "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.pageSize, downloader.maxPerPage)
comments, _, err := downloader.GetComments(t.Context(), &base.Issue{Number: 1}) comments, _, err := downloader.GetComments(t.Context(), &base.Issue{Number: 1})
require.NoError(t, err) require.NoError(t, err)
+17 -1
View File
@@ -47,6 +47,20 @@
</div> </div>
</div> </div>
<!-- Authentication Source Filter Menu Item -->
{{if .HasAuthSources}}
<div class="ui dropdown type jump item">
<span class="text">{{ctx.Locale.Tr "admin.users.auth_source"}}</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu flex-items-menu">
{{range $index, $option := .AuthSourceFilterOptions}}
{{if eq $index 1}}<div class="divider"></div>{{end}}
<label class="item"><input type="radio" name="source_id" value="{{$option.Value}}" {{if $option.Selected}}checked{{end}}> {{$option.Label}}</label>
{{end}}
</div>
</div>
{{end}}
<!-- Sort Menu Item --> <!-- Sort Menu Item -->
<div class="ui dropdown type jump item"> <div class="ui dropdown type jump item">
<span class="text"> <span class="text">
@@ -75,6 +89,7 @@
{{SortArrow "alphabetically" "reversealphabetically" $.SortType true}} {{SortArrow "alphabetically" "reversealphabetically" $.SortType true}}
</th> </th>
<th>{{ctx.Locale.Tr "email"}}</th> <th>{{ctx.Locale.Tr "email"}}</th>
<th>{{ctx.Locale.Tr "admin.users.auth_source"}}</th>
<th>{{ctx.Locale.Tr "admin.users.activated"}}</th> <th>{{ctx.Locale.Tr "admin.users.activated"}}</th>
<th>{{ctx.Locale.Tr "admin.users.restricted"}}</th> <th>{{ctx.Locale.Tr "admin.users.restricted"}}</th>
<th>{{ctx.Locale.Tr "admin.users.2fa"}}</th> <th>{{ctx.Locale.Tr "admin.users.2fa"}}</th>
@@ -102,6 +117,7 @@
{{template "shared/user/user_type_label" .}} {{template "shared/user/user_type_label" .}}
</td> </td>
<td class="gt-ellipsis tw-max-w-48">{{.Email}}</td> <td class="gt-ellipsis tw-max-w-48">{{.Email}}</td>
<td class="gt-ellipsis tw-max-w-32">{{if .LoginSource}}{{index $.SourceNames .LoginSource}}{{else}}{{ctx.Locale.Tr "admin.users.local"}}{{end}}</td>
<td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td>
<td>{{svg (Iif .IsRestricted "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif .IsRestricted "octicon-check" "octicon-x")}}</td>
<td>{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}}</td>
@@ -119,7 +135,7 @@
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr class="no-results-row"><td class="tw-text-center" colspan="9">{{ctx.Locale.Tr "no_results_found"}}</td></tr> <tr class="no-results-row"><td class="tw-text-center" colspan="10">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
+1 -1
View File
@@ -11939,7 +11939,7 @@
"operationId": "adminSearchUsers", "operationId": "adminSearchUsers",
"parameters": [ "parameters": [
{ {
"description": "ID of the user's login source to search for", "description": "ID of the user's login source to search for, 0 means the local users",
"in": "query", "in": "query",
"name": "source_id", "name": "source_id",
"schema": { "schema": {
+1 -1
View File
@@ -825,7 +825,7 @@
{ {
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"description": "ID of the user's login source to search for", "description": "ID of the user's login source to search for, 0 means the local users",
"name": "source_id", "name": "source_id",
"in": "query" "in": "query"
}, },
+2
View File
@@ -17,6 +17,8 @@ test('create a bot and manage its access token', async ({page, request}) => {
await page.getByRole('row', {name: /^user /}).getByRole('radio', {name: 'Read', exact: true}).check(); await page.getByRole('row', {name: /^user /}).getByRole('radio', {name: 'Read', exact: true}).check();
await page.getByRole('button', {name: 'Generate Token'}).click(); await page.getByRole('button', {name: 'Generate Token'}).click();
const token = await page.getByRole('code').textContent(); const token = await page.getByRole('code').textContent();
await page.getByRole('button', {name: 'Copy', exact: true}).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(token);
const response = await request.get('/api/v1/user', {headers: {Authorization: `token ${token}`}}); const response = await request.get('/api/v1/user', {headers: {Authorization: `token ${token}`}});
expect(await response.json()).toMatchObject({login: botName, type: 'Bot'}); expect(await response.json()).toMatchObject({login: botName, type: 'Bot'});
+12
View File
@@ -39,6 +39,18 @@ test('pdf file', async ({page, request}) => {
await assertFlushWithParent(container, page.locator('.file-view')); await assertFlushWithParent(container, page.locator('.file-view'));
}); });
test('code line anchors', async ({page, request}) => {
const repoName = `e2e-line-anchor-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
await apiCreateFiles(request, owner, repoName, [{path: 'test.txt', content: 'a\n'}]);
const url = `/${owner}/${repoName}/src/branch/main/test.txt`;
await page.goto(`${url}#L0`);
await page.goto(`${url}#L1`);
await expect(page.locator('.code-view tr.active')).toHaveCount(1);
await assertNoJsError(page);
});
test('asciicast file', async ({page, request}) => { test('asciicast file', async ({page, request}) => {
const repoName = `e2e-asciicast-render-${randomString(8)}`; const repoName = `e2e-asciicast-render-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER; const owner = env.GITEA_TEST_E2E_USER;
+45
View File
@@ -16,8 +16,10 @@ import (
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/test" "gitea.dev/modules/test"
"gitea.dev/services/auth/source/ldap"
"gitea.dev/tests" "gitea.dev/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -34,6 +36,49 @@ func TestAdminViewUsers(t *testing.T) {
session.MakeRequest(t, req, http.StatusForbidden) session.MakeRequest(t, req, http.StatusForbidden)
} }
func TestAdminViewUsersFilterAuthSource(t *testing.T) {
defer tests.PrepareTestEnv(t)()
source := &auth_model.Source{Type: auth_model.LDAP, Name: "test-user-list-filter", IsActive: false, Cfg: &ldap.Source{}} // users stay attached to a deactivated source
require.NoError(t, auth_model.CreateSource(t.Context(), source))
user2 := &user_model.User{ID: 2, LoginType: auth_model.LDAP, LoginSource: source.ID}
require.NoError(t, user_model.UpdateUserCols(t.Context(), user2, "login_type", "login_source"))
session := loginUser(t, "user1")
listUsers := func(query string) (*HTMLDoc, []string) {
req := NewRequest(t, "GET", "/-/admin/users?"+query)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
return doc, doc.Find("table tbody tr td:nth-child(2) a").Map(func(_ int, s *goquery.Selection) string {
return s.Text()
})
}
doc, users := listUsers("source_id=") // the "All" option submits an empty value
AssertHTMLElement(t, doc, `input[name="source_id"][value=""][checked]`, true)
assert.Subset(t, users, []string{"user1", "user2"})
doc, users = listUsers(fmt.Sprintf("source_id=%d", source.ID)) // the "test-user-list-filter" LDAP source
AssertHTMLElement(t, doc, fmt.Sprintf(`input[name="source_id"][value="%d"][checked]`, source.ID), true)
assert.Equal(t, []string{"user2"}, users)
assert.Equal(t, source.Name, doc.Find("table tbody tr td:nth-child(4)").Text())
_, users = listUsers("source_id=0") // 0 means the "Local" source
assert.Contains(t, users, "user1")
assert.NotContains(t, users, "user2")
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadAdmin)
req := NewRequest(t, "GET", "/api/v1/admin/users?source_id=0").AddTokenAuth(token) // the API also treats 0 as local users
apiUsers := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), []api.User{})
apiUserNames := make([]string, 0, len(apiUsers))
for _, u := range apiUsers {
apiUserNames = append(apiUserNames, u.UserName)
}
assert.Contains(t, apiUserNames, "user1")
assert.NotContains(t, apiUserNames, "user2")
}
func TestAdminViewUser(t *testing.T) { func TestAdminViewUser(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
@@ -38,6 +38,11 @@ func TestAPIGetIssueAttachment(t *testing.T) {
apiAttachment := DecodeJSON(t, resp, &api.Attachment{}) apiAttachment := DecodeJSON(t, resp, &api.Attachment{})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
commentAttachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 3, RepoID: repo.ID})
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: commentAttachment.IssueID}).Index, commentAttachment.ID)).
AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusNotFound)
} }
func TestAPIListIssueAttachments(t *testing.T) { func TestAPIListIssueAttachments(t *testing.T) {
+4 -3
View File
@@ -170,8 +170,9 @@ func TestPackageNpm(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
rootPaths := []string{ rootPaths := []string{
fmt.Sprintf("/api/packages/%s/npm/@scope/test-package", user.Name), "/api/packages/user2/npm/@scope/test-package",
fmt.Sprintf("/api/packages/%s/npm/@scope%%2ftest-package", user.Name), "/api/packages/user2/npm/@scope%2Ftest-package",
"/api/packages/user2/npm/%40scope%2ftest-package",
} }
for _, root := range rootPaths { for _, root := range rootPaths {
req := NewRequest(t, "GET", fmt.Sprintf("%s/-/%s/%s", root, packageVersion, filename)).AddTokenAuth(token) req := NewRequest(t, "GET", fmt.Sprintf("%s/-/%s/%s", root, packageVersion, filename)).AddTokenAuth(token)
@@ -186,7 +187,7 @@ func TestPackageNpm(t *testing.T) {
pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeNpm) pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeNpm)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, pvs, 1) assert.Len(t, pvs, 1)
assert.Equal(t, int64(4), pvs[0].DownloadCount) assert.EqualValues(t, 6, pvs[0].DownloadCount)
}) })
t.Run("PackageMetadata", func(t *testing.T) { t.Run("PackageMetadata", func(t *testing.T) {
@@ -22,6 +22,10 @@ func TestAPIGetRawFileOrLFS(t *testing.T) {
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String()) assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String())
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo2/media/test.xml").AddTokenAuth(getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository))
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
// Test with LFS // Test with LFS
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
createLFSTestRepository(t, "repo-lfs-test") createLFSTestRepository(t, "repo-lfs-test")
+10
View File
@@ -16,6 +16,8 @@ import (
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd" "gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
repo_service "gitea.dev/services/repository" repo_service "gitea.dev/services/repository"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -175,6 +177,14 @@ func TestGitPushVisibilityOption(t *testing.T) {
doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t) doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t)
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID}) repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository") assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository")
defer test.MockVariableValue(&setting.Repository.ForcePrivate, true)()
forcedRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{Name: "repo-visibility-forced", DefaultBranch: "master", IsPrivate: true})
require.NoError(t, err)
u.Path = forcedRepo.FullName() + ".git"
doGitAddRemote(gitPath, "forced", u)(t)
doGitPushTestRepository(gitPath, "forced", "master", "-o", "repo.private=false")(t)
assert.True(t, unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: forcedRepo.ID}).IsPrivate)
}) })
} }
+9
View File
@@ -571,6 +571,15 @@ func testRefreshTokenInvalidation(t *testing.T) {
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription) assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription)
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
"grant_type": "refresh_token",
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
"client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
"redirect_uri": "https://example.com",
"refresh_token": parsed.AccessToken,
})
MakeRequest(t, req, http.StatusBadRequest)
req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
"grant_type": "refresh_token", "grant_type": "refresh_token",
"client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+9
View File
@@ -84,6 +84,7 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22}) org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22})
req := NewRequest(t, "GET", "/"+org22.Name) req := NewRequest(t, "GET", "/"+org22.Name)
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", "/"+org22.Name).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound)
session := loginUser(t, "user1") session := loginUser(t, "user1")
oldName := org22.Name oldName := org22.Name
@@ -106,6 +107,8 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23}) org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23})
req = NewRequest(t, "GET", "/"+org23.Name) req = NewRequest(t, "GET", "/"+org23.Name)
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
strangerSession := loginUser(t, "user4")
strangerSession.MakeRequest(t, NewRequest(t, "POST", "/"+org23.Name+"?action=follow"), http.StatusNotFound)
oldName = org23.Name oldName = org23.Name
newName = "org23_renamed" newName = "org23_renamed"
@@ -127,6 +130,8 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31}) user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31})
req = NewRequest(t, "GET", "/"+user31.Name) req = NewRequest(t, "GET", "/"+user31.Name)
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", "/"+user31.Name).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound)
strangerSession.MakeRequest(t, NewRequest(t, "POST", "/"+user31.Name+"?action=follow"), http.StatusNotFound)
oldName = user31.Name oldName = user31.Name
newName = "user31_renamed" newName = "user31_renamed"
@@ -330,6 +335,10 @@ func testGetUserRss(t *testing.T) {
session := loginUser(t, "user2") session := loginUser(t, "user2")
req = NewRequestf(t, "GET", "/non-existent-user.rss") req = NewRequestf(t, "GET", "/non-existent-user.rss")
session.MakeRequest(t, req, http.StatusNotFound) session.MakeRequest(t, req, http.StatusNotFound)
defer test.MockVariableValue(&setting.Other.EnableFeed, false)()
MakeRequest(t, NewRequestf(t, "GET", "/%s.rss", user34), http.StatusNotFound)
MakeRequest(t, NewRequestf(t, "GET", "/%s", user34).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound)
} }
func testUserListStopWatches(t *testing.T) { func testUserListStopWatches(t *testing.T) {
+1
View File
@@ -58,6 +58,7 @@ function selectRange(range: string): Element | null {
stopLineNum = tmp; stopLineNum = tmp;
range = `${stop}-${start}`; range = `${stop}-${start}`;
} }
if (startLineNum < 1) return null;
const first = elLineNums[startLineNum - 1] ?? null; const first = elLineNums[startLineNum - 1] ?? null;
for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) { for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) {
+2 -4
View File
@@ -64,7 +64,7 @@ function replaceWithFeedbackSvg(origSvg: SVGElement, success: boolean): () => vo
// Enable clipboard copy from HTML attributes. These properties are supported: // Enable clipboard copy from HTML attributes. These properties are supported:
// - data-clipboard-text: Direct text to copy // - data-clipboard-text: Direct text to copy
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied // - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of other elements will be copied
export function initGlobalCopyToClipboardListener() { export function initGlobalCopyToClipboardListener() {
document.addEventListener('click', async (e) => { document.addEventListener('click', async (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-clipboard-text], [data-clipboard-target]'); const target = (e.target as HTMLElement).closest<HTMLElement>('[data-clipboard-text], [data-clipboard-target]');
@@ -78,10 +78,8 @@ export function initGlobalCopyToClipboardListener() {
const textTarget = document.querySelector(textSelector)!; const textTarget = document.querySelector(textSelector)!;
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') { if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value; text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
} else if (textTarget.nodeName === 'DIV') {
text = textTarget.textContent;
} else { } else {
throw new Error(`Unsupported element for clipboard target: ${textSelector}`); text = textTarget.textContent;
} }
} }
// now, text can not be null // now, text can not be null
+4
View File
@@ -17,7 +17,11 @@ test('isGiteaError', () => {
expect(isGiteaError('', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false); expect(isGiteaError('', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
expect(isGiteaError('', `Error\n at https://other-site.com/script.js:1:1`)).toBe(false); expect(isGiteaError('', `Error\n at https://other-site.com/script.js:1:1`)).toBe(false);
expect(isGiteaError('', `Error\n at ${origin}/assets/js/index.abc123.js:1:1`)).toBe(true); expect(isGiteaError('', `Error\n at ${origin}/assets/js/index.abc123.js:1:1`)).toBe(true);
expect(isGiteaError('', `Error\n at ${origin}/web_src/js/index.ts:1:1`)).toBe(false);
expect(isGiteaError(`${origin}/assets/js/index.js`, `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false); expect(isGiteaError(`${origin}/assets/js/index.js`, `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
vi.spyOn(window.config, 'runModeIsProd', 'get').mockReturnValue(false);
expect(isGiteaError('', `Error\n at ${origin}/web_src/js/index.ts:1:1`)).toBe(true);
vi.restoreAllMocks();
}); });
test('showGlobalErrorMessage', () => { test('showGlobalErrorMessage', () => {
+1
View File
@@ -58,6 +58,7 @@ export function isGiteaError(filename: string, stack: string): boolean {
if (extensionRe.test(filename) || extensionRe.test(stack)) return false; if (extensionRe.test(filename) || extensionRe.test(stack)) return false;
const assetBaseUrl = new URL(`${windowConfig()?.assetUrlPrefix}/`, window.location.origin).href; const assetBaseUrl = new URL(`${windowConfig()?.assetUrlPrefix}/`, window.location.origin).href;
if (filename && !filename.startsWith(assetBaseUrl) && !filename.startsWith(window.location.origin)) return false; if (filename && !filename.startsWith(assetBaseUrl) && !filename.startsWith(window.location.origin)) return false;
if (!windowConfig()?.runModeIsProd && stack.includes(`${window.location.origin}/web_src/`)) return true;
return !stack || stack.includes(assetBaseUrl); return !stack || stack.includes(assetBaseUrl);
} }