Merge branch 'main' into lunny/issue_dev

This commit is contained in:
Lunny Xiao 2024-10-01 18:12:49 -07:00
commit cbeed1168f
69 changed files with 1936 additions and 930 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,6 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build ignore
package main
@ -5,6 +8,8 @@ package main
import (
"archive/tar"
"compress/gzip"
"crypto/md5"
"encoding/hex"
"flag"
"fmt"
"io"
@ -15,6 +20,8 @@ import (
"path/filepath"
"strings"
"code.gitea.io/gitea/build/license"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/util"
)
@ -77,7 +84,7 @@ func main() {
}
tr := tar.NewReader(gz)
aliasesFiles := make(map[string][]string)
for {
hdr, err := tr.Next()
@ -97,26 +104,73 @@ func main() {
continue
}
if strings.HasPrefix(filepath.Base(hdr.Name), "README") {
fileBaseName := filepath.Base(hdr.Name)
licenseName := strings.TrimSuffix(fileBaseName, ".txt")
if strings.HasPrefix(fileBaseName, "README") {
continue
}
if strings.HasPrefix(filepath.Base(hdr.Name), "deprecated_") {
if strings.HasPrefix(fileBaseName, "deprecated_") {
continue
}
out, err := os.Create(path.Join(destination, strings.TrimSuffix(filepath.Base(hdr.Name), ".txt")))
out, err := os.Create(path.Join(destination, licenseName))
if err != nil {
log.Fatalf("Failed to create new file. %s", err)
}
defer out.Close()
if _, err := io.Copy(out, tr); err != nil {
// some license files have same content, so we need to detect these files and create a convert map into a json file
// Later we use this convert map to avoid adding same license content with different license name
h := md5.New()
// calculate md5 and write file in the same time
r := io.TeeReader(tr, h)
if _, err := io.Copy(out, r); err != nil {
log.Fatalf("Failed to write new file. %s", err)
} else {
fmt.Printf("Written %s\n", out.Name())
md5 := hex.EncodeToString(h.Sum(nil))
aliasesFiles[md5] = append(aliasesFiles[md5], licenseName)
}
}
// generate convert license name map
licenseAliases := make(map[string]string)
for _, fileNames := range aliasesFiles {
if len(fileNames) > 1 {
licenseName := license.GetLicenseNameFromAliases(fileNames)
if licenseName == "" {
// license name should not be empty as expected
// if it is empty, we need to rewrite the logic of GetLicenseNameFromAliases
log.Fatalf("GetLicenseNameFromAliases: license name is empty")
}
for _, fileName := range fileNames {
licenseAliases[fileName] = licenseName
}
}
}
// save convert license name map to file
b, err := json.Marshal(licenseAliases)
if err != nil {
log.Fatalf("Failed to create json bytes. %s", err)
}
licenseAliasesDestination := filepath.Join(destination, "etc", "license-aliases.json")
if err := os.MkdirAll(filepath.Dir(licenseAliasesDestination), 0o755); err != nil {
log.Fatalf("Failed to create directory for license aliases json file. %s", err)
}
f, err := os.Create(licenseAliasesDestination)
if err != nil {
log.Fatalf("Failed to create license aliases json file. %s", err)
}
defer f.Close()
if _, err = f.Write(b); err != nil {
log.Fatalf("Failed to write license aliases json file. %s", err)
}
fmt.Println("Done")
}

View File

@ -0,0 +1,41 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package license
import "strings"
func GetLicenseNameFromAliases(fnl []string) string {
if len(fnl) == 0 {
return ""
}
shortestItem := func(list []string) string {
s := list[0]
for _, l := range list[1:] {
if len(l) < len(s) {
s = l
}
}
return s
}
allHasPrefix := func(list []string, s string) bool {
for _, l := range list {
if !strings.HasPrefix(l, s) {
return false
}
}
return true
}
sl := shortestItem(fnl)
slv := strings.Split(sl, "-")
var result string
for i := len(slv); i >= 0; i-- {
result = strings.Join(slv[:i], "-")
if allHasPrefix(fnl, result) {
return result
}
}
return ""
}

View File

@ -0,0 +1,39 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package license
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetLicenseNameFromAliases(t *testing.T) {
tests := []struct {
target string
inputs []string
}{
{
// real case which you can find in license-aliases.json
target: "AGPL-1.0",
inputs: []string{
"AGPL-1.0-only",
"AGPL-1.0-or-late",
},
},
{
target: "",
inputs: []string{
"APSL-1.0",
"AGPL-1.0-only",
"AGPL-1.0-or-late",
},
},
}
for _, tt := range tests {
result := GetLicenseNameFromAliases(tt.inputs)
assert.Equal(t, result, tt.target)
}
}

1
go.mod
View File

@ -68,6 +68,7 @@ require (
github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/go-github/v61 v61.0.0
github.com/google/licenseclassifier/v2 v2.0.0
github.com/google/pprof v0.0.0-20240618054019-d3b898a103f8
github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.2.0

3
go.sum
View File

@ -441,6 +441,8 @@ github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/licenseclassifier/v2 v2.0.0 h1:1Y57HHILNf4m0ABuMVb6xk4vAJYEUO0gDxNpog0pyeA=
github.com/google/licenseclassifier/v2 v2.0.0/go.mod h1:cOjbdH0kyC9R22sdQbYsFkto4NGCAc+ZSwbeThazEtM=
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/pprof v0.0.0-20240618054019-d3b898a103f8 h1:ASJ/LAqdCHOyMYI+dwNxn7Rd8FscNkMyTr1KZU1JI/M=
github.com/google/pprof v0.0.0-20240618054019-d3b898a103f8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
@ -735,6 +737,7 @@ github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jN
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=

View File

@ -83,3 +83,22 @@
issue_id: 2 # in repo_id 1
review_id: 20
created_unix: 946684810
-
id: 10
type: 22 # review
poster_id: 5
issue_id: 3 # in repo_id 1
content: "reviewed by user5"
review_id: 21
created_unix: 946684816
-
id: 11
type: 27 # review request
poster_id: 2
issue_id: 3 # in repo_id 1
content: "review request for user5"
review_id: 22
assignee_id: 5
created_unix: 946684817

View File

@ -0,0 +1 @@
[] # empty

View File

@ -26,7 +26,7 @@
fork_id: 0
is_template: false
template_id: 0
size: 7597
size: 8478
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false

View File

@ -179,3 +179,22 @@
content: "Review Comment"
updated_unix: 946684810
created_unix: 946684810
-
id: 21
type: 2
reviewer_id: 5
issue_id: 3
content: "reviewed by user5"
commit_id: 4a357436d925b5c974181ff12a994538ddc5a269
updated_unix: 946684816
created_unix: 946684816
-
id: 22
type: 4
reviewer_id: 5
issue_id: 3
content: "review request for user5"
updated_unix: 946684817
created_unix: 946684817

View File

@ -414,7 +414,7 @@ func (pr *PullRequest) getReviewedByLines(ctx context.Context, writer io.Writer)
// Note: This doesn't page as we only expect a very limited number of reviews
reviews, err := FindLatestReviews(ctx, FindReviewOptions{
Type: ReviewTypeApprove,
Types: []ReviewType{ReviewTypeApprove},
IssueID: pr.IssueID,
OfficialOnly: setting.Repository.PullRequest.DefaultMergeMessageOfficialApproversOnly,
})

View File

@ -389,7 +389,7 @@ func GetCurrentReview(ctx context.Context, reviewer *user_model.User, issue *Iss
return nil, nil
}
reviews, err := FindReviews(ctx, FindReviewOptions{
Type: ReviewTypePending,
Types: []ReviewType{ReviewTypePending},
IssueID: issue.ID,
ReviewerID: reviewer.ID,
})

View File

@ -92,7 +92,7 @@ func (reviews ReviewList) LoadIssues(ctx context.Context) error {
// FindReviewOptions represent possible filters to find reviews
type FindReviewOptions struct {
db.ListOptions
Type ReviewType
Types []ReviewType
IssueID int64
ReviewerID int64
OfficialOnly bool
@ -107,8 +107,8 @@ func (opts *FindReviewOptions) toCond() builder.Cond {
if opts.ReviewerID > 0 {
cond = cond.And(builder.Eq{"reviewer_id": opts.ReviewerID})
}
if opts.Type != ReviewTypeUnknown {
cond = cond.And(builder.Eq{"type": opts.Type})
if len(opts.Types) > 0 {
cond = cond.And(builder.In("type", opts.Types))
}
if opts.OfficialOnly {
cond = cond.And(builder.Eq{"official": true})

View File

@ -63,7 +63,7 @@ func TestReviewType_Icon(t *testing.T) {
func TestFindReviews(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
reviews, err := issues_model.FindReviews(db.DefaultContext, issues_model.FindReviewOptions{
Type: issues_model.ReviewTypeApprove,
Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove},
IssueID: 2,
ReviewerID: 1,
})
@ -75,7 +75,7 @@ func TestFindReviews(t *testing.T) {
func TestFindLatestReviews(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
reviews, err := issues_model.FindLatestReviews(db.DefaultContext, issues_model.FindReviewOptions{
Type: issues_model.ReviewTypeApprove,
Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove},
IssueID: 11,
})
assert.NoError(t, err)

View File

@ -500,7 +500,7 @@ var migrations = []Migration{
// v259 -> v260
NewMigration("Convert scoped access tokens", v1_20.ConvertScopedAccessTokens),
// Gitea 1.20.0 ends at 260
// Gitea 1.20.0 ends at v260
// v260 -> v261
NewMigration("Drop custom_labels column of action_runner table", v1_21.DropCustomLabelsColumnOfActionRunner),
@ -602,6 +602,8 @@ var migrations = []Migration{
// v304 -> v305
NewMigration("Add index for release sha1", v1_23.AddIndexForReleaseSha1),
// v305 -> v306
NewMigration("Add Repository Licenses", v1_23.AddRepositoryLicenses),
// v306 -> v307
NewMigration("Add table issue_dev_link", v1_23.CreateTableIssueDevLink),
}

View File

@ -9,14 +9,15 @@ import (
"xorm.io/xorm"
)
func CreateTableIssueDevLink(x *xorm.Engine) error {
type IssueDevLink struct {
ID int64 `xorm:"pk autoincr"`
IssueID int64 `xorm:"INDEX"`
LinkType int
LinkedRepoID int64 `xorm:"INDEX"` // it can link to self repo or other repo
LinkIndex string // branch name, pull request number or commit sha
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
func AddRepositoryLicenses(x *xorm.Engine) error {
type RepoLicense struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s) NOT NULL"`
CommitID string
License string `xorm:"VARCHAR(255) UNIQUE(s) NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX UPDATED"`
}
return x.Sync(new(IssueDevLink))
return x.Sync(new(RepoLicense))
}

View File

@ -0,0 +1,22 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_23 //nolint
import (
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
)
func CreateTableIssueDevLink(x *xorm.Engine) error {
type IssueDevLink struct {
ID int64 `xorm:"pk autoincr"`
IssueID int64 `xorm:"INDEX"`
LinkType int
LinkedRepoID int64 `xorm:"INDEX"` // it can link to self repo or other repo
LinkIndex string // branch name, pull request number or commit sha
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
return x.Sync(new(IssueDevLink))
}

120
models/repo/license.go Normal file
View File

@ -0,0 +1,120 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(RepoLicense))
}
type RepoLicense struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s) NOT NULL"`
CommitID string
License string `xorm:"VARCHAR(255) UNIQUE(s) NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX UPDATED"`
}
// RepoLicenseList defines a list of repo licenses
type RepoLicenseList []*RepoLicense //revive:disable-line:exported
func (rll RepoLicenseList) StringList() []string {
var licenses []string
for _, rl := range rll {
licenses = append(licenses, rl.License)
}
return licenses
}
// GetRepoLicenses returns the license statistics for a repository
func GetRepoLicenses(ctx context.Context, repo *Repository) (RepoLicenseList, error) {
licenses := make(RepoLicenseList, 0)
if err := db.GetEngine(ctx).Where("`repo_id` = ?", repo.ID).Asc("`license`").Find(&licenses); err != nil {
return nil, err
}
return licenses, nil
}
// UpdateRepoLicenses updates the license statistics for repository
func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string, licenses []string) error {
oldLicenses, err := GetRepoLicenses(ctx, repo)
if err != nil {
return err
}
for _, license := range licenses {
upd := false
for _, o := range oldLicenses {
// Update already existing license
if o.License == license {
if _, err := db.GetEngine(ctx).ID(o.ID).Cols("`commit_id`").Update(o); err != nil {
return err
}
upd = true
break
}
}
// Insert new license
if !upd {
if err := db.Insert(ctx, &RepoLicense{
RepoID: repo.ID,
CommitID: commitID,
License: license,
}); err != nil {
return err
}
}
}
// Delete old licenses
licenseToDelete := make([]int64, 0, len(oldLicenses))
for _, o := range oldLicenses {
if o.CommitID != commitID {
licenseToDelete = append(licenseToDelete, o.ID)
}
}
if len(licenseToDelete) > 0 {
if _, err := db.GetEngine(ctx).In("`id`", licenseToDelete).Delete(&RepoLicense{}); err != nil {
return err
}
}
return nil
}
// CopyLicense Copy originalRepo license information to destRepo (use for forked repo)
func CopyLicense(ctx context.Context, originalRepo, destRepo *Repository) error {
repoLicenses, err := GetRepoLicenses(ctx, originalRepo)
if err != nil {
return err
}
if len(repoLicenses) > 0 {
newRepoLicenses := make(RepoLicenseList, 0, len(repoLicenses))
for _, rl := range repoLicenses {
newRepoLicense := &RepoLicense{
RepoID: destRepo.ID,
CommitID: rl.CommitID,
License: rl.License,
}
newRepoLicenses = append(newRepoLicenses, newRepoLicense)
}
if err := db.Insert(ctx, &newRepoLicenses); err != nil {
return err
}
}
return nil
}
// CleanRepoLicenses will remove all license record of the repo
func CleanRepoLicenses(ctx context.Context, repo *Repository) error {
return db.DeleteBeans(ctx, &RepoLicense{
RepoID: repo.ID,
})
}

View File

@ -23,7 +23,7 @@ type LicenseValues struct {
func GetLicense(name string, values *LicenseValues) ([]byte, error) {
data, err := options.License(name)
if err != nil {
return nil, fmt.Errorf("GetRepoInitFile[%s]: %w", name, err)
return nil, fmt.Errorf("GetLicense[%s]: %w", name, err)
}
return fillLicensePlaceholder(name, values, data), nil
}

View File

@ -114,6 +114,7 @@ type Repository struct {
MirrorUpdated time.Time `json:"mirror_updated,omitempty"`
RepoTransfer *RepoTransfer `json:"repo_transfer"`
Topics []string `json:"topics"`
Licenses []string `json:"licenses"`
}
// CreateRepoOption options when creating repository

View File

@ -0,0 +1 @@
{"AGPL-1.0-only":"AGPL-1.0","AGPL-1.0-or-later":"AGPL-1.0","AGPL-3.0-only":"AGPL-3.0","AGPL-3.0-or-later":"AGPL-3.0","CAL-1.0":"CAL-1.0","CAL-1.0-Combined-Work-Exception":"CAL-1.0","GFDL-1.1-invariants-only":"GFDL-1.1","GFDL-1.1-invariants-or-later":"GFDL-1.1","GFDL-1.1-no-invariants-only":"GFDL-1.1","GFDL-1.1-no-invariants-or-later":"GFDL-1.1","GFDL-1.1-only":"GFDL-1.1","GFDL-1.1-or-later":"GFDL-1.1","GFDL-1.2-invariants-only":"GFDL-1.2","GFDL-1.2-invariants-or-later":"GFDL-1.2","GFDL-1.2-no-invariants-only":"GFDL-1.2","GFDL-1.2-no-invariants-or-later":"GFDL-1.2","GFDL-1.2-only":"GFDL-1.2","GFDL-1.2-or-later":"GFDL-1.2","GFDL-1.3-invariants-only":"GFDL-1.3","GFDL-1.3-invariants-or-later":"GFDL-1.3","GFDL-1.3-no-invariants-only":"GFDL-1.3","GFDL-1.3-no-invariants-or-later":"GFDL-1.3","GFDL-1.3-only":"GFDL-1.3","GFDL-1.3-or-later":"GFDL-1.3","GPL-1.0-only":"GPL-1.0","GPL-1.0-or-later":"GPL-1.0","GPL-2.0-only":"GPL-2.0","GPL-2.0-or-later":"GPL-2.0","GPL-3.0-only":"GPL-3.0","GPL-3.0-or-later":"GPL-3.0","LGPL-2.0-only":"LGPL-2.0","LGPL-2.0-or-later":"LGPL-2.0","LGPL-2.1-only":"LGPL-2.1","LGPL-2.1-or-later":"LGPL-2.1","LGPL-3.0-only":"LGPL-3.0","LGPL-3.0-or-later":"LGPL-3.0","MPL-2.0":"MPL-2.0","MPL-2.0-no-copyleft-exception":"MPL-2.0","OFL-1.0":"OFL-1.0","OFL-1.0-RFN":"OFL-1.0","OFL-1.0-no-RFN":"OFL-1.0","OFL-1.1":"OFL-1.1","OFL-1.1-RFN":"OFL-1.1","OFL-1.1-no-RFN":"OFL-1.1"}

View File

@ -1040,6 +1040,7 @@ issue_labels_helper = Select an issue label set.
license = License
license_helper = Select a license file.
license_helper_desc = A license governs what others can and can't do with your code. Not sure which one is right for your project? See <a target="_blank" rel="noopener noreferrer" href="%s">Choose a license.</a>
multiple_licenses = Multiple Licenses
object_format = Object Format
object_format_helper = Object format of the repository. Cannot be changed later. SHA1 is most compatible.
readme = README
@ -2951,6 +2952,7 @@ dashboard.start_schedule_tasks = Start actions schedule tasks
dashboard.sync_branch.started = Branches Sync started
dashboard.sync_tag.started = Tags Sync started
dashboard.rebuild_issue_indexer = Rebuild issue indexer
dashboard.sync_repo_licenses = Sync repo licenses
users.user_manage_panel = User Account Management
users.new_account = Create User Account

View File

@ -1327,6 +1327,7 @@ func Routes() *web.Router {
m.Get("/issue_config", context.ReferencesGitRepo(), repo.GetIssueConfig)
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
m.Get("/licenses", reqRepoReader(unit.TypeCode), repo.GetLicenses)
m.Get("/activities/feeds", repo.ListRepoActivityFeeds)
m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed)
m.Group("/avatar", func() {

View File

@ -0,0 +1,51 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/services/context"
)
// GetLicenses returns licenses
func GetLicenses(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/licenses repository repoGetLicenses
// ---
// summary: Get repo licenses
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// responses:
// "404":
// "$ref": "#/responses/notFound"
// "200":
// "$ref": "#/responses/LicensesList"
licenses, err := repo_model.GetRepoLicenses(ctx, ctx.Repo.Repository)
if err != nil {
log.Error("GetRepoLicenses failed: %v", err)
ctx.InternalServerError(err)
return
}
resp := make([]string, len(licenses))
for i := range licenses {
resp[i] = licenses[i].License
}
ctx.JSON(http.StatusOK, resp)
}

View File

@ -83,7 +83,6 @@ func ListPullReviews(ctx *context.APIContext) {
opts := issues_model.FindReviewOptions{
ListOptions: utils.GetListOptions(ctx),
Type: issues_model.ReviewTypeUnknown,
IssueID: pr.IssueID,
}

View File

@ -731,6 +731,7 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err
}
// Default branch only updated if changed and exist or the repository is empty
updateRepoLicense := false
if opts.DefaultBranch != nil && repo.DefaultBranch != *opts.DefaultBranch && (repo.IsEmpty || ctx.Repo.GitRepo.IsBranchExist(*opts.DefaultBranch)) {
if !repo.IsEmpty {
if err := gitrepo.SetDefaultBranch(ctx, ctx.Repo.Repository, *opts.DefaultBranch); err != nil {
@ -739,6 +740,7 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err
return err
}
}
updateRepoLicense = true
}
repo.DefaultBranch = *opts.DefaultBranch
}
@ -748,6 +750,15 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err
return err
}
if updateRepoLicense {
if err := repo_service.AddRepoToLicenseUpdaterQueue(&repo_service.LicenseUpdaterOptions{
RepoID: ctx.Repo.Repository.ID,
}); err != nil {
ctx.Error(http.StatusInternalServerError, "AddRepoToLicenseUpdaterQueue", err)
return err
}
}
log.Trace("Repository basic settings updated: %s/%s", owner.Name, repo.Name)
return nil
}

View File

@ -359,6 +359,13 @@ type swaggerLanguageStatistics struct {
Body map[string]int64 `json:"body"`
}
// LicensesList
// swagger:response LicensesList
type swaggerLicensesList struct {
// in: body
Body []string `json:"body"`
}
// CombinedStatus
// swagger:response CombinedStatus
type swaggerCombinedStatus struct {

View File

@ -47,6 +47,7 @@ import (
markup_service "code.gitea.io/gitea/services/markup"
repo_migrations "code.gitea.io/gitea/services/migrations"
mirror_service "code.gitea.io/gitea/services/mirror"
"code.gitea.io/gitea/services/oauth2_provider"
pull_service "code.gitea.io/gitea/services/pull"
release_service "code.gitea.io/gitea/services/release"
repo_service "code.gitea.io/gitea/services/repository"
@ -144,7 +145,7 @@ func InitWebInstalled(ctx context.Context) {
log.Info("ORM engine initialization successful!")
mustInit(system.Init)
mustInitCtx(ctx, oauth2.Init)
mustInitCtx(ctx, oauth2_provider.Init)
mustInit(release_service.Init)
mustInitCtx(ctx, models.Init)
@ -172,6 +173,8 @@ func InitWebInstalled(ctx context.Context) {
actions_service.Init()
mustInit(repo_service.InitLicenseClassifier)
// Finally start up the cron
cron.NewContext(ctx)
}

View File

@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/private"
gitea_context "code.gitea.io/gitea/services/context"
repo_service "code.gitea.io/gitea/services/repository"
)
// SetDefaultBranch updates the default branch
@ -36,5 +37,15 @@ func SetDefaultBranch(ctx *gitea_context.PrivateContext) {
})
return
}
if err := repo_service.AddRepoToLicenseUpdaterQueue(&repo_service.LicenseUpdaterOptions{
RepoID: ctx.Repo.Repository.ID,
}); err != nil {
ctx.JSON(http.StatusInternalServerError, private.Response{
Err: fmt.Sprintf("Unable to set default branch on repository: %s/%s Error: %v", ownerName, repoName, err),
})
return
}
ctx.PlainText(http.StatusOK, "success")
}

View File

@ -282,10 +282,19 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
branch := refFullName.BranchName()
// If our branch is the default branch of an unforked repo - there's no PR to create or refer to
if !repo.IsFork && branch == baseRepo.DefaultBranch {
results = append(results, private.HookPostReceiveBranchResult{})
continue
if branch == baseRepo.DefaultBranch {
if err := repo_service.AddRepoToLicenseUpdaterQueue(&repo_service.LicenseUpdaterOptions{
RepoID: repo.ID,
}); err != nil {
ctx.JSON(http.StatusInternalServerError, private.Response{Err: err.Error()})
return
}
// If our branch is the default branch of an unforked repo - there's no PR to create or refer to
if !repo.IsFork {
results = append(results, private.HookPostReceiveBranchResult{})
continue
}
}
pr, err := issues_model.GetUnmergedPullRequest(ctx, repo.ID, baseRepo.ID, branch, baseRepo.DefaultBranch, issues_model.PullRequestFlowGithub)

View File

@ -4,878 +4,34 @@
package auth
import (
go_context "context"
"errors"
"fmt"
"html"
"html/template"
"io"
"net/http"
"net/url"
"sort"
"strings"
"code.gitea.io/gitea/models/auth"
org_model "code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
auth_module "code.gitea.io/gitea/modules/auth"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/modules/web/middleware"
auth_service "code.gitea.io/gitea/services/auth"
source_service "code.gitea.io/gitea/services/auth/source"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/externalaccount"
"code.gitea.io/gitea/services/forms"
user_service "code.gitea.io/gitea/services/user"
"gitea.com/go-chi/binding"
"github.com/golang-jwt/jwt/v5"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
go_oauth2 "golang.org/x/oauth2"
)
const (
tplGrantAccess base.TplName = "user/auth/grant"
tplGrantError base.TplName = "user/auth/grant_error"
)
// TODO move error and responses to SDK or models
// AuthorizeErrorCode represents an error code specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
type AuthorizeErrorCode string
const (
// ErrorCodeInvalidRequest represents the according error in RFC 6749
ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
// ErrorCodeUnauthorizedClient represents the according error in RFC 6749
ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
// ErrorCodeAccessDenied represents the according error in RFC 6749
ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
// ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
// ErrorCodeInvalidScope represents the according error in RFC 6749
ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
// ErrorCodeServerError represents the according error in RFC 6749
ErrorCodeServerError AuthorizeErrorCode = "server_error"
// ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
)
// AuthorizeError represents an error type specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
type AuthorizeError struct {
ErrorCode AuthorizeErrorCode `json:"error" form:"error"`
ErrorDescription string
State string
}
// Error returns the error message
func (err AuthorizeError) Error() string {
return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
}
// AccessTokenErrorCode represents an error code specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
type AccessTokenErrorCode string
const (
// AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
// AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidClient = "invalid_client"
// AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidGrant = "invalid_grant"
// AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
// AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
// AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidScope = "invalid_scope"
)
// AccessTokenError represents an error response specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
type AccessTokenError struct {
ErrorCode AccessTokenErrorCode `json:"error" form:"error"`
ErrorDescription string `json:"error_description"`
}
// Error returns the error message
func (err AccessTokenError) Error() string {
return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
}
// errCallback represents a oauth2 callback error
type errCallback struct {
Code string
Description string
}
func (err errCallback) Error() string {
return err.Description
}
// TokenType specifies the kind of token
type TokenType string
const (
// TokenTypeBearer represents a token type specified in RFC 6749
TokenTypeBearer TokenType = "bearer"
// TokenTypeMAC represents a token type specified in RFC 6749
TokenTypeMAC = "mac"
)
// AccessTokenResponse represents a successful access token response
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType TokenType `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token,omitempty"`
}
func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
if setting.OAuth2.InvalidateRefreshTokens {
if err := grant.IncreaseCounter(ctx); err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "cannot increase the grant counter",
}
}
}
// generate access token to access the API
expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
accessToken := &oauth2.Token{
GrantID: grant.ID,
Type: oauth2.TypeAccessToken,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
},
}
signedAccessToken, err := accessToken.SignToken(serverKey)
if err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot sign token",
}
}
// generate refresh token to request an access token after it expired later
refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime()
refreshToken := &oauth2.Token{
GrantID: grant.ID,
Counter: grant.Counter,
Type: oauth2.TypeRefreshToken,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(refreshExpirationDate),
},
}
signedRefreshToken, err := refreshToken.SignToken(serverKey)
if err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot sign token",
}
}
// generate OpenID Connect id_token
signedIDToken := ""
if grant.ScopeContains("openid") {
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
if err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot find application",
}
}
user, err := user_model.GetUserByID(ctx, grant.UserID)
if err != nil {
if user_model.IsErrUserNotExist(err) {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot find user",
}
}
log.Error("Error loading user: %v", err)
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "server error",
}
}
idToken := &oauth2.OIDCToken{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
Issuer: setting.AppURL,
Audience: []string{app.ClientID},
Subject: fmt.Sprint(grant.UserID),
},
Nonce: grant.Nonce,
}
if grant.ScopeContains("profile") {
idToken.Name = user.GetDisplayName()
idToken.PreferredUsername = user.Name
idToken.Profile = user.HTMLURL()
idToken.Picture = user.AvatarLink(ctx)
idToken.Website = user.Website
idToken.Locale = user.Language
idToken.UpdatedAt = user.UpdatedUnix
}
if grant.ScopeContains("email") {
idToken.Email = user.Email
idToken.EmailVerified = user.IsActive
}
if grant.ScopeContains("groups") {
groups, err := getOAuthGroupsForUser(ctx, user)
if err != nil {
log.Error("Error getting groups: %v", err)
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "server error",
}
}
idToken.Groups = groups
}
signedIDToken, err = idToken.SignToken(clientKey)
if err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot sign token",
}
}
}
return &AccessTokenResponse{
AccessToken: signedAccessToken,
TokenType: TokenTypeBearer,
ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
RefreshToken: signedRefreshToken,
IDToken: signedIDToken,
}, nil
}
type userInfoResponse struct {
Sub string `json:"sub"`
Name string `json:"name"`
Username string `json:"preferred_username"`
Email string `json:"email"`
Picture string `json:"picture"`
Groups []string `json:"groups"`
}
// InfoOAuth manages request for userinfo endpoint
func InfoOAuth(ctx *context.Context) {
if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}
response := &userInfoResponse{
Sub: fmt.Sprint(ctx.Doer.ID),
Name: ctx.Doer.FullName,
Username: ctx.Doer.Name,
Email: ctx.Doer.Email,
Picture: ctx.Doer.AvatarLink(ctx),
}
groups, err := getOAuthGroupsForUser(ctx, ctx.Doer)
if err != nil {
ctx.ServerError("Oauth groups for user", err)
return
}
response.Groups = groups
ctx.JSON(http.StatusOK, response)
}
// returns a list of "org" and "org:team" strings,
// that the given user is a part of.
func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User) ([]string, error) {
orgs, err := org_model.GetUserOrgsList(ctx, user)
if err != nil {
return nil, fmt.Errorf("GetUserOrgList: %w", err)
}
var groups []string
for _, org := range orgs {
groups = append(groups, org.Name)
teams, err := org.LoadTeams(ctx)
if err != nil {
return nil, fmt.Errorf("LoadTeams: %w", err)
}
for _, team := range teams {
if team.IsMember(ctx, user.ID) {
groups = append(groups, org.Name+":"+team.LowerName)
}
}
}
return groups, nil
}
func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
authHeader := ctx.Req.Header.Get("Authorization")
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
return base.BasicAuthDecode(authData)
}
return "", "", errors.New("invalid basic authentication")
}
// IntrospectOAuth introspects an oauth token
func IntrospectOAuth(ctx *context.Context) {
clientIDValid := false
if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
// this is likely a database error; log it and respond without details
log.Error("Error retrieving client_id: %v", err)
ctx.Error(http.StatusInternalServerError)
return
}
clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
}
if !clientIDValid {
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`)
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}
var response struct {
Active bool `json:"active"`
Scope string `json:"scope,omitempty"`
Username string `json:"username,omitempty"`
jwt.RegisteredClaims
}
form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey)
if err == nil {
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
if err == nil && grant != nil {
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
if err == nil && app != nil {
response.Active = true
response.Scope = grant.Scope
response.Issuer = setting.AppURL
response.Audience = []string{app.ClientID}
response.Subject = fmt.Sprint(grant.UserID)
}
if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
response.Username = user.Name
}
}
}
ctx.JSON(http.StatusOK, response)
}
// AuthorizeOAuth manages authorize requests
func AuthorizeOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AuthorizationForm)
errs := binding.Errors{}
errs = form.Validate(ctx.Req, errs)
if len(errs) > 0 {
errstring := ""
for _, e := range errs {
errstring += e.Error() + "\n"
}
ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
return
}
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
if auth.IsErrOauthClientIDInvalid(err) {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeUnauthorizedClient,
ErrorDescription: "Client ID not registered",
State: form.State,
}, "")
return
}
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
return
}
var user *user_model.User
if app.UID != 0 {
user, err = user_model.GetUserByID(ctx, app.UID)
if err != nil {
ctx.ServerError("GetUserByID", err)
return
}
}
if !app.ContainsRedirectURI(form.RedirectURI) {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeInvalidRequest,
ErrorDescription: "Unregistered Redirect URI",
State: form.State,
}, "")
return
}
if form.ResponseType != "code" {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeUnsupportedResponseType,
ErrorDescription: "Only code response type is supported.",
State: form.State,
}, form.RedirectURI)
return
}
// pkce support
switch form.CodeChallengeMethod {
case "S256":
case "plain":
if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeServerError,
ErrorDescription: "cannot set code challenge method",
State: form.State,
}, form.RedirectURI)
return
}
if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeServerError,
ErrorDescription: "cannot set code challenge",
State: form.State,
}, form.RedirectURI)
return
}
// Here we're just going to try to release the session early
if err := ctx.Session.Release(); err != nil {
// we'll tolerate errors here as they *should* get saved elsewhere
log.Error("Unable to save changes to the session: %v", err)
}
case "":
// "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
// https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
if !app.ConfidentialClient {
// "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeInvalidRequest,
ErrorDescription: "PKCE is required for public clients",
State: form.State,
}, form.RedirectURI)
return
}
default:
// "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
// https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeInvalidRequest,
ErrorDescription: "unsupported code challenge method",
State: form.State,
}, form.RedirectURI)
return
}
grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
// 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 {
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
redirect, err := code.GenerateRedirectURI(form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
// Update nonce to reflect the new session
if len(form.Nonce) > 0 {
err := grant.SetNonce(ctx, form.Nonce)
if err != nil {
log.Error("Unable to update nonce: %v", err)
}
}
ctx.Redirect(redirect.String())
return
}
// show authorize page to grant access
ctx.Data["Application"] = app
ctx.Data["RedirectURI"] = form.RedirectURI
ctx.Data["State"] = form.State
ctx.Data["Scope"] = form.Scope
ctx.Data["Nonce"] = form.Nonce
if user != nil {
ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">@%s</a>`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
} else {
ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
}
ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("<strong>" + html.EscapeString(form.RedirectURI) + "</strong>")
// TODO document SESSION <=> FORM
err = ctx.Session.Set("client_id", app.ClientID)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
err = ctx.Session.Set("redirect_uri", form.RedirectURI)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
err = ctx.Session.Set("state", form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
// Here we're just going to try to release the session early
if err := ctx.Session.Release(); err != nil {
// we'll tolerate errors here as they *should* get saved elsewhere
log.Error("Unable to save changes to the session: %v", err)
}
ctx.HTML(http.StatusOK, tplGrantAccess)
}
// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
func GrantApplicationOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.GrantApplicationForm)
if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
ctx.Session.Get("redirect_uri") != form.RedirectURI {
ctx.Error(http.StatusBadRequest)
return
}
if !form.Granted {
handleAuthorizeError(ctx, AuthorizeError{
State: form.State,
ErrorDescription: "the request is denied",
ErrorCode: ErrorCodeAccessDenied,
}, form.RedirectURI)
return
}
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
return
}
grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
if grant == nil {
grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope)
if err != nil {
handleAuthorizeError(ctx, AuthorizeError{
State: form.State,
ErrorDescription: "cannot create grant for user",
ErrorCode: ErrorCodeServerError,
}, form.RedirectURI)
return
}
} 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 len(form.Nonce) > 0 {
err := grant.SetNonce(ctx, form.Nonce)
if err != nil {
log.Error("Unable to update nonce: %v", err)
}
}
var codeChallenge, codeChallengeMethod string
codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string)
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
redirect, err := code.GenerateRedirectURI(form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
ctx.Redirect(redirect.String(), http.StatusSeeOther)
}
// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
func OIDCWellKnown(ctx *context.Context) {
ctx.Data["SigningKey"] = oauth2.DefaultSigningKey
ctx.JSONTemplate("user/auth/oidc_wellknown")
}
// OIDCKeys generates the JSON Web Key Set
func OIDCKeys(ctx *context.Context) {
jwk, err := oauth2.DefaultSigningKey.ToJWK()
if err != nil {
log.Error("Error converting signing key to JWK: %v", err)
ctx.Error(http.StatusInternalServerError)
return
}
jwk["use"] = "sig"
jwks := map[string][]map[string]string{
"keys": {
jwk,
},
}
ctx.Resp.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(ctx.Resp)
if err := enc.Encode(jwks); err != nil {
log.Error("Failed to encode representation as json. Error: %v", err)
}
}
// AccessTokenOAuth manages all access token requests by the client
func AccessTokenOAuth(ctx *context.Context) {
form := *web.GetForm(ctx).(*forms.AccessTokenForm)
// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
if form.ClientID == "" || form.ClientSecret == "" {
authHeader := ctx.Req.Header.Get("Authorization")
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
clientID, clientSecret, err := base.BasicAuthDecode(authData)
if err != nil {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot parse basic auth header",
})
return
}
// validate that any fields present in the form match the Basic auth header
if form.ClientID != "" && form.ClientID != clientID {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "client_id in request body inconsistent with Authorization header",
})
return
}
form.ClientID = clientID
if form.ClientSecret != "" && form.ClientSecret != clientSecret {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "client_secret in request body inconsistent with Authorization header",
})
return
}
form.ClientSecret = clientSecret
}
}
serverKey := oauth2.DefaultSigningKey
clientKey := serverKey
if serverKey.IsSymmetric() {
var err error
clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
if err != nil {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "Error creating signing key",
})
return
}
}
switch form.GrantType {
case "refresh_token":
handleRefreshToken(ctx, form, serverKey, clientKey)
case "authorization_code":
handleAuthorizationCode(ctx, form, serverKey, clientKey)
default:
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeUnsupportedGrantType,
ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
})
}
}
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidClient,
ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
})
return
}
// "The authorization server MUST ... require client authentication for confidential clients"
// https://datatracker.ietf.org/doc/html/rfc6749#section-6
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
errorDescription := "invalid client secret"
if form.ClientSecret == "" {
errorDescription = "invalid empty client secret"
}
// "invalid_client ... Client authentication failed"
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidClient,
ErrorDescription: errorDescription,
})
return
}
token, err := oauth2.ParseToken(form.RefreshToken, serverKey)
if err != nil {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unable to parse refresh token",
})
return
}
// get grant before increasing counter
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
if err != nil || grant == nil {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "grant does not exist",
})
return
}
// check if token got already used
if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "token was already used",
})
log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
return
}
accessToken, tokenErr := newAccessTokenResponse(ctx, grant, serverKey, clientKey)
if tokenErr != nil {
handleAccessTokenError(ctx, *tokenErr)
return
}
ctx.JSON(http.StatusOK, accessToken)
}
func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidClient,
ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
})
return
}
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
errorDescription := "invalid client secret"
if form.ClientSecret == "" {
errorDescription = "invalid empty client secret"
}
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: errorDescription,
})
return
}
if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unexpected redirect URI",
})
return
}
authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
if err != nil || authorizationCode == nil {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "client is not authorized",
})
return
}
// check if code verifier authorizes the client, PKCE support
if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "failed PKCE code challenge",
})
return
}
// check if granted for this application
if authorizationCode.Grant.ApplicationID != app.ID {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "invalid grant",
})
return
}
// remove token from database to deny duplicate usage
if err := authorizationCode.Invalidate(ctx); err != nil {
handleAccessTokenError(ctx, AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot proceed your request",
})
}
resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
if tokenErr != nil {
handleAccessTokenError(ctx, *tokenErr)
return
}
// send successful response
ctx.JSON(http.StatusOK, resp)
}
func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) {
ctx.JSON(http.StatusBadRequest, acErr)
}
func handleServerError(ctx *context.Context, state, redirectURI string) {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeServerError,
ErrorDescription: "A server error occurred",
State: state,
}, redirectURI)
}
func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
if redirectURI == "" {
log.Warn("Authorization failed: %v", authErr.ErrorDescription)
ctx.Data["Error"] = authErr
ctx.HTML(http.StatusBadRequest, tplGrantError)
return
}
redirect, err := url.Parse(redirectURI)
if err != nil {
ctx.ServerError("url.Parse", err)
return
}
q := redirect.Query()
q.Set("error", string(authErr.ErrorCode))
q.Set("error_description", authErr.ErrorDescription)
q.Set("state", authErr.State)
redirect.RawQuery = q.Encode()
ctx.Redirect(redirect.String(), http.StatusSeeOther)
}
// SignInOAuth handles the OAuth2 login buttons
func SignInOAuth(ctx *context.Context) {
provider := ctx.PathParam(":provider")

View File

@ -0,0 +1,666 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"fmt"
"html"
"html/template"
"net/http"
"net/url"
"strings"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/oauth2_provider"
"gitea.com/go-chi/binding"
jwt "github.com/golang-jwt/jwt/v5"
)
const (
tplGrantAccess base.TplName = "user/auth/grant"
tplGrantError base.TplName = "user/auth/grant_error"
)
// TODO move error and responses to SDK or models
// AuthorizeErrorCode represents an error code specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
type AuthorizeErrorCode string
const (
// ErrorCodeInvalidRequest represents the according error in RFC 6749
ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
// ErrorCodeUnauthorizedClient represents the according error in RFC 6749
ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
// ErrorCodeAccessDenied represents the according error in RFC 6749
ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
// ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
// ErrorCodeInvalidScope represents the according error in RFC 6749
ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
// ErrorCodeServerError represents the according error in RFC 6749
ErrorCodeServerError AuthorizeErrorCode = "server_error"
// ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
)
// AuthorizeError represents an error type specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
type AuthorizeError struct {
ErrorCode AuthorizeErrorCode `json:"error" form:"error"`
ErrorDescription string
State string
}
// Error returns the error message
func (err AuthorizeError) Error() string {
return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
}
// errCallback represents a oauth2 callback error
type errCallback struct {
Code string
Description string
}
func (err errCallback) Error() string {
return err.Description
}
type userInfoResponse struct {
Sub string `json:"sub"`
Name string `json:"name"`
Username string `json:"preferred_username"`
Email string `json:"email"`
Picture string `json:"picture"`
Groups []string `json:"groups"`
}
// InfoOAuth manages request for userinfo endpoint
func InfoOAuth(ctx *context.Context) {
if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}
response := &userInfoResponse{
Sub: fmt.Sprint(ctx.Doer.ID),
Name: ctx.Doer.FullName,
Username: ctx.Doer.Name,
Email: ctx.Doer.Email,
Picture: ctx.Doer.AvatarLink(ctx),
}
groups, err := oauth2_provider.GetOAuthGroupsForUser(ctx, ctx.Doer)
if err != nil {
ctx.ServerError("Oauth groups for user", err)
return
}
response.Groups = groups
ctx.JSON(http.StatusOK, response)
}
func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
authHeader := ctx.Req.Header.Get("Authorization")
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
return base.BasicAuthDecode(authData)
}
return "", "", errors.New("invalid basic authentication")
}
// IntrospectOAuth introspects an oauth token
func IntrospectOAuth(ctx *context.Context) {
clientIDValid := false
if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
// this is likely a database error; log it and respond without details
log.Error("Error retrieving client_id: %v", err)
ctx.Error(http.StatusInternalServerError)
return
}
clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
}
if !clientIDValid {
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`)
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
return
}
var response struct {
Active bool `json:"active"`
Scope string `json:"scope,omitempty"`
Username string `json:"username,omitempty"`
jwt.RegisteredClaims
}
form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
token, err := oauth2_provider.ParseToken(form.Token, oauth2_provider.DefaultSigningKey)
if err == nil {
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
if err == nil && grant != nil {
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
if err == nil && app != nil {
response.Active = true
response.Scope = grant.Scope
response.Issuer = setting.AppURL
response.Audience = []string{app.ClientID}
response.Subject = fmt.Sprint(grant.UserID)
}
if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
response.Username = user.Name
}
}
}
ctx.JSON(http.StatusOK, response)
}
// AuthorizeOAuth manages authorize requests
func AuthorizeOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AuthorizationForm)
errs := binding.Errors{}
errs = form.Validate(ctx.Req, errs)
if len(errs) > 0 {
errstring := ""
for _, e := range errs {
errstring += e.Error() + "\n"
}
ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
return
}
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
if auth.IsErrOauthClientIDInvalid(err) {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeUnauthorizedClient,
ErrorDescription: "Client ID not registered",
State: form.State,
}, "")
return
}
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
return
}
var user *user_model.User
if app.UID != 0 {
user, err = user_model.GetUserByID(ctx, app.UID)
if err != nil {
ctx.ServerError("GetUserByID", err)
return
}
}
if !app.ContainsRedirectURI(form.RedirectURI) {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeInvalidRequest,
ErrorDescription: "Unregistered Redirect URI",
State: form.State,
}, "")
return
}
if form.ResponseType != "code" {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeUnsupportedResponseType,
ErrorDescription: "Only code response type is supported.",
State: form.State,
}, form.RedirectURI)
return
}
// pkce support
switch form.CodeChallengeMethod {
case "S256":
case "plain":
if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeServerError,
ErrorDescription: "cannot set code challenge method",
State: form.State,
}, form.RedirectURI)
return
}
if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeServerError,
ErrorDescription: "cannot set code challenge",
State: form.State,
}, form.RedirectURI)
return
}
// Here we're just going to try to release the session early
if err := ctx.Session.Release(); err != nil {
// we'll tolerate errors here as they *should* get saved elsewhere
log.Error("Unable to save changes to the session: %v", err)
}
case "":
// "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
// https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
if !app.ConfidentialClient {
// "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeInvalidRequest,
ErrorDescription: "PKCE is required for public clients",
State: form.State,
}, form.RedirectURI)
return
}
default:
// "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
// https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeInvalidRequest,
ErrorDescription: "unsupported code challenge method",
State: form.State,
}, form.RedirectURI)
return
}
grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
// 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 {
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
redirect, err := code.GenerateRedirectURI(form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
// Update nonce to reflect the new session
if len(form.Nonce) > 0 {
err := grant.SetNonce(ctx, form.Nonce)
if err != nil {
log.Error("Unable to update nonce: %v", err)
}
}
ctx.Redirect(redirect.String())
return
}
// show authorize page to grant access
ctx.Data["Application"] = app
ctx.Data["RedirectURI"] = form.RedirectURI
ctx.Data["State"] = form.State
ctx.Data["Scope"] = form.Scope
ctx.Data["Nonce"] = form.Nonce
if user != nil {
ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">@%s</a>`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
} else {
ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
}
ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("<strong>" + html.EscapeString(form.RedirectURI) + "</strong>")
// TODO document SESSION <=> FORM
err = ctx.Session.Set("client_id", app.ClientID)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
err = ctx.Session.Set("redirect_uri", form.RedirectURI)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
err = ctx.Session.Set("state", form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
log.Error(err.Error())
return
}
// Here we're just going to try to release the session early
if err := ctx.Session.Release(); err != nil {
// we'll tolerate errors here as they *should* get saved elsewhere
log.Error("Unable to save changes to the session: %v", err)
}
ctx.HTML(http.StatusOK, tplGrantAccess)
}
// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
func GrantApplicationOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.GrantApplicationForm)
if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
ctx.Session.Get("redirect_uri") != form.RedirectURI {
ctx.Error(http.StatusBadRequest)
return
}
if !form.Granted {
handleAuthorizeError(ctx, AuthorizeError{
State: form.State,
ErrorDescription: "the request is denied",
ErrorCode: ErrorCodeAccessDenied,
}, form.RedirectURI)
return
}
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
ctx.ServerError("GetOAuth2ApplicationByClientID", err)
return
}
grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
if grant == nil {
grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope)
if err != nil {
handleAuthorizeError(ctx, AuthorizeError{
State: form.State,
ErrorDescription: "cannot create grant for user",
ErrorCode: ErrorCodeServerError,
}, form.RedirectURI)
return
}
} 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 len(form.Nonce) > 0 {
err := grant.SetNonce(ctx, form.Nonce)
if err != nil {
log.Error("Unable to update nonce: %v", err)
}
}
var codeChallenge, codeChallengeMethod string
codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string)
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
redirect, err := code.GenerateRedirectURI(form.State)
if err != nil {
handleServerError(ctx, form.State, form.RedirectURI)
return
}
ctx.Redirect(redirect.String(), http.StatusSeeOther)
}
// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
func OIDCWellKnown(ctx *context.Context) {
ctx.Data["SigningKey"] = oauth2_provider.DefaultSigningKey
ctx.JSONTemplate("user/auth/oidc_wellknown")
}
// OIDCKeys generates the JSON Web Key Set
func OIDCKeys(ctx *context.Context) {
jwk, err := oauth2_provider.DefaultSigningKey.ToJWK()
if err != nil {
log.Error("Error converting signing key to JWK: %v", err)
ctx.Error(http.StatusInternalServerError)
return
}
jwk["use"] = "sig"
jwks := map[string][]map[string]string{
"keys": {
jwk,
},
}
ctx.Resp.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(ctx.Resp)
if err := enc.Encode(jwks); err != nil {
log.Error("Failed to encode representation as json. Error: %v", err)
}
}
// AccessTokenOAuth manages all access token requests by the client
func AccessTokenOAuth(ctx *context.Context) {
form := *web.GetForm(ctx).(*forms.AccessTokenForm)
// if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
if form.ClientID == "" || form.ClientSecret == "" {
authHeader := ctx.Req.Header.Get("Authorization")
if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
clientID, clientSecret, err := base.BasicAuthDecode(authData)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot parse basic auth header",
})
return
}
// validate that any fields present in the form match the Basic auth header
if form.ClientID != "" && form.ClientID != clientID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "client_id in request body inconsistent with Authorization header",
})
return
}
form.ClientID = clientID
if form.ClientSecret != "" && form.ClientSecret != clientSecret {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "client_secret in request body inconsistent with Authorization header",
})
return
}
form.ClientSecret = clientSecret
}
}
serverKey := oauth2_provider.DefaultSigningKey
clientKey := serverKey
if serverKey.IsSymmetric() {
var err error
clientKey, err = oauth2_provider.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "Error creating signing key",
})
return
}
}
switch form.GrantType {
case "refresh_token":
handleRefreshToken(ctx, form, serverKey, clientKey)
case "authorization_code":
handleAuthorizationCode(ctx, form, serverKey, clientKey)
default:
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnsupportedGrantType,
ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
})
}
}
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2_provider.JWTSigningKey) {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient,
ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
})
return
}
// "The authorization server MUST ... require client authentication for confidential clients"
// https://datatracker.ietf.org/doc/html/rfc6749#section-6
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
errorDescription := "invalid client secret"
if form.ClientSecret == "" {
errorDescription = "invalid empty client secret"
}
// "invalid_client ... Client authentication failed"
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient,
ErrorDescription: errorDescription,
})
return
}
token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unable to parse refresh token",
})
return
}
// get grant before increasing counter
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
if err != nil || grant == nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "grant does not exist",
})
return
}
// check if token got already used
if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "token was already used",
})
log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
return
}
accessToken, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, grant, serverKey, clientKey)
if tokenErr != nil {
handleAccessTokenError(ctx, *tokenErr)
return
}
ctx.JSON(http.StatusOK, accessToken)
}
func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2_provider.JWTSigningKey) {
app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
if err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidClient,
ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
})
return
}
if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
errorDescription := "invalid client secret"
if form.ClientSecret == "" {
errorDescription = "invalid empty client secret"
}
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: errorDescription,
})
return
}
if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "unexpected redirect URI",
})
return
}
authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
if err != nil || authorizationCode == nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "client is not authorized",
})
return
}
// check if code verifier authorizes the client, PKCE support
if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient,
ErrorDescription: "failed PKCE code challenge",
})
return
}
// check if granted for this application
if authorizationCode.Grant.ApplicationID != app.ID {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "invalid grant",
})
return
}
// remove token from database to deny duplicate usage
if err := authorizationCode.Invalidate(ctx); err != nil {
handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{
ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot proceed your request",
})
}
resp, tokenErr := oauth2_provider.NewAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
if tokenErr != nil {
handleAccessTokenError(ctx, *tokenErr)
return
}
// send successful response
ctx.JSON(http.StatusOK, resp)
}
func handleAccessTokenError(ctx *context.Context, acErr oauth2_provider.AccessTokenError) {
ctx.JSON(http.StatusBadRequest, acErr)
}
func handleServerError(ctx *context.Context, state, redirectURI string) {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeServerError,
ErrorDescription: "A server error occurred",
State: state,
}, redirectURI)
}
func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
if redirectURI == "" {
log.Warn("Authorization failed: %v", authErr.ErrorDescription)
ctx.Data["Error"] = authErr
ctx.HTML(http.StatusBadRequest, tplGrantError)
return
}
redirect, err := url.Parse(redirectURI)
if err != nil {
ctx.ServerError("url.Parse", err)
return
}
q := redirect.Query()
q.Set("error", string(authErr.ErrorCode))
q.Set("error_description", authErr.ErrorDescription)
q.Set("state", authErr.State)
redirect.RawQuery = q.Encode()
ctx.Redirect(redirect.String(), http.StatusSeeOther)
}

