mirror of https://github.com/go-gitea/gitea.git
Merge branch 'main' into xormigrate
This commit is contained in:
commit
8adc25ac6f
|
@ -210,7 +210,7 @@ func CreateSource(ctx context.Context, source *Source) error {
|
|||
return ErrSourceAlreadyExist{source.Name}
|
||||
}
|
||||
// Synchronization is only available with LDAP for now
|
||||
if !source.IsLDAP() {
|
||||
if !source.IsLDAP() && !source.IsOAuth2() {
|
||||
source.IsSyncEnabled = false
|
||||
}
|
||||
|
||||
|
|
|
@ -160,12 +160,34 @@ func UpdateExternalUserByExternalID(ctx context.Context, external *ExternalLogin
|
|||
return err
|
||||
}
|
||||
|
||||
// EnsureLinkExternalToUser link the external user to the user
|
||||
func EnsureLinkExternalToUser(ctx context.Context, external *ExternalLoginUser) error {
|
||||
has, err := db.Exist[ExternalLoginUser](ctx, builder.Eq{
|
||||
"external_id": external.ExternalID,
|
||||
"login_source_id": external.LoginSourceID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if has {
|
||||
_, err = db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).AllCols().Update(external)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).Insert(external)
|
||||
return err
|
||||
}
|
||||
|
||||
// FindExternalUserOptions represents an options to find external users
|
||||
type FindExternalUserOptions struct {
|
||||
db.ListOptions
|
||||
Provider string
|
||||
UserID int64
|
||||
OrderBy string
|
||||
Provider string
|
||||
UserID int64
|
||||
LoginSourceID int64
|
||||
HasRefreshToken bool
|
||||
Expired bool
|
||||
OrderBy string
|
||||
}
|
||||
|
||||
func (opts FindExternalUserOptions) ToConds() builder.Cond {
|
||||
|
@ -176,9 +198,22 @@ func (opts FindExternalUserOptions) ToConds() builder.Cond {
|
|||
if opts.UserID > 0 {
|
||||
cond = cond.And(builder.Eq{"user_id": opts.UserID})
|
||||
}
|
||||
if opts.Expired {
|
||||
cond = cond.And(builder.Lt{"expires_at": time.Now()})
|
||||
}
|
||||
if opts.HasRefreshToken {
|
||||
cond = cond.And(builder.Neq{"refresh_token": ""})
|
||||
}
|
||||
if opts.LoginSourceID != 0 {
|
||||
cond = cond.And(builder.Eq{"login_source_id": opts.LoginSourceID})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts FindExternalUserOptions) ToOrders() string {
|
||||
return opts.OrderBy
|
||||
}
|
||||
|
||||
func IterateExternalLogin(ctx context.Context, opts FindExternalUserOptions, f func(ctx context.Context, u *ExternalLoginUser) error) error {
|
||||
return db.Iterate(ctx, opts.ToConds(), f)
|
||||
}
|
||||
|
|
|
@ -71,6 +71,12 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
|||
)),
|
||||
),
|
||||
)
|
||||
|
||||
if options.IsKeywordNumeric() {
|
||||
cond = cond.Or(
|
||||
builder.Eq{"`index`": options.Keyword},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
opt, err := ToDBOptions(ctx, options)
|
||||
|
|
|
@ -283,9 +283,9 @@ const (
|
|||
func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) {
|
||||
indexer := *globalIndexer.Load()
|
||||
|
||||
if opts.Keyword == "" {
|
||||
if opts.Keyword == "" || opts.IsKeywordNumeric() {
|
||||
// This is a conservative shortcut.
|
||||
// If the keyword is empty, db has better (at least not worse) performance to filter issues.
|
||||
// If the keyword is empty or an integer, db has better (at least not worse) performance to filter issues.
|
||||
// When the keyword is empty, it tends to listing rather than searching issues.
|
||||
// So if the user creates an issue and list issues immediately, the issue may not be listed because the indexer needs time to index the issue.
|
||||
// Even worse, the external indexer like elastic search may not be available for a while,
|
||||
|
|
|
@ -31,6 +31,7 @@ func TestDBSearchIssues(t *testing.T) {
|
|||
InitIssueIndexer(true)
|
||||
|
||||
t.Run("search issues with keyword", searchIssueWithKeyword)
|
||||
t.Run("search issues by index", searchIssueByIndex)
|
||||
t.Run("search issues in repo", searchIssueInRepo)
|
||||
t.Run("search issues by ID", searchIssueByID)
|
||||
t.Run("search issues is pr", searchIssueIsPull)
|
||||
|
@ -87,6 +88,43 @@ func searchIssueWithKeyword(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func searchIssueByIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
opts SearchOptions
|
||||
expectedIDs []int64
|
||||
}{
|
||||
{
|
||||
SearchOptions{
|
||||
Keyword: "1000",
|
||||
RepoIDs: []int64{1},
|
||||
},
|
||||
[]int64{},
|
||||
},
|
||||
{
|
||||
SearchOptions{
|
||||
Keyword: "2",
|
||||
RepoIDs: []int64{1, 2, 3, 32},
|
||||
},
|
||||
[]int64{17, 12, 7, 2},
|
||||
},
|
||||
{
|
||||
SearchOptions{
|
||||
Keyword: "1",
|
||||
RepoIDs: []int64{58},
|
||||
},
|
||||
[]int64{19},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
issueIDs, _, err := SearchIssues(context.TODO(), &test.opts)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, test.expectedIDs, issueIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func searchIssueInRepo(t *testing.T) {
|
||||
tests := []struct {
|
||||
opts SearchOptions
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
@ -124,6 +126,12 @@ func (o *SearchOptions) Copy(edit ...func(options *SearchOptions)) *SearchOption
|
|||
return &v
|
||||
}
|
||||
|
||||
// used for optimized issue index based search
|
||||
func (o *SearchOptions) IsKeywordNumeric() bool {
|
||||
_, err := strconv.Atoi(o.Keyword)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type SortBy string
|
||||
|
||||
const (
|
||||
|
|
|
@ -1927,6 +1927,7 @@ milestones.edit_success=Milník „%s“ byl aktualizován.
|
|||
milestones.deletion=Smazat milník
|
||||
milestones.deletion_desc=Odstranění milníku jej smaže ze všech souvisejících úkolů. Pokračovat?
|
||||
milestones.deletion_success=Milník byl odstraněn.
|
||||
milestones.filter_sort.name=Název
|
||||
milestones.filter_sort.earliest_due_data=Nejstarší datum dokončení
|
||||
milestones.filter_sort.latest_due_date=Nejnovější datum dokončení
|
||||
milestones.filter_sort.least_complete=Nejméně dokončené
|
||||
|
|
|
@ -1907,6 +1907,7 @@ milestones.edit_success=Meilenstein "%s" wurde aktualisiert.
|
|||
milestones.deletion=Meilenstein löschen
|
||||
milestones.deletion_desc=Das Löschen des Meilensteins entfernt ihn von allen Issues. Fortfahren?
|
||||
milestones.deletion_success=Der Meilenstein wurde gelöscht.
|
||||
milestones.filter_sort.name=Name
|
||||
milestones.filter_sort.earliest_due_data=Frühestes Fälligkeitsdatum
|
||||
milestones.filter_sort.latest_due_date=Spätestes Fälligkeitsdatum
|
||||
milestones.filter_sort.least_complete=Am wenigsten vollständig
|
||||
|
|
|
@ -1826,6 +1826,7 @@ milestones.edit_success=Το ορόσημο "%s" ενημερώθηκε.
|
|||
milestones.deletion=Διαγραφή Ορόσημου
|
||||
milestones.deletion_desc=Η διαγραφή ενός ορόσημου το αφαιρεί από όλα τα συναφή ζητήματα. Συνέχεια;
|
||||
milestones.deletion_success=Το ορόσημο έχει διαγραφεί.
|
||||
milestones.filter_sort.name=Όνομα
|
||||
milestones.filter_sort.earliest_due_data=Πλησιέστερη παράδοση
|
||||
milestones.filter_sort.latest_due_date=Απώτερη παράδοση
|
||||
milestones.filter_sort.least_complete=Λιγότερο πλήρη
|
||||
|
|
|
@ -1812,6 +1812,7 @@ milestones.edit_success=Se ha actualizado el hito "%s".
|
|||
milestones.deletion=Eliminar hito
|
||||
milestones.deletion_desc=Eliminando un hito lo elimina de todos los problemas relacionados. ¿Continuar?
|
||||
milestones.deletion_success=El hito se ha eliminado.
|
||||
milestones.filter_sort.name=Nombre
|
||||
milestones.filter_sort.earliest_due_data=Fecha de vencimiento más temprana
|
||||
milestones.filter_sort.latest_due_date=Fecha de vencimiento más lejana
|
||||
milestones.filter_sort.least_complete=Menos completa
|
||||
|
|
|
@ -1389,6 +1389,7 @@ milestones.modify=به روزرسانی نقطه عطف
|
|||
milestones.deletion=حذف نقطه عطف
|
||||
milestones.deletion_desc=نقاط عطف از تمام مسائل مرتبط حذف میشوند. آیا ادامه میدهید؟
|
||||
milestones.deletion_success=نقطه عطف حذف شد.
|
||||
milestones.filter_sort.name=نام
|
||||
milestones.filter_sort.least_complete=حداقل کامل شده
|
||||
milestones.filter_sort.most_complete=بیشترین کامل شده
|
||||
milestones.filter_sort.most_issues=بیشترین مسائل
|
||||
|
|
|
@ -1009,6 +1009,7 @@ milestones.clear=Tyhjennä
|
|||
milestones.edit=Muokkaa merkkipaalua
|
||||
milestones.cancel=Peruuta
|
||||
milestones.modify=Päivitä merkkipaalu
|
||||
milestones.filter_sort.name=Nimi
|
||||
milestones.filter_sort.most_issues=Eniten ongelmia
|
||||
milestones.filter_sort.least_issues=Vähiten ongelmia
|
||||
|
||||
|
|
|
@ -1927,6 +1927,7 @@ milestones.edit_success=Le jalon "%s" a été mis à jour.
|
|||
milestones.deletion=Supprimer un Jalon
|
||||
milestones.deletion_desc=Supprimer un jalon le retire de tous les tickets. Continuer ?
|
||||
milestones.deletion_success=Le jalon a été supprimé.
|
||||
milestones.filter_sort.name=Nom
|
||||
milestones.filter_sort.earliest_due_data=Date d’échéance la plus ancienne
|
||||
milestones.filter_sort.latest_due_date=Date d’échéance la plus récente
|
||||
milestones.filter_sort.least_complete=Le moins complété
|
||||
|
|
|
@ -962,6 +962,7 @@ milestones.modify=Mérföldkő frissítése
|
|||
milestones.deletion=Mérföldkő törlése
|
||||
milestones.deletion_desc=A mérföldkő törlése eltávolítja az összes hozzárendelt hibajegyet. Biztosan folytatja?
|
||||
milestones.deletion_success=A mérföldkő törölve.
|
||||
milestones.filter_sort.name=Név
|
||||
milestones.filter_sort.least_complete=Legkevésbé befejezve
|
||||
milestones.filter_sort.most_complete=Leginkább befejezve
|
||||
milestones.filter_sort.most_issues=Legtöbb hibajegy
|
||||
|
|
|
@ -770,6 +770,7 @@ milestones.due_date=Jatuh Tempo (opsional)
|
|||
milestones.clear=Bersihkan
|
||||
milestones.edit=Ubah Milestone
|
||||
milestones.cancel=Batal
|
||||
milestones.filter_sort.name=Nama
|
||||
milestones.filter_sort.least_complete=Paling tidak lengkap
|
||||
milestones.filter_sort.most_complete=Paling lengkap
|
||||
milestones.filter_sort.most_issues=Paling banyak masalah
|
||||
|
|
|
@ -916,6 +916,7 @@ milestones.desc=Lýsing
|
|||
milestones.due_date=Eindagi (valfrjálst)
|
||||
milestones.clear=Hreinsa
|
||||
milestones.cancel=Hætta við
|
||||
milestones.filter_sort.name=Heiti
|
||||
milestones.filter_sort.most_issues=Flest vandamál
|
||||
milestones.filter_sort.least_issues=Fæst vandamál
|
||||
|
||||
|
|
|
@ -1512,6 +1512,7 @@ milestones.modify=Aggiorna pietra miliare
|
|||
milestones.deletion=Elimina pietra miliare
|
||||
milestones.deletion_desc=Eliminare una pietra miliare la rimuove da tutte le relative issue. Continuare?
|
||||
milestones.deletion_success=La pietra miliare è stata eliminata.
|
||||
milestones.filter_sort.name=Nome
|
||||
milestones.filter_sort.least_complete=Meno completato
|
||||
milestones.filter_sort.most_complete=Più completato
|
||||
milestones.filter_sort.most_issues=Più problemi
|
||||
|
|
|
@ -1933,6 +1933,7 @@ milestones.edit_success=マイルストーン "%s" を更新しました。
|
|||
milestones.deletion=マイルストーンの削除
|
||||
milestones.deletion_desc=マイルストーンを削除すると、関連するすべてのイシューから除去されます。 続行しますか?
|
||||
milestones.deletion_success=マイルストーンを削除しました。
|
||||
milestones.filter_sort.name=名称
|
||||
milestones.filter_sort.earliest_due_data=期日が早い順
|
||||
milestones.filter_sort.latest_due_date=期日が遅い順
|
||||
milestones.filter_sort.least_complete=消化率の低い順
|
||||
|
|
|
@ -875,6 +875,7 @@ milestones.modify=마일스톤 갱신
|
|||
milestones.deletion=마일스톤 삭제
|
||||
milestones.deletion_desc=마일스톤을 삭제하면 연관된 모든 이슈에서 삭제됩니다. 계속 하시겠습니까?
|
||||
milestones.deletion_success=마일스톤이 삭제되었습니다.
|
||||
milestones.filter_sort.name=이름
|
||||
milestones.filter_sort.least_complete=완료율이 낮은 순
|
||||
milestones.filter_sort.most_complete=완료율이 높은 순
|
||||
milestones.filter_sort.most_issues=이슈 많은 순
|
||||
|
|
|
@ -1828,6 +1828,7 @@ milestones.edit_success=Izmaiņas atskaites punktā "%s" tika veiksmīgi saglab
|
|||
milestones.deletion=Dzēst atskaites punktu
|
||||
milestones.deletion_desc=Dzēšot šo atskaites punktu, tas tiks noņemts no visām saistītajām problēmām un izmaiņu pieprasījumiem. Vai turpināt?
|
||||
milestones.deletion_success=Atskaites punkts tika veiksmīgi izdzēsts.
|
||||
milestones.filter_sort.name=Nosaukums
|
||||
milestones.filter_sort.earliest_due_data=Agrākais izpildes laiks
|
||||
milestones.filter_sort.latest_due_date=Vēlākais izpildes laiks
|
||||
milestones.filter_sort.least_complete=Vismazāk pabeigtais
|
||||
|
|
|
@ -1507,6 +1507,7 @@ milestones.modify=Mijlpaal bijwerken
|
|||
milestones.deletion=Mijlpaal verwijderen
|
||||
milestones.deletion_desc=Als je een mijlpaal verwijdert, wordt hij van alle gerelateerde kwesties verwijderd. Doorgaan?
|
||||
milestones.deletion_success=De mijlpaal is verwijderd.
|
||||
milestones.filter_sort.name=Naam
|
||||
milestones.filter_sort.least_complete=Minst compleet
|
||||
milestones.filter_sort.most_complete=Meest compleet
|
||||
milestones.filter_sort.most_issues=Meeste problemen
|
||||
|
|
|
@ -1360,6 +1360,7 @@ milestones.modify=Zaktualizuj cel
|
|||
milestones.deletion=Usuń kamień milowy
|
||||
milestones.deletion_desc=Usunięcie celu usuwa go z wszystkich pozostałych zagadnień. Kontynuować?
|
||||
milestones.deletion_success=Cel został usunięty.
|
||||
milestones.filter_sort.name=Nazwa
|
||||
milestones.filter_sort.least_complete=Najmniej kompletne
|
||||
milestones.filter_sort.most_complete=Najbardziej kompletne
|
||||
milestones.filter_sort.most_issues=Najwięcej zgłoszeń
|
||||
|
|
|
@ -1820,6 +1820,7 @@ milestones.edit_success=O marco "%s" foi atualizado.
|
|||
milestones.deletion=Excluir marco
|
||||
milestones.deletion_desc=A exclusão deste marco irá removê-lo de todas as issues. Tem certeza que deseja continuar?
|
||||
milestones.deletion_success=O marco foi excluído.
|
||||
milestones.filter_sort.name=Nome
|
||||
milestones.filter_sort.earliest_due_data=Data limite mais próxima
|
||||
milestones.filter_sort.latest_due_date=Data limite mais distante
|
||||
milestones.filter_sort.least_complete=Menos completo
|
||||
|
|
|
@ -477,6 +477,7 @@ activate_email=Valide o seu endereço de email
|
|||
activate_email.title=%s, por favor valide o seu endereço de email
|
||||
activate_email.text=Por favor clique na seguinte ligação para validar o seu endereço de email dentro de <b>%s</b>:
|
||||
|
||||
register_notify=Bem-vindo(a) a %s
|
||||
register_notify.title=%[1]s, bem-vindo(a) a %[2]s
|
||||
register_notify.text_1=este é o seu email de confirmação de registo para %s!
|
||||
register_notify.text_2=Agora pode iniciar a sessão com o nome de utilizador: %s.
|
||||
|
@ -1933,6 +1934,7 @@ milestones.edit_success=A etapa "%s" foi modificada.
|
|||
milestones.deletion=Eliminar etapa
|
||||
milestones.deletion_desc=Se eliminar uma etapa, irá removê-la de todas as questões relacionadas. Quer continuar?
|
||||
milestones.deletion_success=A etapa foi eliminada.
|
||||
milestones.filter_sort.name=Nome
|
||||
milestones.filter_sort.earliest_due_data=Data de vencimento mais próxima
|
||||
milestones.filter_sort.latest_due_date=Data de vencimento mais distante
|
||||
milestones.filter_sort.least_complete=Menos completo
|
||||
|
@ -2384,6 +2386,7 @@ settings.protect_enable_merge=Habilitar integração
|
|||
settings.protect_enable_merge_desc=Qualquer pessoa com permissão de escrita tem autorização para realizar neste ramo as integrações constantes nos pedidos.
|
||||
settings.protect_whitelist_committers=Lista de permissões para restringir os envios
|
||||
settings.protect_whitelist_committers_desc=Apenas os utilizadores ou equipas constantes na lista terão permissão para enviar para este ramo (mas não poderão fazer envios forçados).
|
||||
settings.protect_whitelist_deploy_keys=Lista de permissão de chaves de instalação com acesso de escrita para enviar.
|
||||
settings.protect_whitelist_users=Utilizadores com permissão para enviar:
|
||||
settings.protect_whitelist_teams=Equipas com permissão para enviar:
|
||||
settings.protect_force_push_allowlist_users=Utilizadores na lista de permissão para enviar forçadamente:
|
||||
|
|
|
@ -1789,6 +1789,7 @@ milestones.edit_success=Этап «%s» обновлён.
|
|||
milestones.deletion=Удалить этап
|
||||
milestones.deletion_desc=Удаление этапа приведет к его удалению из всех связанных задач. Продолжить?
|
||||
milestones.deletion_success=Этап успешно удалён.
|
||||
milestones.filter_sort.name=Название
|
||||
milestones.filter_sort.earliest_due_data=По возрастанию даты завершения
|
||||
milestones.filter_sort.latest_due_date=По убыванию даты завершения
|
||||
milestones.filter_sort.least_complete=Менее полное
|
||||
|
|
|
@ -1352,6 +1352,7 @@ milestones.modify=සන්ධිස්ථානයක් යාවත්කා
|
|||
milestones.deletion=සන්ධිස්ථානය මකන්න
|
||||
milestones.deletion_desc=සන්ධිස්ථානයක් මකා දැමීම සම්බන්ධ සියලු ගැටළු වලින් එය ඉවත් කරයි. දිගටම?
|
||||
milestones.deletion_success=සන්ධිස්ථානය මකා දමා ඇත.
|
||||
milestones.filter_sort.name=නම
|
||||
milestones.filter_sort.least_complete=අවම වශයෙන් සම්පූර්ණයි
|
||||
milestones.filter_sort.most_complete=වඩාත්ම සම්පූර්ණයි
|
||||
milestones.filter_sort.most_issues=බොහෝ ප්රශ්න
|
||||
|
|
|
@ -1132,6 +1132,7 @@ milestones.modify=Uppdatera milstolpe
|
|||
milestones.deletion=Ta bort milstolpe
|
||||
milestones.deletion_desc=Borttagning av en milstolpe tar bort den från samtliga relaterade ärende. Fortsätta?
|
||||
milestones.deletion_success=Milstolpen har blivit borttagen.
|
||||
milestones.filter_sort.name=Namn
|
||||
milestones.filter_sort.least_complete=Minst klar
|
||||
milestones.filter_sort.most_complete=Mest klar
|
||||
milestones.filter_sort.most_issues=Mest ärenden
|
||||
|
|
|
@ -1920,6 +1920,7 @@ milestones.edit_success=`"%s" dönüm noktası güncellendi.`
|
|||
milestones.deletion=Kilometre Taşını Sil
|
||||
milestones.deletion_desc=Bir kilometre taşını silmek, onu ilgili tüm sorunlardan kaldırır. Devam edilsin mi?
|
||||
milestones.deletion_success=Kilometre taşı silindi.
|
||||
milestones.filter_sort.name=İsim
|
||||
milestones.filter_sort.earliest_due_data=En erken bitiş tarihi
|
||||
milestones.filter_sort.latest_due_date=En uzak bitiş tarihi
|
||||
milestones.filter_sort.least_complete=En az tamamlama
|
||||
|
|
|
@ -1399,6 +1399,7 @@ milestones.modify=Оновити етап
|
|||
milestones.deletion=Видалити етап
|
||||
milestones.deletion_desc=Видалення етапу призведе до його видалення з усіх пов'язаних задач. Продовжити?
|
||||
milestones.deletion_success=Етап успішно видалено.
|
||||
milestones.filter_sort.name=Назва
|
||||
milestones.filter_sort.least_complete=Менш повне
|
||||
milestones.filter_sort.most_complete=Більш повне
|
||||
milestones.filter_sort.most_issues=Найбільш задач
|
||||
|
|
|
@ -1923,6 +1923,7 @@ milestones.edit_success=里程碑 %s 已经更新。
|
|||
milestones.deletion=删除里程碑
|
||||
milestones.deletion_desc=删除该里程碑将会移除所有工单中相关的信息。是否继续?
|
||||
milestones.deletion_success=里程碑已被删除。
|
||||
milestones.filter_sort.name=名称
|
||||
milestones.filter_sort.earliest_due_data=到期日从远到近
|
||||
milestones.filter_sort.latest_due_date=到期日从近到远
|
||||
milestones.filter_sort.least_complete=完成度从低到高
|
||||
|
|
|
@ -511,6 +511,7 @@ milestones.due_date=截止日期(可選)
|
|||
milestones.clear=清除
|
||||
milestones.edit=編輯里程碑
|
||||
milestones.cancel=取消
|
||||
milestones.filter_sort.name=組織名稱
|
||||
milestones.filter_sort.least_complete=完成度由低到高
|
||||
milestones.filter_sort.most_complete=完成度由高到低
|
||||
milestones.filter_sort.most_issues=問題由多到少
|
||||
|
|
|
@ -1656,6 +1656,7 @@ milestones.edit_success=已更新里程碑「%s」。
|
|||
milestones.deletion=刪除里程碑
|
||||
milestones.deletion_desc=刪除里程碑會從所有相關的問題移除它。是否繼續?
|
||||
milestones.deletion_success=里程碑已刪除
|
||||
milestones.filter_sort.name=名稱
|
||||
milestones.filter_sort.least_complete=完成度由低到高
|
||||
milestones.filter_sort.most_complete=完成度由高到低
|
||||
milestones.filter_sort.most_issues=問題由多到少
|
||||
|
|
|
@ -622,10 +622,8 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
|
|||
|
||||
// update external user information
|
||||
if gothUser != nil {
|
||||
if err := externalaccount.UpdateExternalUser(ctx, u, *gothUser); err != nil {
|
||||
if !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("UpdateExternalUser failed: %v", err)
|
||||
}
|
||||
if err := externalaccount.EnsureLinkExternalToUser(ctx, u, *gothUser); err != nil {
|
||||
log.Error("EnsureLinkExternalToUser failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,6 @@ import (
|
|||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
auth_service "code.gitea.io/gitea/services/auth"
|
||||
|
@ -1148,9 +1147,39 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
|
|||
|
||||
groups := getClaimedGroups(oauth2Source, &gothUser)
|
||||
|
||||
opts := &user_service.UpdateOptions{}
|
||||
|
||||
// Reactivate user if they are deactivated
|
||||
if !u.IsActive {
|
||||
opts.IsActive = optional.Some(true)
|
||||
}
|
||||
|
||||
// Update GroupClaims
|
||||
opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
|
||||
|
||||
if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
|
||||
if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
|
||||
ctx.ServerError("SyncGroupsToTeams", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := externalaccount.EnsureLinkExternalToUser(ctx, u, gothUser); err != nil {
|
||||
ctx.ServerError("EnsureLinkExternalToUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If this user is enrolled in 2FA and this source doesn't override it,
|
||||
// we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
|
||||
if !needs2FA {
|
||||
// Register last login
|
||||
opts.SetLastLogin = true
|
||||
|
||||
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
"uid": u.ID,
|
||||
"uname": u.Name,
|
||||
|
@ -1162,29 +1191,6 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
|
|||
// Clear whatever CSRF cookie has right now, force to generate a new one
|
||||
ctx.Csrf.DeleteCookie(ctx)
|
||||
|
||||
opts := &user_service.UpdateOptions{
|
||||
SetLastLogin: true,
|
||||
}
|
||||
opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
|
||||
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
|
||||
if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
|
||||
ctx.ServerError("SyncGroupsToTeams", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// update external user information
|
||||
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil {
|
||||
if !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("UpdateExternalUser failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := resetLocale(ctx, u); err != nil {
|
||||
ctx.ServerError("resetLocale", err)
|
||||
return
|
||||
|
@ -1200,22 +1206,13 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
|
|||
return
|
||||
}
|
||||
|
||||
opts := &user_service.UpdateOptions{}
|
||||
opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
|
||||
if opts.IsAdmin.Has() || opts.IsRestricted.Has() {
|
||||
if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() {
|
||||
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
|
||||
if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
|
||||
ctx.ServerError("SyncGroupsToTeams", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||
"twofaUid": u.ID,
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m, &unittest.TestOptions{})
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type fakeProvider struct{}
|
||||
|
||||
func (p *fakeProvider) Name() string {
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (p *fakeProvider) SetName(name string) {}
|
||||
|
||||
func (p *fakeProvider) BeginAuth(state string) (goth.Session, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *fakeProvider) UnmarshalSession(string) (goth.Session, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *fakeProvider) FetchUser(goth.Session) (goth.User, error) {
|
||||
return goth.User{}, nil
|
||||
}
|
||||
|
||||
func (p *fakeProvider) Debug(bool) {
|
||||
}
|
||||
|
||||
func (p *fakeProvider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
|
||||
switch refreshToken {
|
||||
case "expired":
|
||||
return nil, &oauth2.RetrieveError{
|
||||
ErrorCode: "invalid_grant",
|
||||
}
|
||||
default:
|
||||
return &oauth2.Token{
|
||||
AccessToken: "token",
|
||||
TokenType: "Bearer",
|
||||
RefreshToken: "refresh",
|
||||
Expiry: time.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fakeProvider) RefreshTokenAvailable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterGothProvider(
|
||||
NewSimpleProvider("fake", "Fake", []string{"account"},
|
||||
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return &fakeProvider{}
|
||||
}))
|
||||
}
|
|
@ -36,7 +36,7 @@ func (source *Source) FromDB(bs []byte) error {
|
|||
return json.UnmarshalHandleDoubleEncode(bs, &source)
|
||||
}
|
||||
|
||||
// ToDB exports an SMTPConfig to a serialized format.
|
||||
// ToDB exports an OAuth2Config to a serialized format.
|
||||
func (source *Source) ToDB() ([]byte, error) {
|
||||
return json.Marshal(source)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Sync causes this OAuth2 source to synchronize its users with the db.
|
||||
func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
|
||||
log.Trace("Doing: SyncExternalUsers[%s] %d", source.authSource.Name, source.authSource.ID)
|
||||
|
||||
if !updateExisting {
|
||||
log.Info("SyncExternalUsers[%s] not running since updateExisting is false", source.authSource.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
provider, err := createProvider(source.authSource.Name, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !provider.RefreshTokenAvailable() {
|
||||
log.Trace("SyncExternalUsers[%s] provider doesn't support refresh tokens, can't synchronize", source.authSource.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := user_model.FindExternalUserOptions{
|
||||
HasRefreshToken: true,
|
||||
Expired: true,
|
||||
LoginSourceID: source.authSource.ID,
|
||||
}
|
||||
|
||||
return user_model.IterateExternalLogin(ctx, opts, func(ctx context.Context, u *user_model.ExternalLoginUser) error {
|
||||
return source.refresh(ctx, provider, u)
|
||||
})
|
||||
}
|
||||
|
||||
func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *user_model.ExternalLoginUser) error {
|
||||
log.Trace("Syncing login_source_id=%d external_id=%s expiration=%s", u.LoginSourceID, u.ExternalID, u.ExpiresAt)
|
||||
|
||||
shouldDisable := false
|
||||
|
||||
token, err := provider.RefreshToken(u.RefreshToken)
|
||||
if err != nil {
|
||||
if err, ok := err.(*oauth2.RetrieveError); ok && err.ErrorCode == "invalid_grant" {
|
||||
// this signals that the token is not valid and the user should be disabled
|
||||
shouldDisable = true
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
user := &user_model.User{
|
||||
LoginName: u.ExternalID,
|
||||
LoginType: auth.OAuth2,
|
||||
LoginSource: u.LoginSourceID,
|
||||
}
|
||||
|
||||
hasUser, err := user_model.GetUser(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the grant is no longer valid, disable the user and
|
||||
// delete local tokens. If the OAuth2 provider still
|
||||
// recognizes them as a valid user, they will be able to login
|
||||
// via their provider and reactivate their account.
|
||||
if shouldDisable {
|
||||
log.Info("SyncExternalUsers[%s] disabling user %d", source.authSource.Name, user.ID)
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if hasUser {
|
||||
user.IsActive = false
|
||||
err := user_model.UpdateUserCols(ctx, user, "is_active")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete stored tokens, since they are invalid. This
|
||||
// also provents us from checking this in subsequent runs.
|
||||
u.AccessToken = ""
|
||||
u.RefreshToken = ""
|
||||
u.ExpiresAt = time.Time{}
|
||||
|
||||
return user_model.UpdateExternalUserByExternalID(ctx, u)
|
||||
})
|
||||
}
|
||||
|
||||
// Otherwise, update the tokens
|
||||
u.AccessToken = token.AccessToken
|
||||
u.ExpiresAt = token.Expiry
|
||||
|
||||
// Some providers only update access tokens provide a new
|
||||
// refresh token, so avoid updating it if it's empty
|
||||
if token.RefreshToken != "" {
|
||||
u.RefreshToken = token.RefreshToken
|
||||
}
|
||||
|
||||
err = user_model.UpdateExternalUserByExternalID(ctx, u)
|
||||
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSource(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
source := &Source{
|
||||
Provider: "fake",
|
||||
authSource: &auth.Source{
|
||||
ID: 12,
|
||||
Type: auth.OAuth2,
|
||||
Name: "fake",
|
||||
IsActive: true,
|
||||
IsSyncEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
user := &user_model.User{
|
||||
LoginName: "external",
|
||||
LoginType: auth.OAuth2,
|
||||
LoginSource: source.authSource.ID,
|
||||
Name: "test",
|
||||
Email: "external@example.com",
|
||||
}
|
||||
|
||||
err := user_model.CreateUser(context.Background(), user, &user_model.CreateUserOverwriteOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
e := &user_model.ExternalLoginUser{
|
||||
ExternalID: "external",
|
||||
UserID: user.ID,
|
||||
LoginSourceID: user.LoginSource,
|
||||
RefreshToken: "valid",
|
||||
}
|
||||
err = user_model.LinkExternalToUser(context.Background(), user, e)
|
||||
assert.NoError(t, err)
|
||||
|
||||
provider, err := createProvider(source.authSource.Name, source)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("refresh", func(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
err := source.refresh(context.Background(), provider, e)
|
||||
assert.NoError(t, err)
|
||||
|
||||
e := &user_model.ExternalLoginUser{
|
||||
ExternalID: e.ExternalID,
|
||||
LoginSourceID: e.LoginSourceID,
|
||||
}
|
||||
|
||||
ok, err := user_model.GetExternalLogin(context.Background(), e)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, e.RefreshToken, "refresh")
|
||||
assert.Equal(t, e.AccessToken, "token")
|
||||
|
||||
u, err := user_model.GetUserByID(context.Background(), user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, u.IsActive)
|
||||
})
|
||||
|
||||
t.Run("expired", func(t *testing.T) {
|
||||
err := source.refresh(context.Background(), provider, &user_model.ExternalLoginUser{
|
||||
ExternalID: "external",
|
||||
UserID: user.ID,
|
||||
LoginSourceID: user.LoginSource,
|
||||
RefreshToken: "expired",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
e := &user_model.ExternalLoginUser{
|
||||
ExternalID: e.ExternalID,
|
||||
LoginSourceID: e.LoginSourceID,
|
||||
}
|
||||
|
||||
ok, err := user_model.GetExternalLogin(context.Background(), e)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, e.RefreshToken, "")
|
||||
assert.Equal(t, e.AccessToken, "")
|
||||
|
||||
u, err := user_model.GetUserByID(context.Background(), user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, u.IsActive)
|
||||
})
|
||||
})
|
||||
}
|
|
@ -71,14 +71,14 @@ func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth
|
|||
return nil
|
||||
}
|
||||
|
||||
// UpdateExternalUser updates external user's information
|
||||
func UpdateExternalUser(ctx context.Context, user *user_model.User, gothUser goth.User) error {
|
||||
// EnsureLinkExternalToUser link the gothUser to the user
|
||||
func EnsureLinkExternalToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error {
|
||||
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return user_model.UpdateExternalUserByExternalID(ctx, externalLoginUser)
|
||||
return user_model.EnsureLinkExternalToUser(ctx, externalLoginUser)
|
||||
}
|
||||
|
||||
// UpdateMigrationsByType updates all migrated repositories' posterid from gitServiceType to replace originalAuthorID to posterID
|
||||
|
|
|
@ -412,7 +412,7 @@
|
|||
<p class="help">{{ctx.Locale.Tr "admin.auths.sspi_default_language_helper"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Source.IsLDAP}}
|
||||
{{if (or .Source.IsLDAP .Source.IsOAuth2)}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<label><strong>{{ctx.Locale.Tr "admin.auths.syncenabled"}}</strong></label>
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
<input name="attributes_in_bind" type="checkbox" {{if .attributes_in_bind}}checked{{end}}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ldap inline field {{if not (eq .type 2)}}tw-hidden{{end}}">
|
||||
<div class="oauth2 ldap inline field {{if not (or (eq .type 2) (eq .type 6))}}tw-hidden{{end}}">
|
||||
<div class="ui checkbox">
|
||||
<label><strong>{{ctx.Locale.Tr "admin.auths.syncenabled"}}</strong></label>
|
||||
<input name="is_sync_enabled" type="checkbox" {{if .is_sync_enabled}}checked{{end}}>
|
||||
|
|
|
@ -224,7 +224,8 @@ func TestLDAPUserSync(t *testing.T) {
|
|||
}
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
addAuthSourceLDAP(t, "", "")
|
||||
auth.SyncExternalUsers(context.Background(), true)
|
||||
err := auth.SyncExternalUsers(context.Background(), true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if users exists
|
||||
for _, gitLDAPUser := range gitLDAPUsers {
|
||||
|
|
|
@ -96,7 +96,6 @@
|
|||
.page-content.organization #org-info {
|
||||
overflow-wrap: anywhere;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.page-content.organization #org-info .ui.header {
|
||||
|
|
|
@ -2466,7 +2466,7 @@ tbody.commit-list {
|
|||
.sidebar-item-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.diff-file-header {
|
||||
|
|
Loading…
Reference in New Issue