mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-04 07:33:44 +09:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99ac787400 | |||
| 1856fef7a8 | |||
| 7440f1e452 | |||
| 638c75a4d3 | |||
| 575754cbeb | |||
| f24f3e1eda | |||
| 1f615a406f | |||
| 9c77a87908 | |||
| 4c3df3ab3c | |||
| 565957503c | |||
| e48591ba64 | |||
| f2a08e0261 | |||
| 532fb8f4f4 | |||
| 25416e9be7 | |||
| 1c4fff096f | |||
| d16daed041 | |||
| 2d58c8c3df | |||
| 1e28bb1bd7 | |||
| f0e8c3c3d0 | |||
| 7c58b73243 | |||
| 6546382f4e | |||
| 0930bd71fe | |||
| 15b8a5805a |
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
|
||||
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||
gitea_fork: giteabot/gitea
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
name: giteabot-review
|
||||
|
||||
# Relays PR reviews to giteabot.yml through its workflow_run trigger, because review
|
||||
# runs on fork PRs get no secrets and a read-only token. The job itself does nothing.
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types:
|
||||
- submitted
|
||||
- edited
|
||||
- dismissed
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
relay:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: "true"
|
||||
@@ -20,13 +20,12 @@ on:
|
||||
- closed
|
||||
- review_requested
|
||||
- review_request_removed
|
||||
# Review events keep review-derived state such as lgtm labels and status checks
|
||||
# in sync after approvals, edits, or dismissals.
|
||||
pull_request_review:
|
||||
# Reviews arrive through giteabot-review because fork PR review runs get no secrets
|
||||
workflow_run:
|
||||
workflows:
|
||||
- giteabot-review
|
||||
types:
|
||||
- submitted
|
||||
- edited
|
||||
- dismissed
|
||||
- requested
|
||||
# Periodic maintenance is still useful as a backstop for queue cleanup and
|
||||
# other housekeeping, even though main pushes now trigger it promptly.
|
||||
schedule:
|
||||
@@ -43,12 +42,12 @@ on:
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ format('{0}-{1}', github.workflow, (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && format('pr-{0}', github.event.pull_request.number) || 'maintenance') }}
|
||||
group: ${{ format('{0}-{1}', github.workflow, github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'workflow_run' && format('review-{0}', github.event.workflow_run.head_sha) || 'maintenance') }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
giteabot:
|
||||
if: github.repository == 'go-gitea/gitea'
|
||||
if: github.repository == 'go-gitea/gitea' && (github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request_review')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
@@ -57,9 +56,7 @@ jobs:
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
steps:
|
||||
# pull_request_review runs without repository secrets on fork PRs, so fall
|
||||
# back to the workflow token for the non-backport checks handled here.
|
||||
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
|
||||
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITEABOT_TOKEN || github.token }}
|
||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
||||
|
||||
+1
-21
@@ -21,19 +21,8 @@ import (
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
)
|
||||
|
||||
func acmeExternalAccountBinding() (acme.EAB, bool, error) {
|
||||
if setting.AcmeEABKID == "" && setting.AcmeEABHMAC == "" {
|
||||
return acme.EAB{}, false, nil
|
||||
}
|
||||
if setting.AcmeEABKID == "" || setting.AcmeEABHMAC == "" {
|
||||
return acme.EAB{}, false, errors.New("both ACME_EAB_KID and ACME_EAB_HMAC must be set")
|
||||
}
|
||||
return acme.EAB{KeyID: setting.AcmeEABKID, MACKey: setting.AcmeEABHMAC}, true, nil
|
||||
}
|
||||
|
||||
func getCARoot(path string) (*x509.CertPool, error) {
|
||||
r, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -77,14 +66,6 @@ func runACME(listenAddr string, m http.Handler) error {
|
||||
log.Warn("Failed to parse CA Root certificate, using default CA trust: %v", err)
|
||||
}
|
||||
}
|
||||
externalAccountBinding, hasExternalAccount, err := acmeExternalAccountBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var externalAccount *acme.EAB
|
||||
if hasExternalAccount {
|
||||
externalAccount = &externalAccountBinding
|
||||
}
|
||||
// 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"
|
||||
// And one more thing, no idea why we should set the global default variables here
|
||||
@@ -103,7 +84,6 @@ func runACME(listenAddr string, m http.Handler) error {
|
||||
Email: setting.AcmeEmail,
|
||||
Agreed: setting.AcmeTOS,
|
||||
Profile: setting.AcmeProfile,
|
||||
ExternalAccount: externalAccount,
|
||||
DisableHTTPChallenge: !enableHTTPChallenge,
|
||||
DisableTLSALPNChallenge: !enableTLSALPNChallenge,
|
||||
ListenHost: setting.HTTPAddr,
|
||||
@@ -120,7 +100,7 @@ func runACME(listenAddr string, m http.Handler) error {
|
||||
// takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519).
|
||||
// Prefer keeping the existing cert and retrying renewals asynchronously.
|
||||
ctx := graceful.GetManager().ShutdownContext()
|
||||
err = magic.ManageSync(ctx, []string{setting.AppDomain})
|
||||
err := magic.ManageSync(ctx, []string{setting.AppDomain})
|
||||
if err != nil {
|
||||
cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.AppDomain)
|
||||
if cacheErr != nil || cert.Expired() {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// 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, configured, err := acmeExternalAccountBinding()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, configured)
|
||||
assert.Empty(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, configured, err = acmeExternalAccountBinding()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, configured)
|
||||
assert.Equal(t, acme.EAB{KeyID: "kid", MACKey: "hmac"}, binding)
|
||||
}
|
||||
+18
-21
@@ -155,7 +155,7 @@
|
||||
;; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER.
|
||||
;BUILTIN_SSH_SERVER_USER =
|
||||
;;
|
||||
;; Domain name to be exposed in clone URL, defaults to the domain part of ROOT_URL
|
||||
;; Domain name to be exposed in clone URL, defaults to DOMAIN or the domain part of ROOT_URL
|
||||
;SSH_DOMAIN =
|
||||
;;
|
||||
;; 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
|
||||
;; 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
|
||||
;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gitea.ed25519, ssh/gitea.ecdsa, ssh/gogs.rsa
|
||||
;SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gogs.rsa
|
||||
;;
|
||||
;; Enable SSH Authorized Key Backup when rewriting all keys, default is false
|
||||
;SSH_AUTHORIZED_KEYS_BACKUP = false
|
||||
@@ -237,7 +237,7 @@
|
||||
;SSH_PER_WRITE_PER_KB_TIMEOUT = 30s
|
||||
;;
|
||||
;; Indicate whether to check minimum key size with corresponding type
|
||||
;MINIMUM_KEY_SIZE_CHECK = true
|
||||
;MINIMUM_KEY_SIZE_CHECK = false
|
||||
;;
|
||||
;; TLS Settings: Either ACME or manual
|
||||
;; (Other common TLS configuration are found before)
|
||||
@@ -264,11 +264,6 @@
|
||||
;; ACME profile to request from the CA (e.g. "shortlived" for raw-IP certificates)
|
||||
;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)
|
||||
;; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic)
|
||||
;ACME_DIRECTORY = https
|
||||
@@ -841,7 +836,7 @@ LEVEL = Info
|
||||
;EMAIL_DOMAIN_BLOCKLIST =
|
||||
;;
|
||||
;; Disallow registration, only allow admins to create accounts.
|
||||
;DISABLE_REGISTRATION = true
|
||||
;DISABLE_REGISTRATION = false
|
||||
;;
|
||||
;; Allow registration only using gitea itself, it works only when DISABLE_REGISTRATION is false
|
||||
;ALLOW_ONLY_INTERNAL_REGISTRATION = false
|
||||
@@ -973,11 +968,12 @@ LEVEL = Info
|
||||
;; 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
|
||||
;; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS. Default
|
||||
;; value is "noreply." + the domain part of ROOT_URL
|
||||
;NO_REPLY_ADDRESS =
|
||||
;; value is "noreply." + DOMAIN, where DOMAIN resolves to the value from server.DOMAIN
|
||||
;; Note: do not use the <DOMAIN> notation below
|
||||
;NO_REPLY_ADDRESS = ; noreply.<DOMAIN>
|
||||
;;
|
||||
;; Show Registration button, defaults to true only if both DISABLE_REGISTRATION and ALLOW_ONLY_EXTERNAL_REGISTRATION are false
|
||||
;SHOW_REGISTRATION_BUTTON = false
|
||||
;; Show Registration button
|
||||
;SHOW_REGISTRATION_BUTTON = true
|
||||
;;
|
||||
;; Show milestones dashboard page - a view of all the user's milestones
|
||||
;SHOW_MILESTONES_DASHBOARD_PAGE = true
|
||||
@@ -1674,13 +1670,13 @@ LEVEL = Info
|
||||
;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;
|
||||
;; General queue type, currently support: level, channel, redis, dummy
|
||||
;; default to level
|
||||
;TYPE = level
|
||||
;; General queue queue type, currently support: persistable-channel, channel, level, redis, dummy
|
||||
;; default to persistable-channel
|
||||
;TYPE = persistable-channel
|
||||
;;
|
||||
;; data-dir for storing level queues, individual queues will default to `queues/common` meaning the queue is shared.
|
||||
;; data-dir for storing persistable queues and level queues, individual queues will default to `queues/common` meaning the queue is shared.
|
||||
;; Relative paths will be made absolute against "APP_DATA_PATH"
|
||||
;DATADIR = queues/common
|
||||
;DATADIR = queues/
|
||||
;;
|
||||
;; Default queue length before a channel queue will block
|
||||
;LENGTH = 100000
|
||||
@@ -1688,7 +1684,7 @@ LEVEL = Info
|
||||
;; Batch size to send for batched queues
|
||||
;BATCH_LENGTH = 20
|
||||
;;
|
||||
;; When `TYPE` is `level`, this provides a directory for the underlying leveldb
|
||||
;; When `TYPE` is `persistable-channel`, this provides a directory for the underlying leveldb
|
||||
;; 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.
|
||||
;CONN_STR =
|
||||
@@ -1756,6 +1752,7 @@ LEVEL = Info
|
||||
;ENABLE_OPENID_SIGNIN = false
|
||||
;;
|
||||
;; Whether to allow registering via OpenID
|
||||
;; Do not include to rely on rhw DISABLE_REGISTRATION setting
|
||||
;;ENABLE_OPENID_SIGNUP = false
|
||||
;;
|
||||
;; Allowed URI patterns (POSIX regexp).
|
||||
@@ -2018,8 +2015,8 @@ LEVEL = Info
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;
|
||||
;; Either "memory", "file", "redis", "db", "mysql", "couchbase", "memcache" or "postgres"
|
||||
;; Default is "file". "db" will reuse the configuration in [database]
|
||||
;PROVIDER = file
|
||||
;; Default is "memory". "db" will reuse the configuration in [database]
|
||||
;PROVIDER = memory
|
||||
;;
|
||||
;; Provider config options
|
||||
;; memory: doesn't have any config yet
|
||||
|
||||
@@ -62,6 +62,10 @@ Operations that must roll back together should run inside `db.WithTx()` (or
|
||||
Functions that participate in a transaction take a `context.Context` as their first
|
||||
parameter so the transaction can be propagated.
|
||||
|
||||
PostgreSQL, MySQL and MSSQL (via `READ_COMMITTED_SNAPSHOT`) read the last committed
|
||||
row version, so reads never wait for writers. Guard read-then-write logic with a
|
||||
conditional `UPDATE` or a lock.
|
||||
|
||||
### XORM gotchas
|
||||
|
||||
- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates
|
||||
|
||||
@@ -68,7 +68,6 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.24
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
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/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/microsoft/go-mssqldb v1.11.2
|
||||
@@ -110,7 +109,7 @@ require (
|
||||
modernc.org/sqlite v1.59.0
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
xorm.io/builder v0.3.13
|
||||
xorm.io/xorm v1.4.1
|
||||
xorm.io/xorm v1.4.3
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -200,6 +199,7 @@ require (
|
||||
github.com/markbates/going v1.0.3 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // 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/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
|
||||
@@ -862,5 +862,5 @@ pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0=
|
||||
pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
|
||||
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
||||
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||
xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8=
|
||||
xorm.io/xorm v1.4.1/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
||||
xorm.io/xorm v1.4.3 h1:MwWFWzVr+/6D07qGCDhBAfABcuT0gvqY3XmTy1215BM=
|
||||
xorm.io/xorm v1.4.3/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
||||
|
||||
@@ -298,6 +298,12 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// A short page means no waiting jobs remain beyond it.
|
||||
isLastPage := len(jobs) < pickTaskBatchSize
|
||||
if !isLastPage {
|
||||
last := jobs[len(jobs)-1] // read before a lost claim bumps Updated
|
||||
cursorUpdated, cursorID = last.Updated, last.ID
|
||||
}
|
||||
|
||||
for _, v := range jobs {
|
||||
if !runner.CanMatchLabels(v.RunsOn) {
|
||||
@@ -313,12 +319,9 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
// Another runner claimed this job concurrently; try the next one.
|
||||
}
|
||||
|
||||
// A short page means no waiting jobs remain beyond it.
|
||||
if len(jobs) < pickTaskBatchSize {
|
||||
if isLastPage {
|
||||
return nil, false, nil
|
||||
}
|
||||
last := jobs[len(jobs)-1]
|
||||
cursorUpdated, cursorID = last.Updated, last.ID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +564,12 @@ func (grant *OAuth2Grant) SetNonce(ctx context.Context, nonce string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateGrantScope(ctx context.Context, grant *OAuth2Grant, newScope string) error {
|
||||
grant.Scope = newScope
|
||||
_, err := db.GetEngine(ctx).ID(grant.ID).Cols("scope").Update(grant)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOAuth2GrantByID returns the grant with the given ID
|
||||
func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) {
|
||||
grant = new(OAuth2Grant)
|
||||
|
||||
@@ -5,7 +5,9 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
@@ -59,6 +61,11 @@ func InitEngine(ctx context.Context) error {
|
||||
xe.SetMaxIdleConns(setting.Database.MaxIdleConns)
|
||||
xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime)
|
||||
|
||||
if setting.Database.Type.IsMySQL() {
|
||||
// like PostgreSQL and MSSQL, avoids MariaDB snapshot isolation errors
|
||||
xe.SetDefaultTxOptions(&sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||
}
|
||||
|
||||
if setting.Database.SlowQueryThreshold > 0 {
|
||||
xe.AddHook(&EngineHook{
|
||||
Threshold: setting.Database.SlowQueryThreshold,
|
||||
@@ -103,6 +110,10 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
|
||||
|
||||
preprocessDatabaseCollation(xormEngine)
|
||||
|
||||
if setting.Database.Type.IsMSSQL() {
|
||||
enableMSSQLReadCommittedSnapshot(ctx, xormEngine)
|
||||
}
|
||||
|
||||
// We have to run migrateFunc here in case the user is re-running installation on a previously created DB.
|
||||
// If we do not then table schemas will be changed and there will be conflicts when the migrations run properly.
|
||||
//
|
||||
@@ -125,3 +136,12 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// enableMSSQLReadCommittedSnapshot stops MSSQL reads waiting on writers, like PostgreSQL and MySQL
|
||||
func enableMSSQLReadCommittedSnapshot(ctx context.Context, engine EngineMigration) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // ALTER waits for all other connections to close
|
||||
defer cancel()
|
||||
if _, err := engine.Context(ctx).Exec("IF (SELECT is_read_committed_snapshot_on FROM sys.databases WHERE database_id = DB_ID()) = 0 ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON"); err != nil {
|
||||
log.Error("Unable to set READ_COMMITTED_SNAPSHOT=ON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,17 +311,17 @@ func (opts *CommitStatusOptions) ToConds() builder.Cond {
|
||||
func (opts *CommitStatusOptions) ToOrders() string {
|
||||
switch opts.SortType {
|
||||
case "oldest":
|
||||
return "created_unix ASC"
|
||||
return "created_unix ASC, `index` ASC"
|
||||
case "recentupdate":
|
||||
return "updated_unix DESC"
|
||||
return "updated_unix DESC, `index` DESC"
|
||||
case "leastupdate":
|
||||
return "updated_unix ASC"
|
||||
return "updated_unix ASC, `index` ASC"
|
||||
case "leastindex":
|
||||
return "`index` DESC"
|
||||
case "highestindex":
|
||||
return "`index` ASC"
|
||||
default:
|
||||
return "created_unix DESC"
|
||||
return "created_unix DESC, `index` DESC" // timestamps have 1s resolution, `index` keeps paging stable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,28 +32,15 @@ func TestGetCommitStatuses(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 5, int(maxResults))
|
||||
assert.Len(t, statuses, 5)
|
||||
|
||||
assert.Equal(t, "ci/awesomeness", statuses[0].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
|
||||
var indexes []int64
|
||||
for _, status := range statuses {
|
||||
indexes = append(indexes, status.Index)
|
||||
}
|
||||
assert.Equal(t, []int64{5, 4, 3, 2, 1}, indexes)
|
||||
assert.Equal(t, "deploy/awesomeness", statuses[0].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusError, statuses[0].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "cov/awesomeness", statuses[1].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusWarning, statuses[1].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[1].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "cov/awesomeness", statuses[2].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[2].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "ci/awesomeness", statuses[3].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusFailure, statuses[3].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[3].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "deploy/awesomeness", statuses[4].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusError, statuses[4].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].APIURL(t.Context()))
|
||||
|
||||
statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
||||
ListOptions: db.ListOptions{Page: 2, PageSize: 50},
|
||||
RepoID: repo1.ID,
|
||||
|
||||
@@ -123,23 +123,13 @@ func (protectBranch *ProtectedBranch) LoadRepo(ctx context.Context) (err error)
|
||||
}
|
||||
|
||||
// CanUserPush returns if some user could push to this protected branch
|
||||
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User) bool {
|
||||
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool {
|
||||
if !protectBranch.CanPush {
|
||||
return false
|
||||
}
|
||||
|
||||
if !protectBranch.EnableWhitelist {
|
||||
if err := protectBranch.LoadRepo(ctx); err != nil {
|
||||
log.Error("LoadRepo: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
writeAccess, err := access_model.HasAccessUnit(ctx, user, protectBranch.Repo, unit.TypeCode, perm.AccessModeWrite)
|
||||
if err != nil {
|
||||
log.Error("HasAccessUnit: %v", err)
|
||||
return false
|
||||
}
|
||||
return writeAccess
|
||||
return permissionInRepo.CanWrite(unit.TypeCode)
|
||||
}
|
||||
|
||||
if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) {
|
||||
@@ -160,17 +150,17 @@ func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *use
|
||||
|
||||
// CanUserForcePush returns if some user could force push to this protected branch
|
||||
// Since force-push extends normal push, we also check if user has regular push access
|
||||
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User) bool {
|
||||
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool {
|
||||
if !protectBranch.CanForcePush {
|
||||
return false
|
||||
}
|
||||
|
||||
if !protectBranch.EnableForcePushAllowlist {
|
||||
return protectBranch.CanUserPush(ctx, user)
|
||||
return protectBranch.CanUserPush(ctx, user, permissionInRepo)
|
||||
}
|
||||
|
||||
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
|
||||
return protectBranch.CanUserPush(ctx, user)
|
||||
return protectBranch.CanUserPush(ctx, user, permissionInRepo)
|
||||
}
|
||||
|
||||
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
|
||||
@@ -182,7 +172,7 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user
|
||||
log.Error("IsUserInTeams: %v", err)
|
||||
return false
|
||||
}
|
||||
return in && protectBranch.CanUserPush(ctx, user)
|
||||
return in && protectBranch.CanUserPush(ctx, user, permissionInRepo)
|
||||
}
|
||||
|
||||
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
|
||||
|
||||
@@ -407,6 +407,21 @@ func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefN
|
||||
return git.RefNameFromPullIndex(pr.Index).String()
|
||||
}
|
||||
|
||||
func (pr *PullRequest) GetInstructionsCliArgs() (ret struct {
|
||||
BaseBranchArg string
|
||||
HeadBranchArg string
|
||||
LocalBranchArg string
|
||||
},
|
||||
) {
|
||||
ret.BaseBranchArg = util.ShellEscape(pr.BaseBranch)
|
||||
ret.HeadBranchArg = util.ShellEscape(pr.HeadBranch)
|
||||
ret.LocalBranchArg = ret.HeadBranchArg
|
||||
if pr.HeadRepo != nil && pr.HeadRepoID != pr.BaseRepoID {
|
||||
ret.LocalBranchArg = util.ShellEscape(pr.HeadRepo.OwnerName) + "-" + ret.HeadBranchArg
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR)
|
||||
func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
|
||||
opts := FindCommentsOptions{
|
||||
|
||||
@@ -40,8 +40,8 @@ type SearchUserOptions struct {
|
||||
Keyword string
|
||||
Types []UserType
|
||||
UID int64
|
||||
LoginName string // 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
|
||||
LoginName string // this option should be used only for admin user
|
||||
SourceID int64 // this option should be used only for admin user
|
||||
OrderBy db.SearchOrderBy
|
||||
Visible []structs.VisibleType
|
||||
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})
|
||||
}
|
||||
|
||||
if opts.SourceID.Has() {
|
||||
cond = cond.And(builder.Eq{"login_source": opts.SourceID.Value()})
|
||||
if opts.SourceID > 0 {
|
||||
cond = cond.And(builder.Eq{"login_source": opts.SourceID})
|
||||
}
|
||||
if opts.LoginName != "" {
|
||||
cond = cond.And(builder.Eq{"login_name": opts.LoginName})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// BatchChecker provides a reader for check-attribute content that can be long running
|
||||
@@ -120,12 +121,17 @@ func (c *BatchChecker) CheckPath(path string) (rs *Attributes, err error) {
|
||||
return fmt.Errorf("CheckPath timeout: %s", debugMsg)
|
||||
}
|
||||
|
||||
timeout := time.NewTimer(5 * time.Second)
|
||||
defer timeout.Stop()
|
||||
|
||||
rs = NewAttributes()
|
||||
for i := 0; i < c.attributesNum; i++ {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-timeout.C:
|
||||
// there is no "hang" problem now. This code is just used to catch other potential problems.
|
||||
return nil, reportTimeout()
|
||||
err = reportTimeout()
|
||||
setting.PanicInDevOrTesting("Unexpected timeout, need to investigate: %v", err)
|
||||
return nil, err
|
||||
case attr, ok := <-c.stdOut.ReadAttribute():
|
||||
if !ok {
|
||||
return nil, c.ctx.Err()
|
||||
|
||||
@@ -49,8 +49,8 @@ type Command struct {
|
||||
cmd *process.Cmd
|
||||
|
||||
cmdCtx context.Context
|
||||
cmdCancel process.CancelCauseFunc
|
||||
cmdFinished process.FinishedFunc
|
||||
cmdCtxCancel process.CancelCauseFunc
|
||||
cmdFinished func()
|
||||
cmdStartTime time.Time
|
||||
|
||||
pipelineFunc func(Context) error
|
||||
@@ -428,19 +428,24 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
|
||||
if c.callerInfo == "" {
|
||||
c.WithParentCallerInfo()
|
||||
}
|
||||
|
||||
// these logs are for debugging purposes only, so no guarantee of correctness or stability
|
||||
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString)
|
||||
log.Debug("git.Command: %s", desc)
|
||||
|
||||
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
|
||||
defer span.End()
|
||||
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
|
||||
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
|
||||
|
||||
var cmdCtxFinished func()
|
||||
if c.cmdTimeout <= 0 {
|
||||
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc)
|
||||
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContext(ctx, desc)
|
||||
} else {
|
||||
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
|
||||
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
|
||||
}
|
||||
c.cmdFinished = func() {
|
||||
cmdCtxFinished()
|
||||
span.End()
|
||||
}
|
||||
|
||||
c.cmdStartTime = time.Now()
|
||||
|
||||
@@ -27,6 +27,6 @@ func (c *cmdContext) CancelPipeline(err error) error {
|
||||
// * context canceled by pipeline caller with/without error (normal cancellation)
|
||||
// * context canceled by parent context (still context.Canceled error)
|
||||
// * other causes
|
||||
c.cmd.cmdCancel(pipelineError{err})
|
||||
c.cmd.cmdCtxCancel(pipelineError{err})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git/gitrepo"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
@@ -20,6 +24,7 @@ import (
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/cache"
|
||||
"github.com/go-git/go-git/v5/storage/filesystem"
|
||||
"github.com/go-git/go-git/v5/storage/filesystem/dotgit"
|
||||
)
|
||||
|
||||
const isGogit = true
|
||||
@@ -31,25 +36,98 @@ type Repository struct {
|
||||
gogitStorage *reindexingStorage
|
||||
}
|
||||
|
||||
// reindexingStorage picks up packs that git wrote after go-git loaded its index
|
||||
// https://github.com/go-git/go-git/issues/2439
|
||||
// reindexingStorage reloads the pack index when git added or removed packs after go-git loaded it
|
||||
// https://github.com/go-git/go-git/issues/2439 https://github.com/go-git/go-git/issues/1623
|
||||
// FIXME: gogit workaround, remove with the gogit build
|
||||
type reindexingStorage struct {
|
||||
*filesystem.Storage
|
||||
packs []plumbing.Hash
|
||||
}
|
||||
|
||||
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (plumbing.EncodedObject, error) {
|
||||
obj, err := s.Storage.EncodedObject(t, h)
|
||||
if !errors.Is(err, plumbing.ErrObjectNotFound) {
|
||||
return obj, err
|
||||
func isRepackError(err error) bool {
|
||||
return errors.Is(err, plumbing.ErrObjectNotFound) || errors.Is(err, dotgit.ErrPackfileNotFound) || errors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
// retry reruns fn while a concurrent repack keeps changing the packs
|
||||
func (s *reindexingStorage) retry(fn func() error) error {
|
||||
for {
|
||||
err := fn()
|
||||
if !isRepackError(err) {
|
||||
return err
|
||||
}
|
||||
packs, _ := s.ObjectPacks()
|
||||
if slices.Equal(packs, s.packs) {
|
||||
return err
|
||||
}
|
||||
s.packs = packs
|
||||
s.Reindex()
|
||||
}
|
||||
packs, _ := s.ObjectPacks()
|
||||
if slices.Equal(packs, s.packs) {
|
||||
return obj, err
|
||||
}
|
||||
|
||||
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (obj plumbing.EncodedObject, err error) {
|
||||
err = s.retry(func() (err error) {
|
||||
obj, err = s.Storage.EncodedObject(t, h)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.packs = packs
|
||||
s.Reindex()
|
||||
return s.Storage.EncodedObject(t, h)
|
||||
if _, ok := obj.(*plumbing.MemoryObject); ok {
|
||||
return obj, nil
|
||||
}
|
||||
return &lazyObject{EncodedObject: obj, storage: s}, nil
|
||||
}
|
||||
|
||||
// lazyObject looks up a large object again when its file got removed before Reader reopened it
|
||||
// FIXME: gogit workaround, remove with the gogit build
|
||||
type lazyObject struct {
|
||||
plumbing.EncodedObject
|
||||
storage *reindexingStorage
|
||||
}
|
||||
|
||||
func (o *lazyObject) Reader() (rc io.ReadCloser, err error) {
|
||||
rc, err = o.EncodedObject.Reader()
|
||||
if !isRepackError(err) {
|
||||
return rc, err
|
||||
}
|
||||
err = o.storage.retry(func() error {
|
||||
obj, err := o.storage.Storage.EncodedObject(o.Type(), o.Hash())
|
||||
if err == nil {
|
||||
o.EncodedObject = obj
|
||||
rc, err = obj.Reader()
|
||||
}
|
||||
return err
|
||||
})
|
||||
return rc, err
|
||||
}
|
||||
|
||||
// packIdxFS lists packs like git, only while their .idx exists
|
||||
// FIXME: gogit workaround, remove with the gogit build
|
||||
type packIdxFS struct {
|
||||
billy.Filesystem
|
||||
}
|
||||
|
||||
func (f packIdxFS) ReadDir(dir string) ([]os.FileInfo, error) {
|
||||
if dir != f.Join("objects", "pack") {
|
||||
return f.Filesystem.ReadDir(dir)
|
||||
}
|
||||
dirFile, err := os.Open(filepath.Join(f.Root(), dir))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dirFile.Close()
|
||||
infos, err := dirFile.Readdir(-1) // skips files removed before their lstat, unlike billy's ReadDir
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make(container.Set[string], len(infos))
|
||||
for _, info := range infos {
|
||||
names.Add(info.Name())
|
||||
}
|
||||
return slices.DeleteFunc(infos, func(info os.FileInfo) bool {
|
||||
base, isPack := strings.CutSuffix(info.Name(), ".pack")
|
||||
return isPack && !names.Contains(base+".idx")
|
||||
}), nil
|
||||
}
|
||||
|
||||
func openRepositoryInternal(gitRepo *Repository) error {
|
||||
@@ -71,7 +149,7 @@ func openRepositoryInternal(gitRepo *Repository) error {
|
||||
altFs = osfs.New("/")
|
||||
}
|
||||
gitRepo.objectFormatCache = ParseGogitHash(plumbing.ZeroHash).Type()
|
||||
storage := filesystem.NewStorageWithOptions(fs, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs})
|
||||
storage := filesystem.NewStorageWithOptions(packIdxFS{fs}, cache.NewObjectLRUDefault(), filesystem.Options{KeepDescriptors: true, LargeObjectThreshold: setting.Git.LargeObjectThreshold, AlternatesFS: altFs})
|
||||
packs, _ := storage.ObjectPacks()
|
||||
gitRepo.gogitStorage = &reindexingStorage{Storage: storage, packs: packs}
|
||||
gitRepo.gogitRepo, err = gogit.Open(gitRepo.gogitStorage, fs)
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -39,6 +45,41 @@ func TestRepository_GetBranches(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{}, branches)
|
||||
}
|
||||
|
||||
// FIXME: covers the gogit workarounds in repo_base_gogit.go, remove with the gogit build
|
||||
func TestReadsAfterConcurrentRepack(t *testing.T) {
|
||||
repoDir := t.TempDir()
|
||||
require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context()))
|
||||
content := strings.Repeat("a", int(setting.Git.LargeObjectThreshold)+1)
|
||||
for _, from := range []string{"", "from refs/heads/main^0\n"} {
|
||||
stdin := fmt.Sprintf("commit refs/heads/main\ncommitter a <a@a> 0 +0000\ndata 0\n%sM 100644 inline f\ndata %d\n%s\n", from, len(content), content)
|
||||
require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context()))
|
||||
require.NoError(t, gitcmd.NewCommand("repack", "-d").WithDir(repoDir).Run(t.Context()))
|
||||
}
|
||||
|
||||
repo, err := OpenRepositoryLocal(t.Context(), repoDir)
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
require.False(t, repo.IsObjectExist(t.Context(), "0000000000000000000000000000000000000001"))
|
||||
blobRepo, err := OpenRepositoryLocal(t.Context(), repoDir)
|
||||
require.NoError(t, err)
|
||||
defer blobRepo.Close()
|
||||
commit, err := blobRepo.GetBranchCommit(t.Context(), "main")
|
||||
require.NoError(t, err)
|
||||
readBlob := func() string {
|
||||
data, err := commit.GetFileContent(t.Context(), blobRepo, "f", len(content))
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
require.Equal(t, content, readBlob())
|
||||
require.NoError(t, gitcmd.NewCommand("repack", "-a", "-d").WithDir(repoDir).Run(t.Context()))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "objects", "pack", "pack-"+strings.Repeat("1", 40)+".pack"), nil, 0o644))
|
||||
|
||||
branches, _, err := repo.GetBranchNames(t.Context(), 0, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"main"}, branches)
|
||||
assert.Equal(t, content, readBlob())
|
||||
}
|
||||
|
||||
func BenchmarkRepository_GetBranches(b *testing.B) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepositoryLocal(b.Context(), bareRepo1Path)
|
||||
|
||||
@@ -122,6 +122,8 @@ func (t *Tracer) Start(ctx context.Context, spanName string) (context.Context, *
|
||||
ts.parent = parentSpan
|
||||
}
|
||||
|
||||
// FIXME: this ctx handling is not right. The returned ctx should inherit the ctx passed in, but not from span's internal contexts
|
||||
// The returned ctx only needs to inherit the values of the internal contexts of spans
|
||||
parentCtx := ctx
|
||||
for internalSpanIdx, tsp := range starters {
|
||||
var internalSpan traceSpanInternal
|
||||
|
||||
@@ -6,14 +6,17 @@ package gtprof
|
||||
// Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv
|
||||
|
||||
const (
|
||||
TraceSpanContext = "context"
|
||||
TraceSpanHTTP = "http"
|
||||
TraceSpanGitRun = "git-run"
|
||||
TraceSpanDatabase = "database"
|
||||
)
|
||||
|
||||
const (
|
||||
TraceAttrFuncCaller = "func.caller"
|
||||
TraceAttrDbSQL = "db.sql"
|
||||
TraceAttrGitCommand = "git.command"
|
||||
TraceAttrHTTPRoute = "http.route"
|
||||
TraceAttrGeneralName = "general.name"
|
||||
TraceAttrGeneralDesc = "general.desc"
|
||||
TraceAttrFuncCaller = "func.caller"
|
||||
TraceAttrDbSQL = "db.sql"
|
||||
TraceAttrGitCommand = "git.command"
|
||||
TraceAttrHTTPRoute = "http.route"
|
||||
)
|
||||
|
||||
@@ -45,6 +45,10 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
|
||||
return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions)
|
||||
}
|
||||
|
||||
func MarshalDeterministic(v any) ([]byte, error) {
|
||||
return jsonv2.Marshal(v, jsonV2.marshalOptions, jsonv2.Deterministic(true))
|
||||
}
|
||||
|
||||
func (j *JSONv2) Marshal(v any) ([]byte, error) {
|
||||
return jsonv2.Marshal(v, j.marshalOptions)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ type PackageMetadataVersion struct {
|
||||
Engines map[string]string `json:"engines,omitempty"`
|
||||
CPU []string `json:"cpu,omitempty"`
|
||||
OS []string `json:"os,omitempty"`
|
||||
Libc []string `json:"libc,omitempty"`
|
||||
Directories map[string]string `json:"directories,omitempty"`
|
||||
Funding any `json:"funding,omitempty"`
|
||||
AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"`
|
||||
@@ -129,12 +130,9 @@ type PackageMetadataVersion struct {
|
||||
|
||||
// PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
|
||||
type PackageDistribution struct {
|
||||
Integrity string `json:"integrity"`
|
||||
Shasum string `json:"shasum"`
|
||||
Tarball string `json:"tarball"`
|
||||
FileCount int `json:"fileCount,omitempty"`
|
||||
UnpackedSize int `json:"unpackedSize,omitempty"`
|
||||
NpmSignature string `json:"npm-signature,omitempty"`
|
||||
Integrity string `json:"integrity"`
|
||||
Shasum string `json:"shasum"`
|
||||
Tarball string `json:"tarball"`
|
||||
}
|
||||
|
||||
type PackageSearch struct {
|
||||
@@ -226,7 +224,7 @@ func (r *Repository) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
// Bin maps command names to executable files. npm also allows a single string,
|
||||
// in which case the command is named after the package (resolved in ParsePackage).
|
||||
// in which case the command is named after the package (resolved in parseUploadPackage).
|
||||
type Bin map[string]string
|
||||
|
||||
// UnmarshalJSON is needed because the bin field can be a string or an object.
|
||||
@@ -264,7 +262,7 @@ type packageUpload struct {
|
||||
// is non-nil on success; a body without `_attachments` is a deprecate request,
|
||||
// otherwise it is a "publish".
|
||||
func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r, 10*1024*1024))
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -280,16 +278,6 @@ func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
|
||||
return p, nil, err
|
||||
}
|
||||
|
||||
// ParsePackage parses a npm publish PUT body. Bodies without `_attachments`
|
||||
// surface as ErrInvalidAttachment once name/version validation has passed.
|
||||
func ParsePackage(r io.Reader) (*Package, error) {
|
||||
var upload packageUpload
|
||||
if err := json.NewDecoder(r).Decode(&upload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseUploadPackage(&upload)
|
||||
}
|
||||
|
||||
// parseUploadPackage builds a Package from a decoded publish body.
|
||||
func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
for _, meta := range upload.Versions {
|
||||
@@ -343,6 +331,7 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
Engines: meta.Engines,
|
||||
CPU: meta.CPU,
|
||||
OS: meta.OS,
|
||||
Libc: meta.Libc,
|
||||
Directories: meta.Directories,
|
||||
Funding: meta.Funding,
|
||||
AcceptDependencies: meta.AcceptDependencies,
|
||||
@@ -356,12 +345,12 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
|
||||
p.Filename = strings.ToLower(fmt.Sprintf("%s-%s.tgz", name, p.Version))
|
||||
|
||||
attachment := func() *PackageAttachment {
|
||||
attachment := upload.Attachments[meta.Name+"-"+meta.Version+".tgz"] // not the sigstore bundle of `npm publish --provenance`
|
||||
if attachment == nil && len(upload.Attachments) == 1 {
|
||||
for _, a := range upload.Attachments {
|
||||
return a
|
||||
attachment = a
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
}
|
||||
if attachment == nil || len(attachment.Data) == 0 {
|
||||
return nil, ErrInvalidAttachment
|
||||
}
|
||||
@@ -393,8 +382,6 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
return nil, ErrInvalidIntegrity
|
||||
}
|
||||
|
||||
// Derive _hasShrinkwrap and hasInstallScript from the tarball; the
|
||||
// packument can lie about either.
|
||||
p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data)
|
||||
|
||||
return p, nil
|
||||
@@ -410,11 +397,7 @@ const maxNpmTarballScanBytes = int64(32 * 1024 * 1024) // 32 MiB
|
||||
// maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball.
|
||||
const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB
|
||||
|
||||
// inspectTarball reports hasShrinkwrap (presence of package/npm-shrinkwrap.json)
|
||||
// and hasInstallScript (package/package.json declares any of preinstall,
|
||||
// install, postinstall). Both must be derived server-side because the client
|
||||
// can lie in the packument. Any read/decode error yields (false, false) so a
|
||||
// malformed archive does not block publishing.
|
||||
// inspectTarball trusts the tarball over the client's packument, read errors yield zero values to not block publishing
|
||||
func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||
gr, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
@@ -422,11 +405,16 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
var hasGypFile bool
|
||||
var pkg struct {
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
Gypfile any `json:"gypfile"`
|
||||
}
|
||||
tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes))
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err != nil {
|
||||
return hasShrinkwrap, hasInstallScript
|
||||
break
|
||||
}
|
||||
// npm pack puts files under a single root directory (usually "package/").
|
||||
name := strings.TrimPrefix(hdr.Name, "./")
|
||||
@@ -436,30 +424,14 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||
switch {
|
||||
case strings.HasSuffix(name, "/npm-shrinkwrap.json"):
|
||||
hasShrinkwrap = true
|
||||
case strings.HasSuffix(name, ".gyp"):
|
||||
hasGypFile = true
|
||||
case strings.HasSuffix(name, "/package.json"):
|
||||
hasInstallScript = tarballDeclaresInstallScript(tr)
|
||||
}
|
||||
if hasShrinkwrap && hasInstallScript {
|
||||
return hasShrinkwrap, hasInstallScript
|
||||
_ = json.NewDecoder(io.LimitReader(tr, maxNpmPackageJSONBytes)).Decode(&pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tarballDeclaresInstallScript reports whether a package.json declares any
|
||||
// of preinstall, install, postinstall.
|
||||
func tarballDeclaresInstallScript(r io.Reader) bool {
|
||||
var pkg struct {
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r, maxNpmPackageJSONBytes)).Decode(&pkg); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, name := range []string{"preinstall", "install", "postinstall"} {
|
||||
if strings.TrimSpace(pkg.Scripts[name]) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
// npm publish adds "install": "node-gyp rebuild" for a root *.gyp file to the manifest, but not to the tarball
|
||||
return hasShrinkwrap, strings.TrimSpace(pkg.Scripts["preinstall"]+pkg.Scripts["install"]+pkg.Scripts["postinstall"]) != "" || hasGypFile && pkg.Gypfile != false
|
||||
}
|
||||
|
||||
func validateName(name string) bool {
|
||||
|
||||
@@ -41,21 +41,20 @@ func TestParsePackage(t *testing.T) {
|
||||
integrity := "sha512-" + base64Sha512(dataBytes)
|
||||
|
||||
t.Run("InvalidUpload", func(t *testing.T) {
|
||||
p, err := ParsePackage(bytes.NewReader([]byte{0}))
|
||||
p, _, err := ParseUpload(bytes.NewReader([]byte{0}))
|
||||
assert.Nil(t, p)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("InvalidUploadNoData", func(t *testing.T) {
|
||||
b, _ := json.Marshal(packageUpload{})
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, err := parseUploadPackage(&packageUpload{})
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackage)
|
||||
})
|
||||
|
||||
t.Run("InvalidPackageName", func(t *testing.T) {
|
||||
test := func(t *testing.T, name string) {
|
||||
b, _ := json.Marshal(packageUpload{
|
||||
p, err := parseUploadPackage(&packageUpload{
|
||||
PackageMetadata: PackageMetadata{
|
||||
ID: name,
|
||||
Name: name,
|
||||
@@ -66,8 +65,6 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackageName)
|
||||
}
|
||||
@@ -94,7 +91,7 @@ func TestParsePackage(t *testing.T) {
|
||||
|
||||
t.Run("ValidPackageName", func(t *testing.T) {
|
||||
test := func(t *testing.T, name string) {
|
||||
b, _ := json.Marshal(packageUpload{
|
||||
p, err := parseUploadPackage(&packageUpload{
|
||||
PackageMetadata: PackageMetadata{
|
||||
ID: name,
|
||||
Name: name,
|
||||
@@ -105,8 +102,6 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackageVersion)
|
||||
}
|
||||
@@ -125,7 +120,7 @@ func TestParsePackage(t *testing.T) {
|
||||
|
||||
t.Run("InvalidPackageVersion", func(t *testing.T) {
|
||||
version := "first-version"
|
||||
b, _ := json.Marshal(packageUpload{
|
||||
p, err := parseUploadPackage(&packageUpload{
|
||||
PackageMetadata: PackageMetadata{
|
||||
ID: packageFullName,
|
||||
Name: packageFullName,
|
||||
@@ -137,8 +132,6 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackageVersion)
|
||||
})
|
||||
@@ -160,7 +153,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidAttachment)
|
||||
})
|
||||
@@ -185,7 +178,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidAttachment)
|
||||
})
|
||||
@@ -213,7 +206,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidIntegrity)
|
||||
})
|
||||
@@ -241,7 +234,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidIntegrity)
|
||||
})
|
||||
@@ -281,10 +274,13 @@ func TestParsePackage(t *testing.T) {
|
||||
filename: {
|
||||
Data: data,
|
||||
},
|
||||
packageFullName + "-" + packageVersion + ".sigstore": {
|
||||
Data: "{}",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -329,7 +325,7 @@ func TestParsePackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}`
|
||||
p, err := ParsePackage(strings.NewReader(packageJSON))
|
||||
p, _, err := ParseUpload(strings.NewReader(packageJSON))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "MIT", string(p.Metadata.License))
|
||||
})
|
||||
@@ -354,7 +350,7 @@ func TestParsePackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}`
|
||||
p, err := ParsePackage(strings.NewReader(packageJSON))
|
||||
p, _, err := ParseUpload(strings.NewReader(packageJSON))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://gitea.io/gitea/test.git", p.Metadata.Repository.URL)
|
||||
// a string bin is named after the package
|
||||
@@ -426,6 +422,15 @@ func TestInspectTarball(t *testing.T) {
|
||||
// npm pack sometimes emits "./package/..." entries.
|
||||
wantShrinkwrap: true,
|
||||
},
|
||||
{
|
||||
name: "gyp file implies node-gyp install",
|
||||
files: map[string]string{"package/binding.gyp": "{}"},
|
||||
wantInstaller: true,
|
||||
},
|
||||
{
|
||||
name: "gypfile false disables gyp install",
|
||||
files: map[string]string{"package/binding.gyp": "{}", "package/package.json": `{"gypfile":false}`},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -460,41 +465,6 @@ func TestParseUpload(t *testing.T) {
|
||||
require.NotNil(t, dep)
|
||||
assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions)
|
||||
})
|
||||
|
||||
t.Run("dispatches publish when _attachments present", func(t *testing.T) {
|
||||
// Reuse a minimal tarball with a package.json.
|
||||
data := buildTarball(map[string]string{"package/package.json": `{}`})
|
||||
integrity := "sha512-" + base64Sha512(data)
|
||||
body := fmt.Sprintf(
|
||||
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
|
||||
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
|
||||
)
|
||||
p, dep, err := ParseUpload(strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, dep)
|
||||
require.NotNil(t, p)
|
||||
assert.Equal(t, pkg, p.Name)
|
||||
})
|
||||
|
||||
t.Run("publish whose readme mentions deprecated is not misrouted", func(t *testing.T) {
|
||||
// The old fast-path used a substring check for "deprecated"; make sure
|
||||
// the new dispatch keys off _attachments only.
|
||||
data := buildTarball(map[string]string{"package/package.json": `{}`})
|
||||
integrity := "sha512-" + base64Sha512(data)
|
||||
body := fmt.Sprintf(
|
||||
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","readme":"this package is deprecated!","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
|
||||
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
|
||||
)
|
||||
p, dep, err := ParseUpload(strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, dep)
|
||||
require.NotNil(t, p)
|
||||
})
|
||||
|
||||
t.Run("invalid json errors out", func(t *testing.T) {
|
||||
_, _, err := ParseUpload(strings.NewReader("not json"))
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func base64Sha512(data []byte) string {
|
||||
|
||||
@@ -29,6 +29,7 @@ type Metadata struct {
|
||||
Engines map[string]string `json:"engines,omitempty"`
|
||||
CPU []string `json:"cpu,omitempty"`
|
||||
OS []string `json:"os,omitempty"`
|
||||
Libc []string `json:"libc,omitempty"`
|
||||
Directories map[string]string `json:"directories,omitempty"`
|
||||
Funding any `json:"funding,omitempty"`
|
||||
AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"`
|
||||
|
||||
@@ -102,8 +102,6 @@ var (
|
||||
AcmeEmail string
|
||||
AcmeURL string
|
||||
AcmeProfile string
|
||||
AcmeEABKID string
|
||||
AcmeEABHMAC string
|
||||
AcmeCARoot string
|
||||
SSLMinimumVersion string
|
||||
SSLMaximumVersion string
|
||||
@@ -146,11 +144,6 @@ func loadServerDomainAndURL(sec ConfigSection, protocol string) {
|
||||
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) {
|
||||
sec := rootCfg.Section("server")
|
||||
AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea")
|
||||
@@ -180,7 +173,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
|
||||
if EnableAcme {
|
||||
AcmeURL = sec.Key("ACME_URL").MustString("")
|
||||
AcmeProfile = sec.Key("ACME_PROFILE").MustString("")
|
||||
loadAcmeEABFrom(sec)
|
||||
AcmeCARoot = sec.Key("ACME_CA_ROOT").MustString("")
|
||||
|
||||
if sec.HasKey("ACME_ACCEPTTOS") {
|
||||
@@ -216,7 +208,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
|
||||
KeyFile = filepath.Join(CustomPath, KeyFile)
|
||||
}
|
||||
}
|
||||
|
||||
SSLMinimumVersion = sec.Key("SSL_MIN_VERSION").MustString("")
|
||||
SSLMaximumVersion = sec.Key("SSL_MAX_VERSION").MustString("")
|
||||
SSLCurvePreferences = sec.Key("SSL_CURVE_PREFERENCES").Strings(",")
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import "strings"
|
||||
|
||||
const (
|
||||
tildePrefix = '~'
|
||||
commentPrefix = '#'
|
||||
needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\"
|
||||
needsSingleQuote = "!\n"
|
||||
)
|
||||
@@ -74,7 +75,7 @@ func ShellEscape(toEscape string) string {
|
||||
}
|
||||
|
||||
// Now for simplicity we'll look at the rest of the string
|
||||
if !strings.ContainsAny(toEscape[start:], needsEscape) {
|
||||
if !strings.ContainsAny(toEscape[start:], needsEscape) && toEscape[0] != commentPrefix {
|
||||
return toEscape
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ func TestShellEscape(t *testing.T) {
|
||||
"Double quote and escape `...",
|
||||
"~/gitea`",
|
||||
"~/\"gitea\\`\"",
|
||||
}, {
|
||||
"Double quote leading #",
|
||||
"#123",
|
||||
`"#123"`,
|
||||
}, {
|
||||
"Double quotes can handle a number of things without having to escape them but not everything ...",
|
||||
"~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"auth.authorize_application_created_by": "This application was created by %s.",
|
||||
"auth.authorize_application_description": "If you grant access, it will be able to access and write to all your account information, including private repos and organizations.",
|
||||
"auth.authorize_application_with_scopes": "With scopes: %s",
|
||||
"auth.authorize_application_new_scopes": "New scopes: %s",
|
||||
"auth.authorize_title": "Authorize \"%s\" to access your account?",
|
||||
"auth.authorization_failed": "Authorization failed",
|
||||
"auth.authorization_failed_desc": "The authorization failed because we detected an invalid request. Please contact the maintainer of the app you tried to authorize.",
|
||||
|
||||
@@ -406,12 +406,15 @@ func CommonRoutes() *web.Router {
|
||||
})
|
||||
r.Group("/npm", func() {
|
||||
r.Get("/-/v1/search", npm.PackageSearch)
|
||||
r.Get("/-/ping", npm.Ping)
|
||||
r.Get("/-/whoami", npm.Whoami)
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
|
||||
packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">"
|
||||
g.UseUnescapedPath()
|
||||
g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
|
||||
g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFile)
|
||||
g.MatchPath("DELETE", packageId+"/-/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
|
||||
g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFileByName) // former tarball URL, still in lockfiles
|
||||
g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName)
|
||||
g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage)
|
||||
g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +25,7 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
distTags := make(map[string]string)
|
||||
times := make(map[string]time.Time)
|
||||
firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix
|
||||
var latest *packages_model.PackageDescriptor
|
||||
for _, pd := range pds {
|
||||
semVer := pd.SemVer.String()
|
||||
versions[semVer] = createPackageMetadataVersion(registryURL, pd)
|
||||
@@ -35,6 +36,9 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
for _, pvp := range pd.VersionProperties {
|
||||
if pvp.Name == npm_module.TagProperty {
|
||||
distTags[pvp.Value] = pd.Version.Version
|
||||
if pvp.Value == "latest" {
|
||||
latest = pd
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +47,16 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
times["created"] = firstPublished.AsTimeInLocation(time.UTC)
|
||||
times["modified"] = lastPublished.AsTimeInLocation(time.UTC)
|
||||
|
||||
latest := pds[len(pds)-1]
|
||||
if latest == nil { // yarn and pnpm fail without it, e.g. after its version got deleted
|
||||
latest = pds[len(pds)-1]
|
||||
for _, pd := range slices.Backward(pds) {
|
||||
if pd.SemVer.Prerelease() == "" {
|
||||
latest = pd
|
||||
break
|
||||
}
|
||||
}
|
||||
distTags["latest"] = latest.Version.Version
|
||||
}
|
||||
|
||||
metadata := packages_model.DescriptorMetadata[*npm_module.Metadata](latest)
|
||||
|
||||
@@ -86,13 +99,13 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
|
||||
PeerDependencies: metadata.PeerDependencies,
|
||||
PeerDependenciesMeta: metadata.PeerDependenciesMeta,
|
||||
OptionalDependencies: metadata.OptionalDependencies,
|
||||
Readme: metadata.Readme,
|
||||
Bin: metadata.Bin,
|
||||
HasInstallScript: metadata.HasInstallScript,
|
||||
HasShrinkwrap: metadata.HasShrinkwrap,
|
||||
Engines: metadata.Engines,
|
||||
CPU: metadata.CPU,
|
||||
OS: metadata.OS,
|
||||
Libc: metadata.Libc,
|
||||
Directories: metadata.Directories,
|
||||
Funding: metadata.Funding,
|
||||
AcceptDependencies: metadata.AcceptDependencies,
|
||||
@@ -100,7 +113,7 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
|
||||
Dist: npm_module.PackageDistribution{
|
||||
Shasum: pd.Files[0].Blob.HashSHA1,
|
||||
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
|
||||
Tarball: fmt.Sprintf("%s/%s/-/%s/%s", registryURL, url.PathEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)),
|
||||
Tarball: fmt.Sprintf("%s/%s/-/%s", registryURL, pd.Package.Name, pd.Files[0].File.LowerName), // npmjs shape, which npm parses for allowScripts and yarn keeps registry-relative
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
|
||||
Owner: &user_model.User{Name: "alice"},
|
||||
Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)},
|
||||
SemVer: version.Must(version.NewVersion(v)),
|
||||
Metadata: &npm_module.Metadata{Keywords: []string{"gitea"}, Repository: repo},
|
||||
Metadata: &npm_module.Metadata{Readme: v, Keywords: []string{"gitea"}, Repository: repo},
|
||||
Files: []*packages_model.PackageFileDescriptor{{
|
||||
File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"},
|
||||
Blob: &packages_model.PackageBlob{},
|
||||
@@ -35,21 +35,26 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
|
||||
|
||||
result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{
|
||||
descriptor("1.1.0", 1000, npm_module.Repository{}),
|
||||
descriptor("2.0.0-rc.1", 1500, repository),
|
||||
descriptor("1.0.0", 2000, repository),
|
||||
})
|
||||
|
||||
assert.Equal(t, map[string]time.Time{
|
||||
"1.0.0": time.Unix(2000, 0).UTC(),
|
||||
"1.1.0": time.Unix(1000, 0).UTC(),
|
||||
"created": time.Unix(1000, 0).UTC(),
|
||||
"modified": time.Unix(2000, 0).UTC(),
|
||||
"1.0.0": time.Unix(2000, 0).UTC(),
|
||||
"1.1.0": time.Unix(1000, 0).UTC(),
|
||||
"2.0.0-rc.1": time.Unix(1500, 0).UTC(),
|
||||
"created": time.Unix(1000, 0).UTC(),
|
||||
"modified": time.Unix(2000, 0).UTC(),
|
||||
}, result.Time)
|
||||
assert.Equal(t, map[string]string{"latest": "1.1.0"}, result.DistTags)
|
||||
assert.Equal(t, "1.1.0", result.Readme)
|
||||
assert.Empty(t, result.Versions["1.1.0"].Readme)
|
||||
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Maintainers)
|
||||
assert.Equal(t, []string{"gitea"}, result.Keywords)
|
||||
assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords)
|
||||
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers)
|
||||
assert.Equal(t,
|
||||
"https://gitea.dev/api/packages/alice/npm/@scope%2Ftest/-/1.0.0/test-1.0.0.tgz",
|
||||
"https://gitea.dev/api/packages/alice/npm/@scope/test/-/test-1.0.0.tgz",
|
||||
result.Versions["1.0.0"].Dist.Tarball,
|
||||
)
|
||||
assert.Equal(t, repository, result.Versions["1.0.0"].Repository)
|
||||
|
||||
@@ -6,7 +6,9 @@ package npm
|
||||
import (
|
||||
"bytes"
|
||||
std_ctx "context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -55,28 +57,41 @@ func buildNpmRegistryURL(ctx std_ctx.Context, owner *user_model.User) string {
|
||||
return httplib.GuessCurrentAppURL(ctx) + "api/packages/" + url.PathEscape(owner.Name) + "/npm"
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
func packageMetadata(ctx *context.Context) *npm_module.PackageMetadata {
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
resp := createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
return createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
if metadata := packageMetadata(ctx); metadata != nil {
|
||||
serveMetadata(ctx, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func serveMetadata(ctx *context.Context, obj any) {
|
||||
body, err := json.MarshalDeterministic(obj)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
ctx.Resp.Header().Set("ETag", fmt.Sprintf(`W/"%x"`, sha256.Sum256(body)))
|
||||
ctx.ServeContent(bytes.NewReader(body), context.ServeHeaderOptions{ContentType: "application/json;charset=utf-8"})
|
||||
}
|
||||
|
||||
// PackageVersionMetadata returns the metadata for a single version or dist-tag
|
||||
@@ -100,7 +115,11 @@ func PackageVersionMetadata(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag)
|
||||
if versionOrTag != "latest" {
|
||||
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag)
|
||||
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so serve the packument's fallback
|
||||
serveMetadata(ctx, metadata.Versions[metadata.DistTags["latest"]])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,25 +129,40 @@ func PackageVersionMetadata(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd))
|
||||
serveMetadata(ctx, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd))
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
packageVersion := ctx.PathParam("version")
|
||||
filename := ctx.PathParam("filename")
|
||||
func packageVersionByFilename(ctx *context.Context) *packages_model.PackageVersion {
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNpm,
|
||||
Name: packages_model.SearchValue{ExactMatch: true, Value: packageNameFromParams(ctx)},
|
||||
HasFileWithName: ctx.PathParam("filename"),
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return nil
|
||||
}
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return nil
|
||||
}
|
||||
return pvs[0]
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
// DownloadPackageFileByName finds the version and serves the contents of a package
|
||||
func DownloadPackageFileByName(ctx *context.Context) {
|
||||
pv := packageVersionByFilename(ctx)
|
||||
if pv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
Filename: ctx.PathParam("filename"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
@@ -140,54 +174,14 @@ func DownloadPackageFile(ctx *context.Context) {
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// DownloadPackageFileByName finds the version and serves the contents of a package
|
||||
func DownloadPackageFileByName(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNpm,
|
||||
Name: packages_model.SearchValue{
|
||||
ExactMatch: true,
|
||||
Value: packageNameFromParams(ctx),
|
||||
},
|
||||
HasFileWithName: filename,
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pvs[0],
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// UploadPackage creates a new package
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
npmPackage, deprecation, err := npm_module.ParseUpload(ctx.Req.Body)
|
||||
// about the npmjs and GitHub Packages limit, fits base64 tarballs up to ~200 MB
|
||||
npmPackage, deprecation, err := npm_module.ParseUpload(http.MaxBytesReader(ctx.Resp, ctx.Req.Body, 256*1024*1024))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
|
||||
apiError(ctx, http.StatusRequestEntityTooLarge, err)
|
||||
} else if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
@@ -340,26 +334,14 @@ func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation)
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// DeletePackageVersion deletes the package version
|
||||
// DeletePackageVersion deletes the package version, which `npm unpublish` addresses by its tarball
|
||||
func DeletePackageVersion(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
packageVersion := ctx.PathParam("version")
|
||||
pv := packageVersionByFilename(ctx)
|
||||
if pv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := packages_service.RemovePackageVersionByNameAndVersion(
|
||||
ctx,
|
||||
ctx.Doer,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
@@ -394,9 +376,7 @@ func DeletePackage(ctx *context.Context) {
|
||||
|
||||
// ListPackageTags returns all tags for a package
|
||||
func ListPackageTags(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -414,7 +394,11 @@ func ListPackageTags(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, tags)
|
||||
if _, ok := tags["latest"]; ok {
|
||||
ctx.JSON(http.StatusOK, tags)
|
||||
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so list the packument's fallback
|
||||
ctx.JSON(http.StatusOK, metadata.DistTags)
|
||||
}
|
||||
}
|
||||
|
||||
// AddPackageTag adds a tag to the package
|
||||
@@ -524,6 +508,18 @@ func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVe
|
||||
})
|
||||
}
|
||||
|
||||
func Ping(ctx *context.Context) {
|
||||
ctx.JSON(http.StatusOK, map[string]any{})
|
||||
}
|
||||
|
||||
func Whoami(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
apiError(ctx, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]string{"username": ctx.Doer.Name})
|
||||
}
|
||||
|
||||
func PackageSearch(ctx *context.Context) {
|
||||
pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
|
||||
@@ -400,7 +400,7 @@ func SearchUsers(ctx *context.APIContext) {
|
||||
// parameters:
|
||||
// - name: source_id
|
||||
// in: query
|
||||
// description: ID of the user's login source to search for, 0 means the local users
|
||||
// description: ID of the user's login source to search for
|
||||
// type: integer
|
||||
// format: int64
|
||||
// - name: login_name
|
||||
@@ -483,7 +483,7 @@ func SearchUsers(ctx *context.APIContext) {
|
||||
Actor: ctx.Doer,
|
||||
Types: []user_model.UserType{user_model.UserTypeIndividual},
|
||||
LoginName: ctx.FormTrim("login_name"),
|
||||
SourceID: ctx.FormOptionalInt64("source_id"),
|
||||
SourceID: ctx.FormInt64("source_id"),
|
||||
Keyword: ctx.FormTrim("q"),
|
||||
Visible: visible,
|
||||
OrderBy: orderBy,
|
||||
|
||||
+18
-34
@@ -76,6 +76,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
@@ -935,31 +936,22 @@ func apiAuth(authMethod auth.Method) func(*context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// verifyAuthWithOptions checks authentication according to options
|
||||
func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIContext) {
|
||||
// verifyAuthWithOptionsAPI checks authentication according to options
|
||||
func verifyAuthWithOptionsAPI(options *common.VerifyOptions) func(ctx *context.APIContext) {
|
||||
return func(ctx *context.APIContext) {
|
||||
// Check prohibit login users.
|
||||
if ctx.IsSigned {
|
||||
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "This account is not activated.",
|
||||
})
|
||||
check := common.CheckSignedInUser(ctx.Doer, nil)
|
||||
if check.NeedActivateAccount {
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{"message": "This account is not activated."})
|
||||
return
|
||||
}
|
||||
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
|
||||
} else if check.LoginIsProhibited {
|
||||
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
|
||||
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "This account is prohibited from signing in, please contact your site administrator.",
|
||||
})
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{"message": "This account is prohibited from signing in, please contact your site administrator."})
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Doer.MustChangePassword {
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "You must change your password. Change it at: " + setting.AppURL + "/user/change_password",
|
||||
})
|
||||
} else if check.NeedChangePassword {
|
||||
msg := "You must change your password. Change it at: " + httplib.MakeAbsoluteURL(ctx, setting.AppSubURL+"/user/settings/change_password")
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{"message": msg})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -970,20 +962,12 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC
|
||||
return
|
||||
}
|
||||
|
||||
if options.SignInRequired {
|
||||
if !ctx.IsSigned {
|
||||
// Restrict API calls with error message.
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "Only signed in user is allowed to call APIs.",
|
||||
})
|
||||
return
|
||||
} else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "This account is not activated.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if options.SignInRequired && !ctx.IsSigned {
|
||||
// Restrict API calls with error message.
|
||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||
"message": "Only signed in user is allowed to call APIs.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if options.AdminRequired {
|
||||
@@ -1035,7 +1019,7 @@ func Routes() *web.Router {
|
||||
// Get user from session if logged in.
|
||||
m.AfterRouting(apiAuth(buildAuthGroup()))
|
||||
|
||||
m.AfterRouting(verifyAuthWithOptions(&common.VerifyOptions{
|
||||
m.AfterRouting(verifyAuthWithOptionsAPI(&common.VerifyOptions{
|
||||
SignInRequired: setting.Service.RequireSignInViewStrict,
|
||||
}))
|
||||
|
||||
|
||||
@@ -1032,7 +1032,7 @@ func MergePullRequest(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if pull_service.IsErrInvalidMergeStyle(err) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do)))
|
||||
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
|
||||
|
||||
@@ -6,6 +6,8 @@ package common
|
||||
import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/context"
|
||||
@@ -56,3 +58,16 @@ type VerifyOptions struct {
|
||||
AdminRequired bool
|
||||
DisableCrossOriginProtection bool
|
||||
}
|
||||
|
||||
func CheckSignedInUser(doer *user_model.User, sess session.Store) (ret struct {
|
||||
NeedActivateAccount bool
|
||||
LoginIsProhibited bool
|
||||
NeedChangePassword bool
|
||||
},
|
||||
) {
|
||||
ret.NeedActivateAccount = !doer.IsActive && setting.Service.RegisterEmailConfirm
|
||||
ret.LoginIsProhibited = !doer.IsActive || doer.ProhibitLogin
|
||||
isImpersonated := sess != nil && context.IsDoerSessionImpersonated(sess)
|
||||
ret.NeedChangePassword = doer.MustChangePassword && !isImpersonated && !doer.IsTypeBot()
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckSignedInUser(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm)()
|
||||
sessNormal := session.NewMockMemStore("session-a")
|
||||
sessImpersonated := session.NewMockMemStore("session-b")
|
||||
_ = sessImpersonated.Set(session.KeyImpersonatorData, "any-value")
|
||||
|
||||
setting.Service.RegisterEmailConfirm = false
|
||||
ret := CheckSignedInUser(&user_model.User{IsActive: false}, nil)
|
||||
assert.False(t, ret.NeedActivateAccount)
|
||||
assert.True(t, ret.LoginIsProhibited)
|
||||
|
||||
setting.Service.RegisterEmailConfirm = true
|
||||
ret = CheckSignedInUser(&user_model.User{IsActive: false}, nil)
|
||||
assert.True(t, ret.NeedActivateAccount)
|
||||
assert.True(t, ret.LoginIsProhibited)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{IsActive: true}, nil)
|
||||
assert.False(t, ret.NeedActivateAccount)
|
||||
assert.False(t, ret.LoginIsProhibited)
|
||||
assert.False(t, ret.NeedChangePassword)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{IsActive: true, ProhibitLogin: true}, nil)
|
||||
assert.False(t, ret.NeedActivateAccount)
|
||||
assert.True(t, ret.LoginIsProhibited)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, nil)
|
||||
assert.True(t, ret.NeedChangePassword)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, sessNormal)
|
||||
assert.True(t, ret.NeedChangePassword)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true, Type: user_model.UserTypeBot}, sessNormal)
|
||||
assert.False(t, ret.NeedChangePassword)
|
||||
|
||||
ret = CheckSignedInUser(&user_model.User{MustChangePassword: true}, sessImpersonated)
|
||||
assert.False(t, ret.NeedChangePassword)
|
||||
}
|
||||
@@ -229,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
}
|
||||
} else {
|
||||
if isForcePush {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer, ctx.Repo.Permission)
|
||||
} else {
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer)
|
||||
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer, ctx.Repo.Permission)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,67 @@ package private
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/private"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPreReceiveActionsProtectedBranch(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
protection git_model.ProtectedBranch
|
||||
forcePush bool
|
||||
allowed bool
|
||||
}{
|
||||
{name: "push", protection: git_model.ProtectedBranch{CanPush: true}, allowed: true},
|
||||
{name: "push allowlist", protection: git_model.ProtectedBranch{CanPush: true, EnableWhitelist: true}},
|
||||
{name: "force push", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true}, forcePush: true, allowed: true},
|
||||
{name: "force push allowlist", protection: git_model.ProtectedBranch{CanPush: true, CanForcePush: true, EnableForcePushAllowlist: true}, forcePush: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockCtx, resp := contexttest.MockPrivateContext(t, "/")
|
||||
ctx := &preReceiveContext{PrivateContext: mockCtx, opts: &private.HookOptions{UserID: user_model.ActionsUserID}}
|
||||
ctx.SetPathParam("owner", "user2")
|
||||
ctx.SetPathParam("repo", "repo2")
|
||||
RepoAssignment(ctx.PrivateContext)
|
||||
require.False(t, ctx.Written())
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
|
||||
doer := user_model.NewActionsUserWithTaskID(53)
|
||||
loadContextDoerPermission(ctx.PrivateContext, doer.ID, doer.ExtDoerData.EncodeToString())
|
||||
|
||||
protection := tc.protection
|
||||
protection.RepoID = ctx.Repo.Repository.ID
|
||||
protection.RuleName = "probe"
|
||||
require.NoError(t, db.Insert(t.Context(), &protection))
|
||||
defer func() {
|
||||
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), ctx.Repo.Repository, protection.ID))
|
||||
}()
|
||||
|
||||
oldCommitID, newCommitID := "205ac761f3326a7ebe416e8673760016450b5cec", "1032bbf17fbc0d9c95bb5418dabe8f8c99278700"
|
||||
if tc.forcePush {
|
||||
oldCommitID, newCommitID = newCommitID, oldCommitID
|
||||
}
|
||||
preReceiveBranch(ctx, oldCommitID, newCommitID, git.RefNameFromBranch("probe"))
|
||||
if tc.allowed {
|
||||
assert.False(t, ctx.Written(), resp.Body.String())
|
||||
} else {
|
||||
assert.Contains(t, resp.Body.String(), "Not allowed to")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
|
||||
// Otherwise, a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||
|
||||
@@ -48,13 +48,6 @@ const (
|
||||
// UserSearchDefaultAdminSort is the default sort type for admin view
|
||||
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
|
||||
func Users(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.users")
|
||||
@@ -83,30 +76,6 @@ func Users(ctx *context.Context) {
|
||||
"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{
|
||||
Actor: ctx.Doer,
|
||||
Types: types,
|
||||
@@ -119,7 +88,6 @@ func Users(ctx *context.Context) {
|
||||
IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]),
|
||||
IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]),
|
||||
IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]),
|
||||
SourceID: sourceIDFilter,
|
||||
OrderBy: db.SearchOrderBy(sortType),
|
||||
}, tplUsers)
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
|
||||
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
|
||||
// An admin-disabled user has no such signature, so we leave IsActive alone
|
||||
// and let verifyAuthWithOptions route them through the prohibit-login / activate page.
|
||||
// and let verifyAuthWithOptionsWeb route them through the prohibit-login / activate page.
|
||||
if !u.IsActive {
|
||||
extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
audit_model "gitea.dev/models/audit"
|
||||
"gitea.dev/models/auth"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/audit"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
@@ -321,9 +323,18 @@ func AuthorizeOAuth(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var addedScopes, removedScopes []string
|
||||
if grant != nil {
|
||||
if form.Scope == "" {
|
||||
form.Scope = grant.Scope
|
||||
}
|
||||
addedScopes, removedScopes = util.DiffSlice(strings.Fields(grant.Scope), strings.Fields(form.Scope))
|
||||
}
|
||||
scopeChanged := len(addedScopes) > 0 || len(removedScopes) > 0
|
||||
|
||||
// Redirect if user already granted access and the application is confidential or trusted otherwise
|
||||
// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
|
||||
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil {
|
||||
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil && !scopeChanged {
|
||||
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
@@ -347,6 +358,7 @@ func AuthorizeOAuth(ctx *context.Context) {
|
||||
|
||||
// check if additional scopes
|
||||
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
|
||||
ctx.Data["AddedScopes"] = addedScopes
|
||||
|
||||
// show authorize page to grant access
|
||||
ctx.Data["Application"] = app
|
||||
@@ -432,12 +444,10 @@ func GrantApplicationOAuth(ctx *context.Context) {
|
||||
|
||||
audit.Record(ctx, audit_model.UserOAuth2ApplicationGrant, ctx.Doer, "oauth2_application", app.Name, "granted_scope", form.Scope)
|
||||
} else if grant.Scope != form.Scope {
|
||||
handleAuthorizeError(ctx, AuthorizeError{
|
||||
State: form.State,
|
||||
ErrorDescription: "a grant exists with different scope",
|
||||
ErrorCode: ErrorCodeServerError,
|
||||
}, form.RedirectURI)
|
||||
return
|
||||
if err := auth.UpdateGrantScope(ctx, grant, form.Scope); err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(form.Nonce) > 0 {
|
||||
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/egress/policy"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/contexttest"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/oauth2_provider"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -105,3 +109,27 @@ func TestOAuth2AvatarClientBlocksCloudMetadata(t *testing.T) {
|
||||
assert.ErrorIs(t, err, policy.ErrDenied,
|
||||
"avatar client must refuse a link-local cloud-metadata address")
|
||||
}
|
||||
|
||||
func TestOAuth2ScopeChange(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
app := unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: 1})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("oauth2-scope-change")}
|
||||
authorize := func(scope string) int {
|
||||
ctx, resp := contexttest.MockContext(t, "/login/oauth/authorize", mockOpt)
|
||||
ctx.Doer = doer
|
||||
web.SetForm(ctx, &forms.AuthorizationForm{ResponseType: "code", ClientID: app.ClientID, RedirectURI: app.RedirectURIs[0], State: "state", Scope: scope})
|
||||
AuthorizeOAuth(ctx)
|
||||
return resp.Code
|
||||
}
|
||||
assert.Equal(t, http.StatusSeeOther, authorize(""))
|
||||
assert.Equal(t, http.StatusSeeOther, authorize("profile openid"))
|
||||
assert.Equal(t, http.StatusOK, authorize("openid profile email"))
|
||||
|
||||
ctx, resp := contexttest.MockContext(t, "/login/oauth/grant", mockOpt)
|
||||
ctx.Doer = doer
|
||||
web.SetForm(ctx, &forms.GrantApplicationForm{ClientID: app.ClientID, Granted: true, RedirectURI: app.RedirectURIs[0], State: "state", Scope: "openid profile email"})
|
||||
GrantApplicationOAuth(ctx)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||
unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Grant{ID: 1, Scope: "openid profile email"})
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/common"
|
||||
"gitea.dev/services/audit"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
@@ -282,10 +283,9 @@ func MustChangePasswordPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure only requests for users who are eligible to change their password via
|
||||
// this method passes through
|
||||
if !ctx.Doer.MustChangePassword {
|
||||
ctx.ServerError("MustUpdatePassword", errors.New("cannot update password. Please visit the settings page"))
|
||||
if !common.CheckSignedInUser(ctx.Doer, ctx.Session).NeedChangePassword {
|
||||
log.Debug("User %s attempted to access the must change password page, but they are not required to change their password", ctx.Doer.Name)
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+3
-23
@@ -17,38 +17,18 @@ import (
|
||||
"gitea.dev/modules/sitemap"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
"gitea.dev/routers/web/auth"
|
||||
"gitea.dev/routers/web/user"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
// tplHome home page template
|
||||
tplHome templates.TplName = "home"
|
||||
)
|
||||
const tplHome templates.TplName = "home"
|
||||
|
||||
// Home render home page
|
||||
func Home(ctx *context.Context) {
|
||||
if ctx.IsSigned {
|
||||
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
||||
ctx.HTML(http.StatusOK, auth.TplActivate)
|
||||
} else if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
|
||||
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
|
||||
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
||||
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
|
||||
} else if doerMustChangePassword(ctx) {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
|
||||
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
|
||||
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
|
||||
} else {
|
||||
user.Dashboard(ctx)
|
||||
}
|
||||
user.Dashboard(ctx)
|
||||
return
|
||||
// Check non-logged users landing page.
|
||||
} else if setting.LandingPageURL != setting.LandingPageHome {
|
||||
// Check non-logged users landing page
|
||||
ctx.Redirect(setting.AppSubURL + string(setting.LandingPageURL))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1145,7 +1145,7 @@ func MergePullRequest(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if pull_service.IsErrInvalidMergeStyle(err) {
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option"))
|
||||
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
|
||||
|
||||
+18
-32
@@ -174,38 +174,29 @@ func newWebAuthMiddleware() *AuthMiddleware {
|
||||
return webAuth
|
||||
}
|
||||
|
||||
func doerMustChangePassword(ctx *context.Context) bool {
|
||||
// an impersonating admin must not be forced to set the impersonated user's password
|
||||
return ctx.Doer != nil && ctx.Doer.MustChangePassword && !ctx.DoerIsImpersonated()
|
||||
}
|
||||
|
||||
// verifyAuthWithOptions checks authentication according to options
|
||||
func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Context) {
|
||||
// verifyAuthWithOptionsWeb checks authentication according to options
|
||||
func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.Context) {
|
||||
crossOriginProtection := http.NewCrossOriginProtection()
|
||||
|
||||
return func(ctx *context.Context) {
|
||||
// Check prohibit login users.
|
||||
if ctx.IsSigned {
|
||||
if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
|
||||
check := common.CheckSignedInUser(ctx.Doer, ctx.Session)
|
||||
if check.NeedActivateAccount {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
||||
ctx.HTML(http.StatusOK, "user/auth/activate")
|
||||
return
|
||||
}
|
||||
if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin {
|
||||
} else if check.LoginIsProhibited {
|
||||
log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr())
|
||||
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
||||
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
|
||||
return
|
||||
}
|
||||
|
||||
if doerMustChangePassword(ctx) {
|
||||
} else if check.NeedChangePassword {
|
||||
if ctx.Req.URL.Path != "/user/settings/change_password" {
|
||||
if strings.HasPrefix(ctx.Req.UserAgent(), "git") {
|
||||
ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password"))
|
||||
return
|
||||
}
|
||||
ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
|
||||
ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password"
|
||||
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
|
||||
return
|
||||
@@ -230,15 +221,9 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
|
||||
}
|
||||
}
|
||||
|
||||
if options.SignInRequired {
|
||||
if !ctx.IsSigned {
|
||||
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
|
||||
return
|
||||
} else if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm {
|
||||
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
||||
ctx.HTML(http.StatusOK, "user/auth/activate")
|
||||
return
|
||||
}
|
||||
if options.SignInRequired && !ctx.IsSigned {
|
||||
ctx.Redirect(middleware.RedirectLinkUserLogin(ctx.Req))
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to log in page if auto-signin info is provided and has not signed in.
|
||||
@@ -336,7 +321,7 @@ func Routes() *web.Router {
|
||||
// The CORS mechanism already protects cross-origin requests, and the CrossOriginProtection has no "allowed origin" list, so disable CrossOriginProtection.
|
||||
// - For non-browser client requests: git clone via http, no Sec-Fetch-Site header.
|
||||
// Such requests are not cross-origin requests, so disable CrossOriginProtection.
|
||||
var optSignInFromAnyOrigin = verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: true})
|
||||
var optSignInFromAnyOrigin = verifyAuthWithOptionsWeb(&common.VerifyOptions{DisableCrossOriginProtection: true})
|
||||
|
||||
// addProjectBoardRoutes registers a board's column and card routes, shared by the
|
||||
// repository and owner mount points.
|
||||
@@ -355,13 +340,14 @@ func addProjectBoardRoutes(m *web.Router) {
|
||||
// registerWebRoutes register routes
|
||||
func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
// middleware: required to be signed in or signed out
|
||||
reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true})
|
||||
reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true})
|
||||
reqSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true})
|
||||
reqSignOut := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignOutRequired: true})
|
||||
// middleware: optional sign in (if signed in, use the user as doer, if not, no doer)
|
||||
optSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict})
|
||||
optExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView})
|
||||
optSignInHome := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: false}) // site home doesn't need "require sign-in" protection
|
||||
optSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict})
|
||||
optExploreSignIn := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView})
|
||||
// middleware: only apply CrossOriginProtection
|
||||
crossOriginProtect := verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: false})
|
||||
crossOriginProtect := verifyAuthWithOptionsWeb(&common.VerifyOptions{DisableCrossOriginProtection: false})
|
||||
|
||||
openIDSignInEnabled := func(ctx *context.Context) {
|
||||
if !setting.Service.EnableOpenIDSignIn {
|
||||
@@ -533,7 +519,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
// FIXME: not all routes need go through same middleware.
|
||||
// Especially some AJAX requests, we can reduce middleware number to improve performance.
|
||||
|
||||
m.Get("/", Home)
|
||||
m.Get("/", optSignInHome, Home)
|
||||
m.Get("/sitemap.xml", sitemapEnabled, optExploreSignIn, HomeSitemap)
|
||||
m.Group("/.well-known", func() {
|
||||
m.Get("/openid-configuration", auth.OIDCWellKnown)
|
||||
@@ -780,7 +766,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
|
||||
m.Get("/avatar/{hash}", user.AvatarByEmailHash)
|
||||
|
||||
adminReq := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true, AdminRequired: true})
|
||||
adminReq := verifyAuthWithOptionsWeb(&common.VerifyOptions{SignInRequired: true, AdminRequired: true})
|
||||
|
||||
// ***** START: Admin *****
|
||||
m.Group("/-/admin", func() {
|
||||
|
||||
@@ -224,7 +224,7 @@ func handlePullRequestAutoMerge(ctx context.Context, pr *issues_model.PullReques
|
||||
|
||||
// although expectedHeadCommitID is checked before, we should pass it to the Merge function to
|
||||
// make it be checked again in case the head commit id changed after the previous check.
|
||||
if err := pull_service.Merge(pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil {
|
||||
if err := pull_service.Merge(ctx, pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil {
|
||||
if pull_service.IsErrSHADoesNotMatch(err) {
|
||||
return errors.Join(errSkipAutoMerge, err)
|
||||
}
|
||||
|
||||
@@ -211,11 +211,6 @@ func (ctx *Context) DoerNeedTwoFactorAuth() bool {
|
||||
return ctx.Session.Get(session.KeyUserHasTwoFactorAuth) == false
|
||||
}
|
||||
|
||||
// DoerIsImpersonated returns true if the current session is an admin impersonating the doer
|
||||
func (ctx *Context) DoerIsImpersonated() bool {
|
||||
return ctx.Session.Get(session.KeyImpersonatorData) != nil
|
||||
}
|
||||
|
||||
// HasError returns true if error occurs in form validation.
|
||||
// Attention: this function changes ctx.Data and ctx.Flash
|
||||
// If HasError is called, then before Redirect, the error message should be stored by ctx.Flash.Error(ctx.GetErrMsg()) again.
|
||||
|
||||
@@ -69,7 +69,7 @@ func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo {
|
||||
|
||||
func (c TemplateContext) ImpersonatedUser() *user_model.User {
|
||||
webCtx := GetWebContext(c)
|
||||
if webCtx == nil || webCtx.Doer == nil || !webCtx.DoerIsImpersonated() {
|
||||
if webCtx == nil || webCtx.Doer == nil || !IsDoerSessionImpersonated(webCtx.Session) {
|
||||
return nil
|
||||
}
|
||||
return webCtx.Doer
|
||||
|
||||
@@ -190,7 +190,7 @@ func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *r
|
||||
protectionRequireSigned := false
|
||||
if protectedBranch != nil {
|
||||
protectedBranch.Repo = targetRepo
|
||||
canPushWithProtection = protectedBranch.CanUserPush(ctx, doer)
|
||||
canPushWithProtection = protectedBranch.CanUserPush(ctx, doer, doerRepoPerm)
|
||||
protectionRequireSigned = protectedBranch.RequireSignedCommits
|
||||
// If branch-wide push is restricted, allow direct commit when the
|
||||
// URL-derived tree path matches an unprotected file pattern. The
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/session"
|
||||
)
|
||||
|
||||
// UserAssignmentWeb returns a middleware to handle context-user assignment for web routes
|
||||
@@ -58,3 +59,7 @@ func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, string)) (
|
||||
}
|
||||
return contextUser
|
||||
}
|
||||
|
||||
func IsDoerSessionImpersonated(sess session.Store) bool {
|
||||
return sess.Get(session.KeyImpersonatorData) != nil
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func ToBranch(ctx context.Context, repo *repo_model.Repository, branchName strin
|
||||
return nil, err
|
||||
}
|
||||
bp.Repo = repo
|
||||
branch.UserCanPush = bp.CanUserPush(ctx, user)
|
||||
branch.UserCanPush = bp.CanUserPush(ctx, user, permission)
|
||||
branch.UserCanMerge = git_model.IsUserMergeWhitelisted(ctx, bp, user.ID, permission)
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -14,6 +14,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"uuid"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/gtprof"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/references"
|
||||
@@ -289,9 +291,22 @@ func hasPullRequestCommitBeenMerged(ctx context.Context, pr *issues_model.PullRe
|
||||
|
||||
// Merge merges pull request to base repository.
|
||||
// Caller should check PR is ready to be merged (review and status checks)
|
||||
func Merge(prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
|
||||
func Merge(outerCtx context.Context, prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
|
||||
outerCtxId := uuid.NewV4().String()
|
||||
|
||||
_, outerSpan := gtprof.GetTracer().Start(outerCtx, gtprof.TraceSpanContext)
|
||||
outerSpan.SetAttributeString("context.trace-id", outerCtxId) // this attribute is only used internally for debugging purpose
|
||||
defer outerSpan.End()
|
||||
|
||||
// TODO: in the future, the contexts from graceful.GetManager() should be wrapped with gtprof tracing, refactor the code to framework-level support
|
||||
ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
|
||||
|
||||
ctx, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanContext)
|
||||
span.SetAttributeString(gtprof.TraceAttrGeneralName, "merge-pull-request")
|
||||
span.SetAttributeString(gtprof.TraceAttrGeneralDesc, fmt.Sprintf("merge pull request %d with merge style %s", prID, mergeStyle))
|
||||
span.SetAttributeString("context.trace-id-outer", outerCtxId) // this attribute is only used internally for debugging purpose
|
||||
defer span.End()
|
||||
|
||||
err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error {
|
||||
pr, err := issues_model.GetPullRequestByID(ctx, prID)
|
||||
if err != nil {
|
||||
|
||||
@@ -123,8 +123,8 @@ func isUserAllowedToPushOrForcePushInRepoBranch(ctx context.Context, user *user_
|
||||
}
|
||||
if pb != nil { // override previous results if there is a branch protection rule
|
||||
pb.Repo = repo
|
||||
pushAllowed = pb.CanUserPush(ctx, user)
|
||||
forcePushAllowed = pb.CanUserForcePush(ctx, user)
|
||||
pushAllowed = pb.CanUserPush(ctx, user, repoPerm)
|
||||
forcePushAllowed = pb.CanUserForcePush(ctx, user, repoPerm)
|
||||
}
|
||||
return pushAllowed, forcePushAllowed, nil
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rule != nil && !rule.CanUserPush(ctx, doer) {
|
||||
if rule != nil && !rule.CanUserPush(ctx, doer, perm) {
|
||||
return "", git_model.ErrBranchIsProtected
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -88,7 +89,11 @@ func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_mode
|
||||
}
|
||||
if protectedBranch != nil {
|
||||
protectedBranch.Repo = repo
|
||||
if !protectedBranch.CanUserPush(ctx, doer) {
|
||||
perm, err := access.GetDoerRepoPermission(ctx, repo, doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !protectedBranch.CanUserPush(ctx, doer, perm) {
|
||||
return ErrUserCannotCommit{
|
||||
UserName: doer.LowerName,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -667,7 +668,11 @@ func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, gi
|
||||
protectedBranch.Repo = repo
|
||||
globUnprotected := protectedBranch.GetUnprotectedFilePatterns()
|
||||
globProtected := protectedBranch.GetProtectedFilePatterns()
|
||||
canUserPush := protectedBranch.CanUserPush(ctx, doer)
|
||||
perm, err := access.GetDoerRepoPermission(ctx, repo, doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
canUserPush := protectedBranch.CanUserPush(ctx, doer, perm)
|
||||
for _, treePath := range treePaths {
|
||||
isUnprotectedFile := false
|
||||
if len(globUnprotected) != 0 {
|
||||
|
||||
@@ -7,20 +7,26 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
const gitLogGraphFormatSep = "^" // disallowed char in git ref names
|
||||
|
||||
// GetCommitGraph return a list of commit (GraphItems) from all branches
|
||||
func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllowedColors int, hidePRRefs bool, refs, files []string) (*Graph, error) {
|
||||
format := "DATA:%D|%H|%ad|%h|%s"
|
||||
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
format := "DATA:" + strings.Join([]string{
|
||||
"%D", // ref names without the " (", ")" wrapping.
|
||||
"%H", // commit hash
|
||||
"%ad", // author date (format respects --date= option)
|
||||
"%h", // abbreviated commit hash
|
||||
"%s", // subject
|
||||
}, gitLogGraphFormatSep)
|
||||
|
||||
page = max(page, 1)
|
||||
graphCmd := gitcmd.NewCommand("log", "--graph", "--date-order", "--decorate=full")
|
||||
|
||||
if hidePRRefs {
|
||||
@@ -31,7 +37,7 @@ func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllow
|
||||
graphCmd.AddArguments("--tags", "--branches")
|
||||
}
|
||||
|
||||
graphCmd.AddArguments("-C", "-M", "--date=iso-strict").
|
||||
graphCmd.AddArguments("--find-copies", "--find-renames", "--date=iso-strict").
|
||||
AddOptionFormat("-n %d", setting.UI.GraphMaxCommitNum*page).
|
||||
AddOptionFormat("--pretty=format:%s", format)
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ func parseGitTime(timeStr string) time.Time {
|
||||
|
||||
// NewCommit creates a new commit from a provided line
|
||||
func NewCommit(row, column int, line []byte) (*Commit, error) {
|
||||
data := bytes.SplitN(line, []byte("|"), 5)
|
||||
data := bytes.SplitN(line, []byte(gitLogGraphFormatSep), 5)
|
||||
if len(data) < 5 {
|
||||
return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line))
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func BenchmarkGetCommitGraph(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkParseCommitString(b *testing.B) {
|
||||
testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|Add route for graph"
|
||||
testString := "* DATA:^4e61bacab44e9b4730e44a6615d04098dd3a8eaf^2016-12-20 21:10:41 +0100^4e61bac^Add route for graph"
|
||||
|
||||
parser := &Parser{}
|
||||
parser.Reset()
|
||||
@@ -224,14 +224,14 @@ func TestParseGlyphs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCommitStringParsing(t *testing.T) {
|
||||
dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|"
|
||||
dataFirstPart := "* DATA:^4e61bacab44e9b4730e44a6615d04098dd3a8eaf^2016-12-20 21:10:41 +0100^4e61bac^"
|
||||
tests := []struct {
|
||||
shouldPass bool
|
||||
testName string
|
||||
commitMessage string
|
||||
}{
|
||||
{true, "normal", "not a fancy message"},
|
||||
{true, "extra pipe", "An extra pipe: |"},
|
||||
{true, "extra sep", "An extra sep"},
|
||||
{true, "extra 'Data:'", "DATA: might be trouble"},
|
||||
}
|
||||
|
||||
|
||||
@@ -47,20 +47,6 @@
|
||||
</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 -->
|
||||
<div class="ui dropdown type jump item">
|
||||
<span class="text">
|
||||
@@ -89,7 +75,6 @@
|
||||
{{SortArrow "alphabetically" "reversealphabetically" $.SortType true}}
|
||||
</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.restricted"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.users.2fa"}}</th>
|
||||
@@ -117,7 +102,6 @@
|
||||
{{template "shared/user/user_type_label" .}}
|
||||
</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 .IsRestricted "octicon-check" "octicon-x")}}</td>
|
||||
<td>{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}}</td>
|
||||
@@ -135,7 +119,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="no-results-row"><td class="tw-text-center" colspan="10">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
|
||||
<tr class="no-results-row"><td class="tw-text-center" colspan="9">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{{- $activeStopwatch := call $data.GetActiveStopwatch -}}
|
||||
{{- $notificationUnreadCount := call $data.GetNotificationUnreadCount -}}
|
||||
{{/* always rendered so a real-time push can reveal it without a reload */}}
|
||||
<a class="item active-stopwatch{{if not $activeStopwatch}} tw-hidden{{end}} {{$itemExtraClass}}" {{if $activeStopwatch}}href="{{$activeStopwatch.IssueLink}}" data-seconds="{{$activeStopwatch.Seconds}}"{{end}} title="{{ctx.Locale.Tr "active_stopwatch"}}">
|
||||
<a class="item active-stopwatch{{if not $activeStopwatch}} tw-hidden{{end}} {{$itemExtraClass}}" {{if $activeStopwatch}}data-seconds="{{$activeStopwatch.Seconds}}"{{end}} title="{{ctx.Locale.Tr "active_stopwatch"}}">
|
||||
<div class="tw-relative flex-text-block">
|
||||
{{svg "octicon-stopwatch"}}
|
||||
<span class="header-stopwatch-dot"></span>
|
||||
|
||||
@@ -9,18 +9,15 @@
|
||||
<h3 class="tw-m-0">{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_title"}}</h3>
|
||||
{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_desc"}}
|
||||
</div>
|
||||
{{$localBranch := $pull.HeadBranch}}
|
||||
{{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}
|
||||
{{$localBranch = print $pull.HeadRepo.OwnerName "-" $pull.HeadBranch}}
|
||||
{{end}}
|
||||
{{$args := $pull.GetInstructionsCliArgs}}
|
||||
<div class="ui secondary segment tw-font-mono">
|
||||
{{$gitRemoteName := ctx.RootData.SystemConfig.Repository.GitGuideRemoteName.Value ctx}}
|
||||
{{if eq $pull.Flow 0}}
|
||||
<div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$pull.HeadBranch}}:{{$localBranch}}</div>
|
||||
<div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$args.HeadBranchArg}}:{{$args.LocalBranchArg}}</div>
|
||||
{{else}}
|
||||
<div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$localBranch}}</div>
|
||||
<div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$args.LocalBranchArg}}</div>
|
||||
{{end}}
|
||||
<div>git checkout {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
{{if $data.ShowMergeInstructions}}
|
||||
<div>
|
||||
@@ -32,32 +29,32 @@
|
||||
</div>
|
||||
<div class="ui secondary segment tw-font-mono">
|
||||
<div data-pull-merge-style="merge">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --no-ff {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --no-ff {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="rebase">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --ff-only {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --ff-only {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="rebase-merge">
|
||||
<div>git checkout {{$localBranch}}</div>
|
||||
<div>git rebase {{$pull.BaseBranch}}</div>
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --no-ff {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.LocalBranchArg}}</div>
|
||||
<div>git rebase {{$args.BaseBranchArg}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --no-ff {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="squash">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --squash {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --squash {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="fast-forward-only">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --ff-only {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --ff-only {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="manually-merged">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div>git push {{$gitRemoteName}} {{$pull.BaseBranch}}</div>
|
||||
<div>git push {{$gitRemoteName}} {{$args.BaseBranchArg}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -11939,7 +11939,7 @@
|
||||
"operationId": "adminSearchUsers",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "ID of the user's login source to search for, 0 means the local users",
|
||||
"description": "ID of the user's login source to search for",
|
||||
"in": "query",
|
||||
"name": "source_id",
|
||||
"schema": {
|
||||
|
||||
+1
-1
@@ -825,7 +825,7 @@
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "ID of the user's login source to search for, 0 means the local users",
|
||||
"description": "ID of the user's login source to search for",
|
||||
"name": "source_id",
|
||||
"in": "query"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{if .EnableCaptcha}}{{if eq .CaptchaType "image"}}
|
||||
<div class="inline field tw-text-center">
|
||||
<div class="inline field tw-text-center" data-global-init="initImageCaptcha">
|
||||
{{.Captcha.CreateHTML}}
|
||||
</div>
|
||||
<div class="required field {{if .Err_Captcha}}error{{end}}">
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{{end}}
|
||||
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}<br>
|
||||
{{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "<b>%s</b>" .Scope)}}
|
||||
{{if .AddedScopes}}<br>{{ctx.Locale.Tr "auth.authorize_application_new_scopes" (HTMLFormat "<b>%s</b>" (StringUtils.Join .AddedScopes " "))}}{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
|
||||
@@ -39,18 +39,6 @@ test('pdf file', async ({page, request}) => {
|
||||
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}) => {
|
||||
const repoName = `e2e-asciicast-render-${randomString(8)}`;
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// minimalWorkflowPayload returns the minimal YAML for a single-job workflow with no steps.
|
||||
@@ -116,3 +120,42 @@ func TestCreateTaskForRunnerConcurrentClaim(t *testing.T) {
|
||||
assert.NotZero(t, updated.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareWaitingRunJob(t *testing.T) *actions_model.ActionRunJob {
|
||||
if setting.Database.Type.IsSQLite3() {
|
||||
t.Skip("SQLite serializes write transactions")
|
||||
}
|
||||
job := &actions_model.ActionRunJob{RepoID: 1, Status: actions_model.StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
return job
|
||||
}
|
||||
|
||||
func TestCreateTaskForRunnerDuringOpenClaimDoesNotWait(t *testing.T) {
|
||||
job := prepareWaitingRunJob(t)
|
||||
|
||||
require.NoError(t, db.WithTx(t.Context(), func(ctx context.Context) error {
|
||||
_, err := db.GetEngine(ctx).ID(job.ID).Cols("name").Update(&actions_model.ActionRunJob{Name: "claiming"})
|
||||
require.NoError(t, err)
|
||||
pickupCtx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _, err = actions_model.CreateTaskForRunner(pickupCtx, &actions_model.ActionRunner{})
|
||||
return err
|
||||
}))
|
||||
}
|
||||
|
||||
func TestClaimRunJobAfterConcurrentCancelUpdatesNothing(t *testing.T) {
|
||||
job := prepareWaitingRunJob(t)
|
||||
|
||||
require.NoError(t, db.WithTx(t.Context(), func(ctx context.Context) error {
|
||||
claimed, err := actions_model.GetRunJobByRepoAndID(ctx, job.RepoID, job.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = db.GetEngine(t.Context()).ID(job.ID).Cols("status").Update(&actions_model.ActionRunJob{Status: actions_model.StatusCancelled})
|
||||
require.NoError(t, err)
|
||||
|
||||
claimed.TaskID, claimed.Status = 1, actions_model.StatusRunning
|
||||
affected, err := actions_model.UpdateRunJob(ctx, claimed, builder.Eq{"task_id": 0, "status": actions_model.StatusWaiting}, "task_id", "status")
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, affected)
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -16,10 +16,8 @@ import (
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/services/auth/source/ldap"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -36,49 +34,6 @@ func TestAdminViewUsers(t *testing.T) {
|
||||
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) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
@@ -280,6 +235,17 @@ func TestAdminBotUser(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TokenIgnoresMustChangePassword", func(t *testing.T) {
|
||||
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
|
||||
bot.IsActive, bot.MustChangePassword = true, true
|
||||
require.NoError(t, user_model.UpdateUserCols(t.Context(), bot, "is_active", "must_change_password"))
|
||||
token := &auth_model.AccessToken{UID: bot.ID, Name: "git", Scope: auth_model.AccessTokenScopeAll}
|
||||
require.NoError(t, auth_model.NewAccessToken(t.Context(), token))
|
||||
|
||||
MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user2/repo1").AddTokenAuth(token.Token), http.StatusOK)
|
||||
MakeRequest(t, NewRequest(t, "GET", "/user2/repo1.git/info/refs?service=git-upload-pack").AddBasicAuth(bot.Name, token.Token), http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("APIRejectsAuthSource", func(t *testing.T) {
|
||||
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "bot-user"})
|
||||
req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/"+bot.Name, map[string]any{"source_id": 1}).AddBasicAuth("user1")
|
||||
|
||||
@@ -104,6 +104,7 @@ func TestPackageNpm(t *testing.T) {
|
||||
},
|
||||
"cpu": ["x64", "arm64"],
|
||||
"os": ["linux", "darwin"],
|
||||
"libc": ["glibc"],
|
||||
"directories": {
|
||||
"doc": "./doc",
|
||||
"man": "./man"
|
||||
@@ -218,7 +219,7 @@ func TestPackageNpm(t *testing.T) {
|
||||
assert.Equal(t, packageBinPath, pmv.Bin[packageBinName])
|
||||
assert.Equal(t, integrity, pmv.Dist.Integrity)
|
||||
assert.Equal(t, sha1SumHex, pmv.Dist.Shasum)
|
||||
assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball)
|
||||
assert.Equal(t, fmt.Sprintf("%sapi/packages/%s/npm/%s/-/%s", setting.AppURL, user.Name, packageName, filename), pmv.Dist.Tarball)
|
||||
assert.Equal(t, repoType, result.Repository.Type)
|
||||
assert.Equal(t, repoURL, result.Repository.URL)
|
||||
assert.Equal(t, map[string]string{"tea": "2.x", "soy-milk": "1.2"}, pmv.PeerDependencies)
|
||||
@@ -228,10 +229,24 @@ func TestPackageNpm(t *testing.T) {
|
||||
assert.Equal(t, map[string]string{"node": ">=22.7.0", "npm": ">=10.8.2"}, pmv.Engines)
|
||||
assert.Equal(t, []string{"x64", "arm64"}, pmv.CPU)
|
||||
assert.Equal(t, []string{"linux", "darwin"}, pmv.OS)
|
||||
assert.Equal(t, []string{"glibc"}, pmv.Libc)
|
||||
assert.Equal(t, map[string]string{"doc": "./doc", "man": "./man"}, pmv.Directories)
|
||||
assert.Equal(t, "https://example.com/fund", pmv.Funding)
|
||||
assert.Equal(t, map[string]string{"left-pad": "1.x"}, pmv.AcceptDependencies)
|
||||
assert.Empty(t, pmv.Deprecated)
|
||||
|
||||
req = NewRequest(t, "GET", root).AddTokenAuth(token).SetHeader("If-None-Match", resp.Header().Get("ETag"))
|
||||
MakeRequest(t, req, http.StatusNotModified)
|
||||
})
|
||||
|
||||
t.Run("PingWhoami", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
registry := fmt.Sprintf("/api/packages/%s/npm/-/", user.Name)
|
||||
MakeRequest(t, NewRequest(t, "GET", registry+"ping"), http.StatusOK)
|
||||
MakeRequest(t, NewRequest(t, "GET", registry+"whoami"), http.StatusUnauthorized)
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", registry+"whoami").AddTokenAuth(token), http.StatusOK)
|
||||
assert.JSONEq(t, `{"username":"`+user.Name+`"}`, resp.Body.String())
|
||||
})
|
||||
|
||||
t.Run("PackageVersionMetadata", func(t *testing.T) {
|
||||
@@ -290,22 +305,6 @@ func TestPackageNpm(t *testing.T) {
|
||||
assert.Equal(t, packageVersion, result[packageTag2])
|
||||
})
|
||||
|
||||
t.Run("PackageMetadataDistTags", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", root).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
result := DecodeJSON(t, resp, &npm.PackageMetadata{})
|
||||
|
||||
assert.Len(t, result.DistTags, 2)
|
||||
assert.Contains(t, result.DistTags, packageTag)
|
||||
assert.Equal(t, packageVersion, result.DistTags[packageTag])
|
||||
assert.Contains(t, result.DistTags, packageTag2)
|
||||
assert.Equal(t, packageVersion, result.DistTags[packageTag2])
|
||||
})
|
||||
|
||||
t.Run("DeleteTag", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
@@ -319,6 +318,12 @@ func TestPackageNpm(t *testing.T) {
|
||||
test(t, http.StatusBadRequest, "1.0")
|
||||
test(t, http.StatusOK, "dummy")
|
||||
test(t, http.StatusOK, packageTag2)
|
||||
test(t, http.StatusOK, packageTag)
|
||||
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", tagsRoot).AddTokenAuth(token), http.StatusOK)
|
||||
assert.Equal(t, map[string]string{packageTag: packageVersion}, DecodeJSON(t, resp, map[string]string{}))
|
||||
resp = MakeRequest(t, NewRequest(t, "GET", root+"/"+packageTag).AddTokenAuth(token), http.StatusOK)
|
||||
assert.Equal(t, packageVersion, DecodeJSON(t, resp, &npm.PackageMetadataVersion{}).Version)
|
||||
})
|
||||
|
||||
t.Run("Search", func(t *testing.T) {
|
||||
@@ -523,7 +528,7 @@ func TestPackageNpm(t *testing.T) {
|
||||
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename))
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename)).
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/-rev/dummy", root, filename)).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
|
||||
@@ -378,11 +378,11 @@ func TestCantMergeConflict(t *testing.T) {
|
||||
BaseBranch: "base",
|
||||
})
|
||||
|
||||
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false)
|
||||
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false)
|
||||
assert.Error(t, err, "Merge should return an error due to conflict")
|
||||
assert.True(t, pull_service.IsErrMergeConflicts(err), "Merge error is not a conflict error")
|
||||
|
||||
err = pull_service.Merge(pr.ID, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false)
|
||||
err = pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false)
|
||||
assert.Error(t, err, "Merge should return an error due to conflict")
|
||||
assert.True(t, pull_service.IsErrRebaseConflicts(err), "Merge error is not a conflict error")
|
||||
})
|
||||
@@ -473,7 +473,7 @@ func TestCantMergeUnrelated(t *testing.T) {
|
||||
BaseBranch: "base",
|
||||
})
|
||||
|
||||
err = pull_service.Merge(pr.ID, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false)
|
||||
err = pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false)
|
||||
assert.Error(t, err, "Merge should return an error due to unrelated")
|
||||
assert.True(t, pull_service.IsErrMergeUnrelatedHistories(err), "Merge error is not a unrelated histories error")
|
||||
})
|
||||
@@ -509,7 +509,7 @@ func TestFastForwardOnlyMerge(t *testing.T) {
|
||||
BaseBranch: "master",
|
||||
})
|
||||
|
||||
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
|
||||
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -596,7 +596,7 @@ func TestFastForwardOnlyMergeWithRequiredSignedCommits(t *testing.T) {
|
||||
pb.RequireSignedCommits = false
|
||||
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, pb, git_model.WhitelistOptions{}))
|
||||
|
||||
require.NoError(t, pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false))
|
||||
require.NoError(t, pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -631,7 +631,7 @@ func TestCantFastForwardOnlyMergeDiverging(t *testing.T) {
|
||||
BaseBranch: "master",
|
||||
})
|
||||
|
||||
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
|
||||
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
|
||||
assert.Error(t, err, "Merge should return an error due to being for a diverging branch")
|
||||
assert.True(t, pull_service.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error")
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
/* fonts */
|
||||
--fonts-proportional: -apple-system, "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial;
|
||||
--fonts-monospace: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, var(--fonts-emoji);
|
||||
/* Chromium's "math" is a font Linux and ChromeOS don't ship, so fall back to the math fonts they do, https://issues.chromium.org/issues/40069293 */
|
||||
--fonts-math: math, "STIX Two Math", "DejaVu Math TeX Gyre", "Noto Sans Math";
|
||||
/* GitHub explicitly sets font names like: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Twemoji Mozilla";
|
||||
Actually "Twemoji Mozilla" emoji font is widely used by browsers like Firefox, Pale Moon, and it is more likely up-to-dated than the system emoji font.
|
||||
So not setting emoji font seems to be the best choice, here we just use a non-existing dummy font name and let browsers choose. */
|
||||
@@ -107,6 +109,11 @@ samp,
|
||||
font-size: 0.95em; /* compensate for monospace fonts being usually slightly larger */
|
||||
}
|
||||
|
||||
math {
|
||||
font-family: var(--fonts-math);
|
||||
font-size-adjust: ex-height 0.52; /* match KaTeX's x-height (0.431 × 1.21em) regardless of math font, https://github.com/w3c/mathml-core/issues/41 */
|
||||
}
|
||||
|
||||
/* there are many <code> blocks in non-markup(.markup code) / non-code-diff(code.code-inner) containers, for example: translation strings, etc,
|
||||
so we need to make <code> have default global styles, ".markup code" has its own styles and these styles sometimes conflict.
|
||||
TODO: in the future, we should use `div` instead of `code` for `.code-inner` because it is a container for highlighted code line, then drop this ":not" patch */
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
export async function initCaptcha() {
|
||||
registerGlobalInitFunc('initImageCaptcha', (el: HTMLElement) => {
|
||||
const a = el.querySelector('a')!;
|
||||
a.removeAttribute('href'); // remove generated href="javascript:"
|
||||
const img = el.querySelector('img')!;
|
||||
img.removeAttribute('onclick'); // remove generated onclick="...." and use our own event listener
|
||||
img.addEventListener('click', () => {
|
||||
const url = new URL(img.src);
|
||||
url.searchParams.set('reload', String(Date.now()));
|
||||
img.src = url.href;
|
||||
});
|
||||
});
|
||||
|
||||
const captchaEl = document.querySelector('#captcha');
|
||||
if (!captchaEl) return;
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ function selectRange(range: string): Element | null {
|
||||
stopLineNum = tmp;
|
||||
range = `${stop}-${start}`;
|
||||
}
|
||||
if (startLineNum < 1) return null;
|
||||
|
||||
const first = elLineNums[startLineNum - 1] ?? null;
|
||||
for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) {
|
||||
|
||||
@@ -93,7 +93,6 @@ function updateStopwatchData(data: Array<StopwatchData>) {
|
||||
} else {
|
||||
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
|
||||
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
|
||||
for (const btnEl of btnEls) btnEl.setAttribute('href', issueUrl);
|
||||
document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl);
|
||||
document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
|
||||
document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
|
||||
|
||||
@@ -17,11 +17,7 @@ test('isGiteaError', () => {
|
||||
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 ${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);
|
||||
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', () => {
|
||||
|
||||
@@ -58,7 +58,6 @@ export function isGiteaError(filename: string, stack: string): boolean {
|
||||
if (extensionRe.test(filename) || extensionRe.test(stack)) return false;
|
||||
const assetBaseUrl = new URL(`${windowConfig()?.assetUrlPrefix}/`, window.location.origin).href;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user