View File

@ -11,22 +11,22 @@ import (
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
)
func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2.OIDCToken {
signingKey, err := oauth2.CreateJWTSigningKey("HS256", make([]byte, 32))
func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2_provider.OIDCToken {
signingKey, err := oauth2_provider.CreateJWTSigningKey("HS256", make([]byte, 32))
assert.NoError(t, err)
assert.NotNil(t, signingKey)
response, terr := newAccessTokenResponse(db.DefaultContext, grant, signingKey, signingKey)
response, terr := oauth2_provider.NewAccessTokenResponse(db.DefaultContext, grant, signingKey, signingKey)
assert.Nil(t, terr)
assert.NotNil(t, response)
parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2.OIDCToken{}, func(token *jwt.Token) (any, error) {
parsedToken, err := jwt.ParseWithClaims(response.IDToken, &oauth2_provider.OIDCToken{}, func(token *jwt.Token) (any, error) {
assert.NotNil(t, token.Method)
assert.Equal(t, signingKey.SigningMethod().Alg(), token.Method.Alg())
return signingKey.VerifyKey(), nil
@ -34,7 +34,7 @@ func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2.OIDCToke
assert.NoError(t, err)
assert.True(t, parsedToken.Valid)
oidcToken, ok := parsedToken.Claims.(*oauth2.OIDCToken)
oidcToken, ok := parsedToken.Claims.(*oauth2_provider.OIDCToken)
assert.True(t, ok)
assert.NotNil(t, oidcToken)

View File

@ -89,7 +89,7 @@ func Branches(ctx *context.Context) {
pager := context.NewPagination(int(branchesCount), pageSize, page, 5)
pager.SetDefaultParams(ctx)
ctx.Data["Page"] = pager
ctx.Data["LicenseFileName"] = repo_service.LicenseFileName
ctx.HTML(http.StatusOK, tplBranch)
}

View File

@ -29,7 +29,7 @@ import (
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/gitdiff"
git_service "code.gitea.io/gitea/services/repository"
repo_service "code.gitea.io/gitea/services/repository"
)
const (
@ -101,7 +101,7 @@ func Commits(ctx *context.Context) {
pager := context.NewPagination(int(commitsCount), pageSize, page, 5)
pager.SetDefaultParams(ctx)
ctx.Data["Page"] = pager
ctx.Data["LicenseFileName"] = repo_service.LicenseFileName
ctx.HTML(http.StatusOK, tplCommits)
}
@ -218,6 +218,8 @@ func SearchCommits(ctx *context.Context) {
}
ctx.Data["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name
ctx.Data["RefName"] = ctx.Repo.RefName
ctx.Data["LicenseFileName"] = repo_service.LicenseFileName
ctx.HTML(http.StatusOK, tplCommits)
}
@ -263,12 +265,12 @@ func FileHistory(ctx *context.Context) {
pager := context.NewPagination(int(commitsCount), setting.Git.CommitsRangeSize, page, 5)
pager.SetDefaultParams(ctx)
ctx.Data["Page"] = pager
ctx.Data["LicenseFileName"] = repo_service.LicenseFileName
ctx.HTML(http.StatusOK, tplCommits)
}
func LoadBranchesAndTags(ctx *context.Context) {
response, err := git_service.LoadBranchesAndTags(ctx, ctx.Repo, ctx.PathParam("sha"))
response, err := repo_service.LoadBranchesAndTags(ctx, ctx.Repo, ctx.PathParam("sha"))
if err == nil {
ctx.JSON(http.StatusOK, response)
return

View File

@ -289,7 +289,6 @@ func releasesOrTagsFeed(ctx *context.Context, isReleasesOnly bool, formatType st
// SingleRelease renders a single release's page
func SingleRelease(ctx *context.Context) {
ctx.Data["PageIsReleaseList"] = true
ctx.Data["DefaultBranch"] = ctx.Repo.Repository.DefaultBranch
writeAccess := ctx.Repo.CanWrite(unit.TypeReleases)
ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived

View File

@ -51,6 +51,7 @@ import (
"code.gitea.io/gitea/routers/web/feed"
"code.gitea.io/gitea/services/context"
issue_service "code.gitea.io/gitea/services/issue"
repo_service "code.gitea.io/gitea/services/repository"
files_service "code.gitea.io/gitea/services/repository/files"
"github.com/nektos/act/pkg/model"
@ -1077,6 +1078,7 @@ func renderHomeCode(ctx *context.Context) {
ctx.Data["TreeLink"] = treeLink
ctx.Data["TreeNames"] = treeNames
ctx.Data["BranchLink"] = branchLink
ctx.Data["LicenseFileName"] = repo_service.LicenseFileName
ctx.HTML(http.StatusOK, tplRepoHome)
}

View File

@ -17,7 +17,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/oauth2_provider"
)
// Ensure the struct implements the interface.
@ -31,7 +31,7 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
if !strings.Contains(accessToken, ".") {
return 0
}
token, err := oauth2.ParseToken(accessToken, oauth2.DefaultSigningKey)
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
if err != nil {
log.Trace("oauth2.ParseToken: %v", err)
return 0
@ -40,7 +40,7 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) int64 {
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
return 0
}
if token.Type != oauth2.TypeAccessToken {
if token.Kind != oauth2_provider.KindAccessToken {
return 0
}
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {

View File

@ -30,10 +30,6 @@ const ProviderHeaderKey = "gitea-oauth2-provider"
// Init initializes the oauth source
func Init(ctx context.Context) error {
if err := InitSigningKey(); err != nil {
return err
}
// Lock our mutex
gothRWMutex.Lock()

View File

@ -404,6 +404,13 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
ctx.Data["PushMirrors"] = pushMirrors
ctx.Data["RepoName"] = ctx.Repo.Repository.Name
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty
repoLicenses, err := repo_model.GetRepoLicenses(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GetRepoLicenses", err)
return
}
ctx.Data["DetectedRepoLicenses"] = repoLicenses.StringList()
}
// RepoAssignment returns a middleware to handle repository assignment

View File

@ -175,6 +175,11 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
language = repo.PrimaryLanguage.Language
}
repoLicenses, err := repo_model.GetRepoLicenses(ctx, repo)
if err != nil {
return nil
}
repoAPIURL := repo.APIURL()
return &api.Repository{
@ -238,6 +243,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
RepoTransfer: transfer,
Topics: repo.Topics,
ObjectFormatName: repo.ObjectFormatName,
Licenses: repoLicenses.StringList(),
}
}

View File

@ -156,6 +156,16 @@ func registerCleanupPackages() {
})
}
func registerSyncRepoLicenses() {
RegisterTaskFatal("sync_repo_licenses", &BaseConfig{
Enabled: false,
RunAtStart: false,
Schedule: "@annually",
}, func(ctx context.Context, _ *user_model.User, config Config) error {
return repo_service.SyncRepoLicenses(ctx)
})
}
func initBasicTasks() {
if setting.Mirror.Enabled {
registerUpdateMirrorTask()
@ -172,4 +182,5 @@ func initBasicTasks() {
if setting.Packages.Enabled {
registerCleanupPackages()
}
registerSyncRepoLicenses()
}

View File

@ -26,6 +26,7 @@ import (
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
repo_service "code.gitea.io/gitea/services/repository"
"github.com/stretchr/testify/assert"
)
@ -302,6 +303,8 @@ func TestGiteaUploadUpdateGitForPullRequest(t *testing.T) {
toRepoName := "migrated"
uploader := NewGiteaLocalUploader(context.Background(), fromRepoOwner, fromRepoOwner.Name, toRepoName)
uploader.gitServiceType = structs.GiteaService
assert.NoError(t, repo_service.Init(context.Background()))
assert.NoError(t, uploader.CreateRepo(&base.Repository{
Description: "description",
OriginalURL: fromRepo.RepoPath(),

View File

@ -24,6 +24,7 @@ import (
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
repo_service "code.gitea.io/gitea/services/repository"
)
// gitShortEmptySha Git short empty SHA
@ -559,6 +560,14 @@ func SyncPullMirror(ctx context.Context, repoID int64) bool {
}
}
// Update License
if err = repo_service.AddRepoToLicenseUpdaterQueue(&repo_service.LicenseUpdaterOptions{
RepoID: m.Repo.ID,
}); err != nil {
log.Error("SyncMirrors [repo: %-v]: unable to add repo to license updater queue: %v", m.Repo, err)
return false
}
log.Trace("SyncMirrors [repo: %-v]: Successfully updated", m.Repo)
return true

View File

@ -0,0 +1,214 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2_provider //nolint
import (
"context"
"fmt"
auth "code.gitea.io/gitea/models/auth"
org_model "code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"github.com/golang-jwt/jwt/v5"
)
// AccessTokenErrorCode represents an error code specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
type AccessTokenErrorCode string
const (
// AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
// AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidClient = "invalid_client"
// AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidGrant = "invalid_grant"
// AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
// AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
// AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
AccessTokenErrorCodeInvalidScope = "invalid_scope"
)
// AccessTokenError represents an error response specified in RFC 6749
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
type AccessTokenError struct {
ErrorCode AccessTokenErrorCode `json:"error" form:"error"`
ErrorDescription string `json:"error_description"`
}
// Error returns the error message
func (err AccessTokenError) Error() string {
return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
}
// TokenType specifies the kind of token
type TokenType string
const (
// TokenTypeBearer represents a token type specified in RFC 6749
TokenTypeBearer TokenType = "bearer"
// TokenTypeMAC represents a token type specified in RFC 6749
TokenTypeMAC = "mac"
)
// AccessTokenResponse represents a successful access token response
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType TokenType `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token,omitempty"`
}
func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, serverKey, clientKey JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
if setting.OAuth2.InvalidateRefreshTokens {
if err := grant.IncreaseCounter(ctx); err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidGrant,
ErrorDescription: "cannot increase the grant counter",
}
}
}
// generate access token to access the API
expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
accessToken := &Token{
GrantID: grant.ID,
Kind: KindAccessToken,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
},
}
signedAccessToken, err := accessToken.SignToken(serverKey)
if err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot sign token",
}
}
// generate refresh token to request an access token after it expired later
refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime()
refreshToken := &Token{
GrantID: grant.ID,
Counter: grant.Counter,
Kind: KindRefreshToken,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(refreshExpirationDate),
},
}
signedRefreshToken, err := refreshToken.SignToken(serverKey)
if err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot sign token",
}
}
// generate OpenID Connect id_token
signedIDToken := ""
if grant.ScopeContains("openid") {
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
if err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot find application",
}
}
user, err := user_model.GetUserByID(ctx, grant.UserID)
if err != nil {
if user_model.IsErrUserNotExist(err) {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot find user",
}
}
log.Error("Error loading user: %v", err)
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "server error",
}
}
idToken := &OIDCToken{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
Issuer: setting.AppURL,
Audience: []string{app.ClientID},
Subject: fmt.Sprint(grant.UserID),
},
Nonce: grant.Nonce,
}
if grant.ScopeContains("profile") {
idToken.Name = user.GetDisplayName()
idToken.PreferredUsername = user.Name
idToken.Profile = user.HTMLURL()
idToken.Picture = user.AvatarLink(ctx)
idToken.Website = user.Website
idToken.Locale = user.Language
idToken.UpdatedAt = user.UpdatedUnix
}
if grant.ScopeContains("email") {
idToken.Email = user.Email
idToken.EmailVerified = user.IsActive
}
if grant.ScopeContains("groups") {
groups, err := GetOAuthGroupsForUser(ctx, user)
if err != nil {
log.Error("Error getting groups: %v", err)
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "server error",
}
}
idToken.Groups = groups
}
signedIDToken, err = idToken.SignToken(clientKey)
if err != nil {
return nil, &AccessTokenError{
ErrorCode: AccessTokenErrorCodeInvalidRequest,
ErrorDescription: "cannot sign token",
}
}
}
return &AccessTokenResponse{
AccessToken: signedAccessToken,
TokenType: TokenTypeBearer,
ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
RefreshToken: signedRefreshToken,
IDToken: signedIDToken,
}, nil
}
// returns a list of "org" and "org:team" strings,
// that the given user is a part of.
func GetOAuthGroupsForUser(ctx context.Context, user *user_model.User) ([]string, error) {
orgs, err := org_model.GetUserOrgsList(ctx, user)
if err != nil {
return nil, fmt.Errorf("GetUserOrgList: %w", err)
}
var groups []string
for _, org := range orgs {
groups = append(groups, org.Name)
teams, err := org.LoadTeams(ctx)
if err != nil {
return nil, fmt.Errorf("LoadTeams: %w", err)
}
for _, team := range teams {
if team.IsMember(ctx, user.ID) {
groups = append(groups, org.Name+":"+team.LowerName)
}
}
}
return groups, nil
}

View File

@ -0,0 +1,19 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2_provider //nolint
import (
"context"
"code.gitea.io/gitea/modules/setting"
)
// Init initializes the oauth source
func Init(ctx context.Context) error {
if !setting.OAuth2.Enabled {
return nil
}
return InitSigningKey()
}

View File

@ -1,7 +1,7 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
package oauth2_provider //nolint
import (
"crypto/ecdsa"

View File

@ -1,7 +1,7 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package oauth2
package oauth2_provider //nolint
import (
"fmt"
@ -12,29 +12,22 @@ import (
"github.com/golang-jwt/jwt/v5"
)
// ___________ __
// \__ ___/___ | | __ ____ ____
// | | / _ \| |/ // __ \ / \
// | |( <_> ) <\ ___/| | \
// |____| \____/|__|_ \\___ >___| /
// \/ \/ \/
// Token represents an Oauth grant
// TokenType represents the type of token for an oauth application
type TokenType int
// TokenKind represents the type of token for an oauth application
type TokenKind int
const (
// TypeAccessToken is a token with short lifetime to access the api
TypeAccessToken TokenType = 0
// TypeRefreshToken is token with long lifetime to refresh access tokens obtained by the client
TypeRefreshToken = iota
// KindAccessToken is a token with short lifetime to access the api
KindAccessToken TokenKind = 0
// KindRefreshToken is token with long lifetime to refresh access tokens obtained by the client
KindRefreshToken = iota
)
// Token represents a JWT token used to authenticate a client
type Token struct {
GrantID int64 `json:"gnt"`
Type TokenType `json:"tt"`
Kind TokenKind `json:"tt"`
Counter int64 `json:"cnt,omitempty"`
jwt.RegisteredClaims
}

View File

@ -1013,6 +1013,8 @@ type CommitInfo struct {
}
// GetPullCommits returns all commits on given pull request and the last review commit sha
// Attention: The last review commit sha must be from the latest review whose commit id is not empty.
// So the type of the latest review cannot be "ReviewTypeRequest".
func GetPullCommits(ctx *gitea_context.Context, issue *issues_model.Issue) ([]CommitInfo, string, error) {
pull := issue.PullRequest
@ -1058,7 +1060,11 @@ func GetPullCommits(ctx *gitea_context.Context, issue *issues_model.Issue) ([]Co
lastreview, err := issues_model.FindLatestReviews(ctx, issues_model.FindReviewOptions{
IssueID: issue.ID,
ReviewerID: ctx.Doer.ID,
Type: issues_model.ReviewTypeUnknown,
Types: []issues_model.ReviewType{
issues_model.ReviewTypeApprove,
issues_model.ReviewTypeComment,
issues_model.ReviewTypeReject,
},
})
if err != nil && !issues_model.IsErrReviewNotExist(err) {

View File

@ -348,7 +348,7 @@ func DismissApprovalReviews(ctx context.Context, doer *user_model.User, pull *is
reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{
ListOptions: db.ListOptionsAll,
IssueID: pull.IssueID,
Type: issues_model.ReviewTypeApprove,
Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove},
Dismissed: optional.Some(false),
})
if err != nil {

View File

@ -616,6 +616,14 @@ func SetRepoDefaultBranch(ctx context.Context, repo *repo_model.Repository, gitR
return err
}
if !repo.IsEmpty {
if err := AddRepoToLicenseUpdaterQueue(&LicenseUpdaterOptions{
RepoID: repo.ID,
}); err != nil {
log.Error("AddRepoToLicenseUpdaterQueue: %v", err)
}
}
notify_service.ChangeDefaultBranch(ctx, repo)
return nil

View File

@ -303,6 +303,25 @@ func CreateRepositoryDirectly(ctx context.Context, doer, u *user_model.User, opt
rollbackRepo.OwnerID = u.ID
return fmt.Errorf("CreateRepository(git update-server-info): %w", err)
}
// update licenses
var licenses []string
if len(opts.License) > 0 {
licenses = append(licenses, ConvertLicenseName(opts.License))
stdout, _, err := git.NewCommand(ctx, "rev-parse", "HEAD").
SetDescription(fmt.Sprintf("CreateRepository(git rev-parse HEAD): %s", repoPath)).
RunStdString(&git.RunOpts{Dir: repoPath})
if err != nil {
log.Error("CreateRepository(git rev-parse HEAD) in %v: Stdout: %s\nError: %v", repo, stdout, err)
rollbackRepo = repo
rollbackRepo.OwnerID = u.ID
return fmt.Errorf("CreateRepository(git rev-parse HEAD): %w", err)
}
if err := repo_model.UpdateRepoLicenses(ctx, repo, stdout, licenses); err != nil {
return err
}
}
return nil
}); err != nil {
if rollbackRepo != nil {

View File

@ -140,6 +140,7 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID
&git_model.Branch{RepoID: repoID},
&git_model.LFSLock{RepoID: repoID},
&repo_model.LanguageStat{RepoID: repoID},
&repo_model.RepoLicense{RepoID: repoID},
&issues_model.Milestone{RepoID: repoID},
&repo_model.Mirror{RepoID: repoID},
&activities_model.Notification{RepoID: repoID},

View File

@ -198,6 +198,9 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
if err := repo_model.CopyLanguageStat(ctx, opts.BaseRepo, repo); err != nil {
log.Error("Copy language stat from oldRepo failed: %v", err)
}
if err := repo_model.CopyLicense(ctx, opts.BaseRepo, repo); err != nil {
return nil, err
}
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {

View File

@ -0,0 +1,205 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"context"
"fmt"
"io"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/options"
"code.gitea.io/gitea/modules/queue"
licenseclassifier "github.com/google/licenseclassifier/v2"
)
var (
classifier *licenseclassifier.Classifier
LicenseFileName = "LICENSE"
licenseAliases map[string]string
// licenseUpdaterQueue represents a queue to handle update repo licenses
licenseUpdaterQueue *queue.WorkerPoolQueue[*LicenseUpdaterOptions]
)
func AddRepoToLicenseUpdaterQueue(opts *LicenseUpdaterOptions) error {
if opts == nil {
return nil
}
return licenseUpdaterQueue.Push(opts)
}
func loadLicenseAliases() error {
if licenseAliases != nil {
return nil
}
data, err := options.AssetFS().ReadFile("license", "etc", "license-aliases.json")
if err != nil {
return err
}
err = json.Unmarshal(data, &licenseAliases)
if err != nil {
return err
}
return nil
}
func ConvertLicenseName(name string) string {
if err := loadLicenseAliases(); err != nil {
return name
}
v, ok := licenseAliases[name]
if ok {
return v
}
return name
}
func InitLicenseClassifier() error {
// threshold should be 0.84~0.86 or the test will be failed
classifier = licenseclassifier.NewClassifier(.85)
licenseFiles, err := options.AssetFS().ListFiles("license", true)
if err != nil {
return err
}
existLicense := make(container.Set[string])
if len(licenseFiles) > 0 {
for _, licenseFile := range licenseFiles {
licenseName := ConvertLicenseName(licenseFile)
if existLicense.Contains(licenseName) {
continue
}
existLicense.Add(licenseName)
data, err := options.License(licenseFile)
if err != nil {
return err
}
classifier.AddContent("License", licenseFile, licenseName, data)
}
}
return nil
}
type LicenseUpdaterOptions struct {
RepoID int64
}
func repoLicenseUpdater(items ...*LicenseUpdaterOptions) []*LicenseUpdaterOptions {
ctx := graceful.GetManager().ShutdownContext()
for _, opts := range items {
repo, err := repo_model.GetRepositoryByID(ctx, opts.RepoID)
if err != nil {
log.Error("repoLicenseUpdater [%d] failed: GetRepositoryByID: %v", opts.RepoID, err)
continue
}
if repo.IsEmpty {
continue
}
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
log.Error("repoLicenseUpdater [%d] failed: OpenRepository: %v", opts.RepoID, err)
continue
}
defer gitRepo.Close()
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
if err != nil {
log.Error("repoLicenseUpdater [%d] failed: GetBranchCommit: %v", opts.RepoID, err)
continue
}
if err = UpdateRepoLicenses(ctx, repo, commit); err != nil {
log.Error("repoLicenseUpdater [%d] failed: updateRepoLicenses: %v", opts.RepoID, err)
}
}
return nil
}
func SyncRepoLicenses(ctx context.Context) error {
log.Trace("Doing: SyncRepoLicenses")
if err := db.Iterate(
ctx,
nil,
func(ctx context.Context, repo *repo_model.Repository) error {
select {
case <-ctx.Done():
return db.ErrCancelledf("before sync repo licenses for %s", repo.FullName())
default:
}
return AddRepoToLicenseUpdaterQueue(&LicenseUpdaterOptions{RepoID: repo.ID})
},
); err != nil {
log.Trace("Error: SyncRepoLicenses: %v", err)
return err
}
log.Trace("Finished: SyncReposLicenses")
return nil
}
// UpdateRepoLicenses will update repository licenses col if license file exists
func UpdateRepoLicenses(ctx context.Context, repo *repo_model.Repository, commit *git.Commit) error {
if commit == nil {
return nil
}
b, err := commit.GetBlobByPath(LicenseFileName)
if err != nil && !git.IsErrNotExist(err) {
return fmt.Errorf("GetBlobByPath: %w", err)
}
if git.IsErrNotExist(err) {
return repo_model.CleanRepoLicenses(ctx, repo)
}
licenses := make([]string, 0)
if b != nil {
r, err := b.DataAsync()
if err != nil {
return err
}
defer r.Close()
licenses, err = detectLicense(r)
if err != nil {
return fmt.Errorf("detectLicense: %w", err)
}
}
return repo_model.UpdateRepoLicenses(ctx, repo, commit.ID.String(), licenses)
}
// detectLicense returns the licenses detected by the given content buff
func detectLicense(r io.Reader) ([]string, error) {
if r == nil {
return nil, nil
}
matches, err := classifier.MatchFrom(r)
if err != nil {
return nil, err
}
if len(matches.Matches) > 0 {
results := make(container.Set[string], len(matches.Matches))
for _, r := range matches.Matches {
if r.MatchType == "License" && !results.Contains(r.Variant) {
results.Add(r.Variant)
}
}
return results.Values(), nil
}
return nil, nil
}

View File

@ -0,0 +1,73 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"fmt"
"strings"
"testing"
repo_module "code.gitea.io/gitea/modules/repository"
"github.com/stretchr/testify/assert"
)
func Test_detectLicense(t *testing.T) {
type DetectLicenseTest struct {
name string
arg string
want []string
}
tests := []DetectLicenseTest{
{
name: "empty",
arg: "",
want: nil,
},
{
name: "no detected license",
arg: "Copyright (c) 2023 Gitea",
want: nil,
},
}
repo_module.LoadRepoConfig()
err := loadLicenseAliases()
assert.NoError(t, err)
for _, licenseName := range repo_module.Licenses {
license, err := repo_module.GetLicense(licenseName, &repo_module.LicenseValues{
Owner: "Gitea",
Email: "teabot@gitea.io",
Repo: "gitea",
Year: "2024",
})
assert.NoError(t, err)
tests = append(tests, DetectLicenseTest{
name: fmt.Sprintf("single license test: %s", licenseName),
arg: string(license),
want: []string{ConvertLicenseName(licenseName)},
})
}
err = InitLicenseClassifier()
assert.NoError(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
license, err := detectLicense(strings.NewReader(tt.arg))
assert.NoError(t, err)
assert.Equal(t, tt.want, license)
})
}
result, err := detectLicense(strings.NewReader(tests[2].arg + tests[3].arg + tests[4].arg))
assert.NoError(t, err)
t.Run("multiple licenses test", func(t *testing.T) {
assert.Equal(t, 3, len(result))
assert.Contains(t, result, tests[2].want[0])
assert.Contains(t, result, tests[3].want[0])
assert.Contains(t, result, tests[4].want[0])
})
}

View File

@ -172,6 +172,11 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
return repo, fmt.Errorf("StoreMissingLfsObjectsInRepository: %w", err)
}
}
// Update repo license
if err := AddRepoToLicenseUpdaterQueue(&LicenseUpdaterOptions{RepoID: repo.ID}); err != nil {
log.Error("Failed to add repo to license updater queue: %v", err)
}
}
ctx, committer, err := db.TxContext(ctx)

View File

@ -18,6 +18,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
@ -96,6 +97,12 @@ func PushCreateRepo(ctx context.Context, authUser, owner *user_model.User, repoN
// Init start repository service
func Init(ctx context.Context) error {
licenseUpdaterQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "repo_license_updater", repoLicenseUpdater)
if licenseUpdaterQueue == nil {
return fmt.Errorf("unable to create repo_license_updater queue")
}
go graceful.GetManager().RunWithCancel(licenseUpdaterQueue)
if err := repo_module.LoadRepoConfig(); err != nil {
return err
}

View File

@ -13,7 +13,12 @@
{{svg "octicon-tag"}} <b>{{ctx.Locale.PrettyNumber .NumTags}}</b> {{ctx.Locale.TrN .NumTags "repo.tag" "repo.tags"}}
</a>
{{end}}
<span class="item not-mobile" {{if not (eq .Repository.Size 0)}}data-tooltip-content="{{.Repository.SizeDetailsString}}"{{end}}>
{{if .DetectedRepoLicenses}}
<a class="item muted" href="{{.RepoLink}}/src/{{.Repository.DefaultBranch}}/{{PathEscapeSegments .LicenseFileName}}" data-tooltip-placement="top" data-tooltip-content="{{StringUtils.Join .DetectedRepoLicenses ", "}}">
{{svg "octicon-law"}} <b>{{if eq (len .DetectedRepoLicenses) 1}}{{index .DetectedRepoLicenses 0}}{{else}}{{ctx.Locale.Tr "repo.multiple_licenses"}}{{end}}</b>
</a>
{{end}}
<span class="item not-mobile" {{if not (eq .Repository.Size 0)}}data-tooltip-placement="top" data-tooltip-content="{{.Repository.SizeDetailsString}}"{{end}}>
{{$fileSizeFormatted := FileSize .Repository.Size}}{{/* the formatted string is always "{val} {unit}" */}}
{{$fileSizeFields := StringUtils.Split $fileSizeFormatted " "}}
{{svg "octicon-database"}} <b>{{ctx.Locale.PrettyNumber (index $fileSizeFields 0)}}</b> {{index $fileSizeFields 1}}

View File

@ -10640,6 +10640,42 @@
}
}
},
"/repos/{owner}/{repo}/licenses": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Get repo licenses",
"operationId": "repoGetLicenses",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/LicensesList"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/media/{filepath}": {
"get": {
"produces": [
@ -24142,6 +24178,13 @@
"type": "string",
"x-go-name": "LanguagesURL"
},
"licenses": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Licenses"
},
"link": {
"type": "string",
"x-go-name": "Link"
@ -25717,6 +25760,15 @@
}
}
},
"LicensesList": {
"description": "LicensesList",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
"MarkdownRender": {
"description": "MarkdownRender is a rendered markdown document",
"schema": {

View File

@ -0,0 +1,2 @@
x•ŽM
Â0F]çÙ 2“d&ñ*ù™`Á¶¶Æ…··Wð[>Þƒ¯®ó< ëÐ<C3AB>Æ®j!Æ&=Õ *Êž1*¢@””TS*X3WÞu©cé.êK<C3AA>Ð90E¦Äž”$h+µ„fòg<ÖÝ~_@ÞE{=,! €÷m»Ôu¾YŒ Ž„B´g8fz|ú_erkö9U]Þj~2]<¼

View File

@ -1 +1 @@
65f1bf27bc3bf70f64657658635e66094edbcb4d
90c1019714259b24fb81711d4416ac0f18667dfa

View File

@ -304,11 +304,11 @@ func TestAPICron(t *testing.T) {
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "28", resp.Header().Get("X-Total-Count"))
assert.Equal(t, "29", resp.Header().Get("X-Total-Count"))
var crons []api.Cron
DecodeJSON(t, resp, &crons)
assert.Len(t, crons, 28)
assert.Len(t, crons, 29)
})
t.Run("Execute", func(t *testing.T) {

View File

@ -38,7 +38,7 @@ func TestAPIPullReview(t *testing.T) {
var reviews []*api.PullReview
DecodeJSON(t, resp, &reviews)
if !assert.Len(t, reviews, 6) {
if !assert.Len(t, reviews, 8) {
return
}
for _, r := range reviews {

View File

@ -0,0 +1,80 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
api "code.gitea.io/gitea/modules/structs"
"github.com/stretchr/testify/assert"
)
var testLicenseContent = `
Copyright (c) 2024 Gitea
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
`
func TestAPIRepoLicense(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user2")
// Request editor page
req := NewRequest(t, "GET", "/user2/repo1/_new/master/")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
lastCommit := doc.GetInputValueByName("last_commit")
assert.NotEmpty(t, lastCommit)
// Save new file to master branch
req = NewRequestWithValues(t, "POST", "/user2/repo1/_new/master/", map[string]string{
"_csrf": doc.GetCSRF(),
"last_commit": lastCommit,
"tree_path": "LICENSE",
"content": testLicenseContent,
"commit_choice": "direct",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// let gitea update repo license
time.Sleep(time.Second)
checkRepoLicense(t, "user2", "repo1", []string{"BSD-2-Clause"})
// Change default branch
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
branchName := "DefaultBranch"
req = NewRequestWithJSON(t, "PATCH", "/api/v1/repos/user2/repo1", api.EditRepoOption{
DefaultBranch: &branchName,
}).AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusOK)
// let gitea update repo license
time.Sleep(time.Second)
checkRepoLicense(t, "user2", "repo1", []string{"MIT"})
})
}
func checkRepoLicense(t *testing.T, owner, repo string, expected []string) {
reqURL := fmt.Sprintf("/api/v1/repos/%s/%s/licenses", owner, repo)
req := NewRequest(t, "GET", reqURL)
resp := MakeRequest(t, req, http.StatusOK)
var licenses []string
DecodeJSON(t, resp, &licenses)
assert.ElementsMatch(t, expected, licenses, 0)
}

View File

@ -11,7 +11,7 @@ import (
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/routers/web/auth"
oauth2_provider "code.gitea.io/gitea/services/oauth2_provider"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
@ -177,7 +177,7 @@ func TestAccessTokenExchangeWithoutPKCE(t *testing.T) {
"code": "authcode",
})
resp := MakeRequest(t, req, http.StatusBadRequest)
parsedError := new(auth.AccessTokenError)
parsedError := new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "failed PKCE code challenge", parsedError.ErrorDescription)
@ -195,7 +195,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
})
resp := MakeRequest(t, req, http.StatusBadRequest)
parsedError := new(auth.AccessTokenError)
parsedError := new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
assert.Equal(t, "cannot load client with client id: '???'", parsedError.ErrorDescription)
@ -210,7 +210,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
})
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError = new(auth.AccessTokenError)
parsedError = new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "invalid client secret", parsedError.ErrorDescription)
@ -225,7 +225,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
})
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError = new(auth.AccessTokenError)
parsedError = new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "unexpected redirect URI", parsedError.ErrorDescription)
@ -240,7 +240,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
})
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError = new(auth.AccessTokenError)
parsedError = new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "client is not authorized", parsedError.ErrorDescription)
@ -255,7 +255,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
})
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError = new(auth.AccessTokenError)
parsedError = new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "unsupported_grant_type", string(parsedError.ErrorCode))
assert.Equal(t, "Only refresh_token or authorization_code grant type is supported", parsedError.ErrorDescription)
@ -292,7 +292,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
})
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OmJsYWJsYQ==")
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError := new(auth.AccessTokenError)
parsedError := new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "invalid client secret", parsedError.ErrorDescription)
@ -305,7 +305,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
"code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
})
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError = new(auth.AccessTokenError)
parsedError = new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
assert.Equal(t, "cannot load client with client id: ''", parsedError.ErrorDescription)
@ -319,7 +319,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
})
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError = new(auth.AccessTokenError)
parsedError = new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "invalid_request", string(parsedError.ErrorCode))
assert.Equal(t, "client_id in request body inconsistent with Authorization header", parsedError.ErrorDescription)
@ -333,7 +333,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
})
req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError = new(auth.AccessTokenError)
parsedError = new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "invalid_request", string(parsedError.ErrorCode))
assert.Equal(t, "client_secret in request body inconsistent with Authorization header", parsedError.ErrorDescription)
@ -371,7 +371,7 @@ func TestRefreshTokenInvalidation(t *testing.T) {
"refresh_token": parsed.RefreshToken,
})
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError := new(auth.AccessTokenError)
parsedError := new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
assert.Equal(t, "invalid empty client secret", parsedError.ErrorDescription)
@ -384,7 +384,7 @@ func TestRefreshTokenInvalidation(t *testing.T) {
"refresh_token": "UNEXPECTED",
})
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError = new(auth.AccessTokenError)
parsedError = new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription)
@ -414,7 +414,7 @@ func TestRefreshTokenInvalidation(t *testing.T) {
// repeat request should fail
req.Body = io.NopCloser(bytes.NewReader(bs))
resp = MakeRequest(t, req, http.StatusBadRequest)
parsedError = new(auth.AccessTokenError)
parsedError = new(oauth2_provider.AccessTokenError)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
assert.Equal(t, "token was already used", parsedError.ErrorDescription)

View File

@ -0,0 +1,34 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"net/url"
"testing"
pull_service "code.gitea.io/gitea/services/pull"
"github.com/stretchr/testify/assert"
)
func TestListPullCommits(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user5")
req := NewRequest(t, "GET", "/user2/repo1/pulls/3/commits/list")
resp := session.MakeRequest(t, req, http.StatusOK)
var pullCommitList struct {
Commits []pull_service.CommitInfo `json:"commits"`
LastReviewCommitSha string `json:"last_review_commit_sha"`
}
DecodeJSON(t, resp, &pullCommitList)
if assert.Len(t, pullCommitList.Commits, 2) {
assert.Equal(t, "5f22f7d0d95d614d25a5b68592adb345a4b5c7fd", pullCommitList.Commits[0].ID)
assert.Equal(t, "4a357436d925b5c974181ff12a994538ddc5a269", pullCommitList.Commits[1].ID)
}
assert.Equal(t, "4a357436d925b5c974181ff12a994538ddc5a269", pullCommitList.LastReviewCommitSha)
})
}