From 5cbdf83f706ecdace65e5b42897ebbde82c3a0a1 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 2 Mar 2025 10:14:49 -0800 Subject: [PATCH 01/47] Use pullrequestlist instead of []*pullrequest (#33765) Just renames no code changed. --- models/issues/pull_list.go | 4 ++-- models/issues/pull_list_test.go | 12 ++++++------ services/pull/pull.go | 21 ++++++++++----------- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go index b105fab97c..b685175f8e 100644 --- a/models/issues/pull_list.go +++ b/models/issues/pull_list.go @@ -61,7 +61,7 @@ func listPullRequestStatement(ctx context.Context, baseRepoID int64, opts *PullR } // GetUnmergedPullRequestsByHeadInfo returns all pull requests that are open and has not been merged -func GetUnmergedPullRequestsByHeadInfo(ctx context.Context, repoID int64, branch string) ([]*PullRequest, error) { +func GetUnmergedPullRequestsByHeadInfo(ctx context.Context, repoID int64, branch string) (PullRequestList, error) { prs := make([]*PullRequest, 0, 2) sess := db.GetEngine(ctx). Join("INNER", "issue", "issue.id = pull_request.issue_id"). @@ -116,7 +116,7 @@ func HasUnmergedPullRequestsByHeadInfo(ctx context.Context, repoID int64, branch // GetUnmergedPullRequestsByBaseInfo returns all pull requests that are open and has not been merged // by given base information (repo and branch). -func GetUnmergedPullRequestsByBaseInfo(ctx context.Context, repoID int64, branch string) ([]*PullRequest, error) { +func GetUnmergedPullRequestsByBaseInfo(ctx context.Context, repoID int64, branch string) (PullRequestList, error) { prs := make([]*PullRequest, 0, 2) return prs, db.GetEngine(ctx). Where("base_repo_id=? AND base_branch=? AND has_merged=? AND issue.is_closed=?", diff --git a/models/issues/pull_list_test.go b/models/issues/pull_list_test.go index c7a898ca4e..f5553e7885 100644 --- a/models/issues/pull_list_test.go +++ b/models/issues/pull_list_test.go @@ -16,11 +16,11 @@ import ( func TestPullRequestList_LoadAttributes(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - prs := []*issues_model.PullRequest{ + prs := issues_model.PullRequestList{ unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}), unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}), } - assert.NoError(t, issues_model.PullRequestList(prs).LoadAttributes(db.DefaultContext)) + assert.NoError(t, prs.LoadAttributes(db.DefaultContext)) for _, pr := range prs { assert.NotNil(t, pr.Issue) assert.Equal(t, pr.IssueID, pr.Issue.ID) @@ -32,11 +32,11 @@ func TestPullRequestList_LoadAttributes(t *testing.T) { func TestPullRequestList_LoadReviewCommentsCounts(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - prs := []*issues_model.PullRequest{ + prs := issues_model.PullRequestList{ unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}), unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}), } - reviewComments, err := issues_model.PullRequestList(prs).LoadReviewCommentsCounts(db.DefaultContext) + reviewComments, err := prs.LoadReviewCommentsCounts(db.DefaultContext) assert.NoError(t, err) assert.Len(t, reviewComments, 2) for _, pr := range prs { @@ -47,11 +47,11 @@ func TestPullRequestList_LoadReviewCommentsCounts(t *testing.T) { func TestPullRequestList_LoadReviews(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - prs := []*issues_model.PullRequest{ + prs := issues_model.PullRequestList{ unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}), unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}), } - reviewList, err := issues_model.PullRequestList(prs).LoadReviews(db.DefaultContext) + reviewList, err := prs.LoadReviews(db.DefaultContext) assert.NoError(t, err) // 1, 7, 8, 9, 10, 22 assert.Len(t, reviewList, 6) diff --git a/services/pull/pull.go b/services/pull/pull.go index 5d3758eca6..e4db3127b9 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -407,11 +407,10 @@ func AddTestPullRequestTask(doer *user_model.User, repoID int64, branch string, } if isSync { - requests := issues_model.PullRequestList(prs) - if err = requests.LoadAttributes(ctx); err != nil { + if err = prs.LoadAttributes(ctx); err != nil { log.Error("PullRequestList.LoadAttributes: %v", err) } - if invalidationErr := checkForInvalidation(ctx, requests, repoID, doer, branch); invalidationErr != nil { + if invalidationErr := checkForInvalidation(ctx, prs, repoID, doer, branch); invalidationErr != nil { log.Error("checkForInvalidation: %v", invalidationErr) } if err == nil { @@ -645,7 +644,7 @@ func retargetBranchPulls(ctx context.Context, doer *user_model.User, repoID int6 return err } - if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil { + if err := prs.LoadAttributes(ctx); err != nil { return err } @@ -672,11 +671,11 @@ func AdjustPullsCausedByBranchDeleted(ctx context.Context, doer *user_model.User return err } - if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil { + if err := prs.LoadAttributes(ctx); err != nil { return err } - issues_model.PullRequestList(prs).SetHeadRepo(repo) - if err := issues_model.PullRequestList(prs).LoadRepositories(ctx); err != nil { + prs.SetHeadRepo(repo) + if err := prs.LoadRepositories(ctx); err != nil { return err } @@ -707,11 +706,11 @@ func AdjustPullsCausedByBranchDeleted(ctx context.Context, doer *user_model.User return err } - if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil { + if err := prs.LoadAttributes(ctx); err != nil { return err } - issues_model.PullRequestList(prs).SetBaseRepo(repo) - if err := issues_model.PullRequestList(prs).LoadRepositories(ctx); err != nil { + prs.SetBaseRepo(repo) + if err := prs.LoadRepositories(ctx); err != nil { return err } @@ -744,7 +743,7 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re return err } - if err = issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil { + if err = prs.LoadAttributes(ctx); err != nil { return err } From 27bf63ad20e3241d48636064f585a6d432bdcaa7 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 3 Mar 2025 10:57:28 +0800 Subject: [PATCH 02/47] Refactor global init code and add more comments (#33755) Follow up #33748 Now there are 3 "global" functions: * registerGlobalSelectorFunc: for all elements matching the selector, eg: `.ui.dropdown` * registerGlobalInitFunc: for `data-global-init="initInputAutoFocusEnd"` * registerGlobalEventFunc: for `data-global-click="onCommentReactionButtonClick"` And introduce `initGlobalInput` to replace old `initAutoFocusEnd` and `attachDirAuto`, use `data-global-init` to replace fragile `.js-autofocus-end` selector. Another benefit is that by the new approach, no matter how many times `registerGlobalInitFunc` is called, we only need to do one "querySelectorAll" in the last step, it could slightly improve the performance. --- modules/markup/sanitizer_default_test.go | 4 + templates/base/alert.tmpl | 16 +-- templates/repo/issue/new_form.tmpl | 7 +- web_src/js/features/autofocus-end.ts | 6 -- web_src/js/features/common-page.ts | 23 ++++- web_src/js/features/repo-diff.ts | 4 +- web_src/js/index.ts | 65 ++++-------- web_src/js/modules/init.ts | 26 +++++ web_src/js/modules/observer.ts | 124 +++++++++++++---------- web_src/js/utils/dom.ts | 2 +- 10 files changed, 154 insertions(+), 123 deletions(-) delete mode 100644 web_src/js/features/autofocus-end.ts create mode 100644 web_src/js/modules/init.ts diff --git a/modules/markup/sanitizer_default_test.go b/modules/markup/sanitizer_default_test.go index e6fbae5056..5282916944 100644 --- a/modules/markup/sanitizer_default_test.go +++ b/modules/markup/sanitizer_default_test.go @@ -62,6 +62,10 @@ func TestSanitizer(t *testing.T) { `bad`, `bad`, `bad`, `bad`, `bad`, `bad`, + + // Some classes and attributes are used by the frontend framework and will execute JS code, so make sure they are removed + ``, `
txt
`, + `
txt
`, `
txt
`, } for i := 0; i < len(testCases); i += 2 { diff --git a/templates/base/alert.tmpl b/templates/base/alert.tmpl index 760d3bfa2c..3f6d77a645 100644 --- a/templates/base/alert.tmpl +++ b/templates/base/alert.tmpl @@ -1,20 +1,20 @@ -{{if .Flash.ErrorMsg}} +{{- if .Flash.ErrorMsg -}}

{{.Flash.ErrorMsg | SanitizeHTML}}

-{{end}} -{{if .Flash.SuccessMsg}} +{{- end -}} +{{- if .Flash.SuccessMsg -}}

{{.Flash.SuccessMsg | SanitizeHTML}}

-{{end}} -{{if .Flash.InfoMsg}} +{{- end -}} +{{- if .Flash.InfoMsg -}}

{{.Flash.InfoMsg | SanitizeHTML}}

-{{end}} -{{if .Flash.WarningMsg}} +{{- end -}} +{{- if .Flash.WarningMsg -}}

{{.Flash.WarningMsg | SanitizeHTML}}

-{{end}} +{{- end -}} diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index dd4c7617ce..5a923a1602 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -1,6 +1,4 @@ -{{if .Flash}} {{template "base/alert" .}} -{{end}}
{{.CsrfTokenHtml}}
@@ -9,7 +7,10 @@ {{ctx.AvatarUtils.Avatar .SignedUser 40}}
- + {{if .PageIsComparePull}}
{{ctx.Locale.Tr "repo.pulls.title_wip_desc" (index .PullRequestWorkInProgressPrefixes 0)}}
{{end}} diff --git a/web_src/js/features/autofocus-end.ts b/web_src/js/features/autofocus-end.ts deleted file mode 100644 index 53e475b543..0000000000 --- a/web_src/js/features/autofocus-end.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function initAutoFocusEnd() { - for (const el of document.querySelectorAll('.js-autofocus-end')) { - el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus. - el.setSelectionRange(el.value.length, el.value.length); - } -} diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts index c5274201f2..235555a73d 100644 --- a/web_src/js/features/common-page.ts +++ b/web_src/js/features/common-page.ts @@ -2,7 +2,7 @@ import {GET} from '../modules/fetch.ts'; import {showGlobalErrorMessage} from '../bootstrap.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {queryElems} from '../utils/dom.ts'; -import {observeAddedElement} from '../modules/observer.ts'; +import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; const {appUrl} = window.config; @@ -30,7 +30,7 @@ export function initFootLanguageMenu() { export function initGlobalDropdown() { // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. - observeAddedElement('.ui.dropdown:not(.custom)', (el) => { + registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => { const $dropdown = fomanticQuery(el); if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it. @@ -80,6 +80,25 @@ export function initGlobalTabularMenu() { fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false}); } +// for performance considerations, it only uses performant syntax +function attachInputDirAuto(el: Partial) { + if (el.type !== 'hidden' && + el.type !== 'checkbox' && + el.type !== 'radio' && + el.type !== 'range' && + el.type !== 'color') { + el.dir = 'auto'; + } +} + +export function initGlobalInput() { + registerGlobalSelectorFunc('input, textarea', attachInputDirAuto); + registerGlobalInitFunc('initInputAutoFocusEnd', (el: HTMLInputElement) => { + el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus. + el.setSelectionRange(el.value.length, el.value.length); + }); +} + /** * Too many users set their ROOT_URL to wrong value, and it causes a lot of problems: * * Cross-origin API request without correct cookie diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index f1e441a133..1ecd00f1af 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -10,7 +10,7 @@ import {POST, GET} from '../modules/fetch.ts'; import {createTippy} from '../modules/tippy.ts'; import {invertFileFolding} from './file-fold.ts'; import {parseDom} from '../utils.ts'; -import {observeAddedElement} from '../modules/observer.ts'; +import {registerGlobalSelectorFunc} from '../modules/observer.ts'; const {i18n} = window.config; @@ -254,7 +254,7 @@ export function initRepoDiffView() { initExpandAndCollapseFilesButton(); initRepoDiffHashChangeListener(); - observeAddedElement('#diff-file-boxes .diff-file-box', initRepoDiffFileBox); + registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox); addDelegatedEventListener(document, 'click', '.fold-file', (el) => { invertFileFolding(el.closest('.file-content'), el); }); diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 2e253870c0..f48074316e 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -11,7 +11,6 @@ import {initImageDiff} from './features/imagediff.ts'; import {initRepoMigration} from './features/repo-migration.ts'; import {initRepoProject} from './features/repo-projects.ts'; import {initTableSort} from './features/tablesort.ts'; -import {initAutoFocusEnd} from './features/autofocus-end.ts'; import {initAdminUserListSearchForm} from './features/admin/users.ts'; import {initAdminConfigs} from './features/admin/config.ts'; import {initMarkupAnchors} from './markup/anchors.ts'; @@ -62,62 +61,23 @@ import {initRepoContributors} from './features/contributors.ts'; import {initRepoCodeFrequency} from './features/code-frequency.ts'; import {initRepoRecentCommits} from './features/recent-commits.ts'; import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts'; -import {initAddedElementObserver} from './modules/observer.ts'; +import {initGlobalSelectorObserver} from './modules/observer.ts'; import {initRepositorySearch} from './features/repo-search.ts'; import {initColorPickers} from './features/colorpicker.ts'; import {initAdminSelfCheck} from './features/admin/selfcheck.ts'; import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts'; import {initGlobalFetchAction} from './features/common-fetch-action.ts'; -import { - initFootLanguageMenu, - initGlobalDropdown, - initGlobalTabularMenu, - initHeadNavbarContentToggle, -} from './features/common-page.ts'; -import { - initGlobalButtonClickOnEnter, - initGlobalButtons, - initGlobalDeleteButton, -} from './features/common-button.ts'; -import { - initGlobalComboMarkdownEditor, - initGlobalEnterQuickSubmit, - initGlobalFormDirtyLeaveConfirm, -} from './features/common-form.ts'; +import {initFootLanguageMenu, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} from './features/common-page.ts'; +import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts'; +import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; +import {callInitFunctions} from './modules/init.ts'; initGiteaFomantic(); -initAddedElementObserver(); initSubmitEventPolyfill(); -function callInitFunctions(functions: (() => any)[]) { - // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1" - // It is a quick check, no side effect so no need to do slow URL parsing. - const initStart = performance.now(); - if (window.location.search.includes('_ui_performance_trace=1')) { - let results: {name: string, dur: number}[] = []; - for (const func of functions) { - const start = performance.now(); - func(); - results.push({name: func.name, dur: performance.now() - start}); - } - results = results.sort((a, b) => b.dur - a.dur); - for (let i = 0; i < 20 && i < results.length; i++) { - // eslint-disable-next-line no-console - console.log(`performance trace: ${results[i].name} ${results[i].dur.toFixed(3)}`); - } - } else { - for (const func of functions) { - func(); - } - } - const initDur = performance.now() - initStart; - if (initDur > 500) { - console.error(`slow init functions took ${initDur.toFixed(3)}ms`); - } -} - onDomReady(() => { - callInitFunctions([ + const initStartTime = performance.now(); + const initPerformanceTracer = callInitFunctions([ initGlobalDropdown, initGlobalTabularMenu, initGlobalFetchAction, @@ -129,6 +89,7 @@ onDomReady(() => { initGlobalFormDirtyLeaveConfirm, initGlobalComboMarkdownEditor, initGlobalDeleteButton, + initGlobalInput, initCommonOrganization, initCommonIssueListQuickGoto, @@ -150,7 +111,6 @@ onDomReady(() => { initSshKeyFormParser, initStopwatch, initTableSort, - initAutoFocusEnd, initFindFileInRepo, initCopyContent, @@ -212,4 +172,13 @@ onDomReady(() => { initOAuth2SettingsDisableCheckbox, ]); + + // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. + initGlobalSelectorObserver(initPerformanceTracer); + if (initPerformanceTracer) initPerformanceTracer.printResults(); + + const initDur = performance.now() - initStartTime; + if (initDur > 500) { + console.error(`slow init functions took ${initDur.toFixed(3)}ms`); + } }); diff --git a/web_src/js/modules/init.ts b/web_src/js/modules/init.ts new file mode 100644 index 0000000000..538fafd83f --- /dev/null +++ b/web_src/js/modules/init.ts @@ -0,0 +1,26 @@ +export class InitPerformanceTracer { + results: {name: string, dur: number}[] = []; + recordCall(name: string, func: ()=>void) { + const start = performance.now(); + func(); + this.results.push({name, dur: performance.now() - start}); + } + printResults() { + this.results = this.results.sort((a, b) => b.dur - a.dur); + for (let i = 0; i < 20 && i < this.results.length; i++) { + console.info(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`); + } + } +} + +export function callInitFunctions(functions: (() => any)[]): InitPerformanceTracer | null { + // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1" + // It is a quick check, no side effect so no need to do slow URL parsing. + const perfTracer = !window.location.search.includes('_ui_performance_trace=1') ? null : new InitPerformanceTracer(); + if (perfTracer) { + for (const func of functions) perfTracer.recordCall(func.name, func); + } else { + for (const func of functions) func(); + } + return perfTracer; +} diff --git a/web_src/js/modules/observer.ts b/web_src/js/modules/observer.ts index 57d0db31c7..f60c033cf2 100644 --- a/web_src/js/modules/observer.ts +++ b/web_src/js/modules/observer.ts @@ -1,52 +1,73 @@ import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; +import type {Promisable} from 'type-fest'; +import type {InitPerformanceTracer} from './init.ts'; -type DirElement = HTMLInputElement | HTMLTextAreaElement; +let globalSelectorObserverInited = false; -// for performance considerations, it only uses performant syntax -function attachDirAuto(el: Partial) { - if (el.type !== 'hidden' && - el.type !== 'checkbox' && - el.type !== 'radio' && - el.type !== 'range' && - el.type !== 'color') { - el.dir = 'auto'; - } -} +type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void}; +const selectorHandlers: SelectorHandler[] = []; -type GlobalInitFunc = (el: T) => void | Promise; -const globalInitFuncs: Record> = {}; -function attachGlobalInit(el: HTMLElement) { - const initFunc = el.getAttribute('data-global-init'); - const func = globalInitFuncs[initFunc]; - if (!func) throw new Error(`Global init function "${initFunc}" not found`); - func(el); -} - -type GlobalEventFunc = (el: T, e: E) => (void | Promise); +type GlobalEventFunc = (el: T, e: E) => Promisable; const globalEventFuncs: Record> = {}; + +type GlobalInitFunc = (el: T) => Promisable; +const globalInitFuncs: Record> = {}; + +// It handles the global events for all `
` elements. export function registerGlobalEventFunc(event: string, name: string, func: GlobalEventFunc) { - globalEventFuncs[`${event}:${name}`] = func as any; + globalEventFuncs[`${event}:${name}`] = func as GlobalEventFunc; } -type SelectorHandler = { - selector: string, - handler: (el: HTMLElement) => void, -}; - -const selectorHandlers: SelectorHandler[] = [ - {selector: 'input, textarea', handler: attachDirAuto}, - {selector: '[data-global-init]', handler: attachGlobalInit}, -]; - -export function observeAddedElement(selector: string, handler: (el: HTMLElement) => void) { +// It handles the global init functions by a selector, for example: +// > registerGlobalSelectorObserver('.ui.dropdown:not(.custom)', (el) => { initDropdown(el, ...) }); +export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) { selectorHandlers.push({selector, handler}); - const docNodes = document.querySelectorAll(selector); - for (const el of docNodes) { + // Then initAddedElementObserver will call this handler for all existing elements after all handlers are added. + // This approach makes the init stage only need to do one "querySelectorAll". + if (!globalSelectorObserverInited) return; + for (const el of document.querySelectorAll(selector)) { handler(el); } } -export function initAddedElementObserver(): void { +// It handles the global init functions for all `
` elements. +export function registerGlobalInitFunc(name: string, handler: GlobalInitFunc) { + globalInitFuncs[name] = handler as GlobalInitFunc; + // The "global init" functions are managed internally and called by callGlobalInitFunc + // They must be ready before initGlobalSelectorObserver is called. + if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()'); +} + +function callGlobalInitFunc(el: HTMLElement) { + const initFunc = el.getAttribute('data-global-init'); + const func = globalInitFuncs[initFunc]; + if (!func) throw new Error(`Global init function "${initFunc}" not found`); + + type GiteaGlobalInitElement = Partial & {_giteaGlobalInited: boolean}; + if ((el as GiteaGlobalInitElement)._giteaGlobalInited) throw new Error(`Global init function "${initFunc}" already executed`); + (el as GiteaGlobalInitElement)._giteaGlobalInited = true; + func(el); +} + +function attachGlobalEvents() { + // add global "[data-global-click]" event handler + document.addEventListener('click', (e) => { + const elem = (e.target as HTMLElement).closest('[data-global-click]'); + if (!elem) return; + const funcName = elem.getAttribute('data-global-click'); + const func = globalEventFuncs[`click:${funcName}`]; + if (!func) throw new Error(`Global event function "click:${funcName}" not found`); + func(elem, e); + }); +} + +export function initGlobalSelectorObserver(perfTracer?: InitPerformanceTracer): void { + if (globalSelectorObserverInited) throw new Error('initGlobalSelectorObserver() already called'); + globalSelectorObserverInited = true; + + attachGlobalEvents(); + + selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc}); const observer = new MutationObserver((mutationList) => { const len = mutationList.length; for (let i = 0; i < len; i++) { @@ -60,30 +81,27 @@ export function initAddedElementObserver(): void { if (addedNode.matches(selector)) { handler(addedNode); } - const children = addedNode.querySelectorAll(selector); - for (const el of children) { + for (const el of addedNode.querySelectorAll(selector)) { handler(el); } } } } }); - - for (const {selector, handler} of selectorHandlers) { - const docNodes = document.querySelectorAll(selector); - for (const el of docNodes) { - handler(el); + if (perfTracer) { + for (const {selector, handler} of selectorHandlers) { + perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => { + for (const el of document.querySelectorAll(selector)) { + handler(el); + } + }); + } + } else { + for (const {selector, handler} of selectorHandlers) { + for (const el of document.querySelectorAll(selector)) { + handler(el); + } } } - observer.observe(document, {subtree: true, childList: true}); - - document.addEventListener('click', (e) => { - const elem = (e.target as HTMLElement).closest('[data-global-click]'); - if (!elem) return; - const funcName = elem.getAttribute('data-global-click'); - const func = globalEventFuncs[`click:${funcName}`]; - if (!func) throw new Error(`Global event function "click:${funcName}" not found`); - func(elem, e); - }); } diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 603f967b34..4d15784e6e 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -355,7 +355,7 @@ export function querySingleVisibleElem(parent: Element, s return candidates.length ? candidates[0] as T : null; } -export function addDelegatedEventListener(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => void | Promise, options?: boolean | AddEventListenerOptions) { +export function addDelegatedEventListener(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => Promisable, options?: boolean | AddEventListenerOptions) { parent.addEventListener(type, (e: Event) => { const elem = (e.target as HTMLElement).closest(selector); if (!elem) return; From 02657e85a4fcd31d4e6e813fddf6e0fc3bf9ec60 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 3 Mar 2025 11:31:47 +0800 Subject: [PATCH 03/47] Refactor mail code (#33768) `mail.go` contains various different functions, it's time to split it. --- services/mailer/mail.go | 491 +-------------------------- services/mailer/mail_issue.go | 57 +++- services/mailer/mail_issue_common.go | 325 ++++++++++++++++++ services/mailer/mail_release.go | 9 +- services/mailer/mail_repo.go | 3 + services/mailer/mail_team_invite.go | 4 +- services/mailer/mail_user.go | 161 +++++++++ 7 files changed, 536 insertions(+), 514 deletions(-) create mode 100644 services/mailer/mail_issue_common.go create mode 100644 services/mailer/mail_user.go diff --git a/services/mailer/mail.go b/services/mailer/mail.go index 52e19bde6f..7db259ac2c 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -6,46 +6,19 @@ package mailer import ( "bytes" - "context" - "fmt" "html/template" "mime" "regexp" - "strconv" "strings" texttmpl "text/template" - "time" - activities_model "code.gitea.io/gitea/models/activities" - issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/models/renderhelper" - repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" - "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/translation" - incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" sender_service "code.gitea.io/gitea/services/mailer/sender" - "code.gitea.io/gitea/services/mailer/token" ) -const ( - mailAuthActivate templates.TplName = "auth/activate" - mailAuthActivateEmail templates.TplName = "auth/activate_email" - mailAuthResetPassword templates.TplName = "auth/reset_passwd" - mailAuthRegisterNotify templates.TplName = "auth/register_notify" - - mailNotifyCollaborator templates.TplName = "notify/collaborator" - - mailRepoTransferNotify templates.TplName = "notify/repo_transfer" - - // There's no actual limit for subject in RFC 5322 - mailMaxSubjectRunes = 256 -) +const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322 var ( bodyTemplates *template.Template @@ -62,367 +35,6 @@ func SendTestMail(email string) error { return sender_service.Send(sender, sender_service.NewMessage(email, "Gitea Test Email!", "Gitea Test Email!")) } -// sendUserMail sends a mail to the user -func sendUserMail(language string, u *user_model.User, tpl templates.TplName, code, subject, info string) { - locale := translation.NewLocale(language) - data := map[string]any{ - "locale": locale, - "DisplayName": u.DisplayName(), - "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), - "ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale), - "Code": code, - "Language": locale.Language(), - } - - var content bytes.Buffer - - if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil { - log.Error("Template: %v", err) - return - } - - msg := sender_service.NewMessage(u.EmailTo(), subject, content.String()) - msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info) - - SendAsync(msg) -} - -// SendActivateAccountMail sends an activation mail to the user (new user registration) -func SendActivateAccountMail(locale translation.Locale, u *user_model.User) { - if setting.MailService == nil { - // No mail service configured - return - } - opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount} - sendUserMail(locale.Language(), u, mailAuthActivate, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.activate_account"), "activate account") -} - -// SendResetPasswordMail sends a password reset mail to the user -func SendResetPasswordMail(u *user_model.User) { - if setting.MailService == nil { - // No mail service configured - return - } - locale := translation.NewLocale(u.Language) - opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword} - sendUserMail(u.Language, u, mailAuthResetPassword, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.reset_password"), "recover account") -} - -// SendActivateEmailMail sends confirmation email to confirm new email address -func SendActivateEmailMail(u *user_model.User, email string) { - if setting.MailService == nil { - // No mail service configured - return - } - locale := translation.NewLocale(u.Language) - opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateEmail, NewEmail: email} - data := map[string]any{ - "locale": locale, - "DisplayName": u.DisplayName(), - "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), - "Code": user_model.GenerateUserTimeLimitCode(opts, u), - "Email": email, - "Language": locale.Language(), - } - - var content bytes.Buffer - - if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { - log.Error("Template: %v", err) - return - } - - msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String()) - msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID) - - SendAsync(msg) -} - -// SendRegisterNotifyMail triggers a notify e-mail by admin created a account. -func SendRegisterNotifyMail(u *user_model.User) { - if setting.MailService == nil || !u.IsActive { - // No mail service configured OR user is inactive - return - } - locale := translation.NewLocale(u.Language) - - data := map[string]any{ - "locale": locale, - "DisplayName": u.DisplayName(), - "Username": u.Name, - "Language": locale.Language(), - } - - var content bytes.Buffer - - if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil { - log.Error("Template: %v", err) - return - } - - msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String()) - msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID) - - SendAsync(msg) -} - -// SendCollaboratorMail sends mail notification to new collaborator. -func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) { - if setting.MailService == nil || !u.IsActive { - // No mail service configured OR the user is inactive - return - } - locale := translation.NewLocale(u.Language) - repoName := repo.FullName() - - subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName) - data := map[string]any{ - "locale": locale, - "Subject": subject, - "RepoName": repoName, - "Link": repo.HTMLURL(), - "Language": locale.Language(), - } - - var content bytes.Buffer - - if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil { - log.Error("Template: %v", err) - return - } - - msg := sender_service.NewMessage(u.EmailTo(), subject, content.String()) - msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID) - - SendAsync(msg) -} - -func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) { - var ( - subject string - link string - prefix string - // Fall back subject for bad templates, make sure subject is never empty - fallback string - reviewComments []*issues_model.Comment - ) - - commentType := issues_model.CommentTypeComment - if ctx.Comment != nil { - commentType = ctx.Comment.Type - link = ctx.Issue.HTMLURL() + "#" + ctx.Comment.HashTag() - } else { - link = ctx.Issue.HTMLURL() - } - - reviewType := issues_model.ReviewTypeComment - if ctx.Comment != nil && ctx.Comment.Review != nil { - reviewType = ctx.Comment.Review.Type - } - - // This is the body of the new issue or comment, not the mail body - rctx := renderhelper.NewRenderContextRepoComment(ctx.Context, ctx.Issue.Repo).WithUseAbsoluteLink(true) - body, err := markdown.RenderString(rctx, - ctx.Content) - if err != nil { - return nil, err - } - - actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType) - - if actName != "new" { - prefix = "Re: " - } - fallback = prefix + fallbackMailSubject(ctx.Issue) - - if ctx.Comment != nil && ctx.Comment.Review != nil { - reviewComments = make([]*issues_model.Comment, 0, 10) - for _, lines := range ctx.Comment.Review.CodeComments { - for _, comments := range lines { - reviewComments = append(reviewComments, comments...) - } - } - } - locale := translation.NewLocale(lang) - - mailMeta := map[string]any{ - "locale": locale, - "FallbackSubject": fallback, - "Body": body, - "Link": link, - "Issue": ctx.Issue, - "Comment": ctx.Comment, - "IsPull": ctx.Issue.IsPull, - "User": ctx.Issue.Repo.MustOwner(ctx), - "Repo": ctx.Issue.Repo.FullName(), - "Doer": ctx.Doer, - "IsMention": fromMention, - "SubjectPrefix": prefix, - "ActionType": actType, - "ActionName": actName, - "ReviewComments": reviewComments, - "Language": locale.Language(), - "CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush, - } - - var mailSubject bytes.Buffer - if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil { - subject = sanitizeSubject(mailSubject.String()) - if subject == "" { - subject = fallback - } - } else { - log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err) - } - - subject = emoji.ReplaceAliases(subject) - - mailMeta["Subject"] = subject - - var mailBody bytes.Buffer - - if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil { - log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err) - } - - // Make sure to compose independent messages to avoid leaking user emails - msgID := generateMessageIDForIssue(ctx.Issue, ctx.Comment, ctx.ActionType) - reference := generateMessageIDForIssue(ctx.Issue, nil, activities_model.ActionType(0)) - - var replyPayload []byte - if ctx.Comment != nil { - if ctx.Comment.Type.HasMailReplySupport() { - replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment) - } - } else { - replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Issue) - } - if err != nil { - return nil, err - } - - unsubscribePayload, err := incoming_payload.CreateReferencePayload(ctx.Issue) - if err != nil { - return nil, err - } - - msgs := make([]*sender_service.Message, 0, len(recipients)) - for _, recipient := range recipients { - msg := sender_service.NewMessageFrom( - recipient.Email, - fromDisplayName(ctx.Doer), - setting.MailService.FromEmail, - subject, - mailBody.String(), - ) - msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info) - - msg.SetHeader("Message-ID", msgID) - msg.SetHeader("In-Reply-To", reference) - - references := []string{reference} - listUnsubscribe := []string{"<" + ctx.Issue.HTMLURL() + ">"} - - if setting.IncomingEmail.Enabled { - if replyPayload != nil { - token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload) - if err != nil { - log.Error("CreateToken failed: %v", err) - } else { - replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1) - msg.ReplyTo = replyAddress - msg.SetHeader("List-Post", fmt.Sprintf("", replyAddress)) - - references = append(references, fmt.Sprintf("", token, setting.Domain)) - } - } - - token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload) - if err != nil { - log.Error("CreateToken failed: %v", err) - } else { - unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1) - listUnsubscribe = append(listUnsubscribe, "") - } - } - - msg.SetHeader("References", references...) - msg.SetHeader("List-Unsubscribe", listUnsubscribe...) - - for key, value := range generateAdditionalHeaders(ctx, actType, recipient) { - msg.SetHeader(key, value) - } - - msgs = append(msgs, msg) - } - - return msgs, nil -} - -func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string { - var path string - if issue.IsPull { - path = "pulls" - } else { - path = "issues" - } - - var extra string - if comment != nil { - extra = fmt.Sprintf("/comment/%d", comment.ID) - } else { - switch actionType { - case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest: - extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6) - case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest: - extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6) - case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: - extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6) - case activities_model.ActionPullRequestReadyForReview: - extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6) - } - } - - return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain) -} - -func generateMessageIDForRelease(release *repo_model.Release) string { - return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain) -} - -func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string { - repo := ctx.Issue.Repo - - return map[string]string{ - // https://datatracker.ietf.org/doc/html/rfc2919 - "List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain), - - // https://datatracker.ietf.org/doc/html/rfc2369 - "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()), - - "X-Mailer": "Gitea", - "X-Gitea-Reason": reason, - "X-Gitea-Sender": ctx.Doer.Name, - "X-Gitea-Recipient": recipient.Name, - "X-Gitea-Recipient-Address": recipient.Email, - "X-Gitea-Repository": repo.Name, - "X-Gitea-Repository-Path": repo.FullName(), - "X-Gitea-Repository-Link": repo.HTMLURL(), - "X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10), - "X-Gitea-Issue-Link": ctx.Issue.HTMLURL(), - - "X-GitHub-Reason": reason, - "X-GitHub-Sender": ctx.Doer.Name, - "X-GitHub-Recipient": recipient.Name, - "X-GitHub-Recipient-Address": recipient.Email, - - "X-GitLab-NotificationReason": reason, - "X-GitLab-Project": repo.Name, - "X-GitLab-Project-Path": repo.FullName(), - "X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10), - } -} - func sanitizeSubject(subject string) string { runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " "))) if len(runes) > mailMaxSubjectRunes { @@ -432,107 +44,6 @@ func sanitizeSubject(subject string) string { return mime.QEncoding.Encode("utf-8", string(runes)) } -// SendIssueAssignedMail composes and sends issue assigned email -func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error { - if setting.MailService == nil { - // No mail service configured - return nil - } - - if err := issue.LoadRepo(ctx); err != nil { - log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err) - return err - } - - langMap := make(map[string][]*user_model.User) - for _, user := range recipients { - if !user.IsActive { - // don't send emails to inactive users - continue - } - langMap[user.Language] = append(langMap[user.Language], user) - } - - for lang, tos := range langMap { - msgs, err := composeIssueCommentMessages(&mailCommentContext{ - Context: ctx, - Issue: issue, - Doer: doer, - ActionType: activities_model.ActionType(0), - Content: content, - Comment: comment, - }, lang, tos, false, "issue assigned") - if err != nil { - return err - } - SendAsync(msgs...) - } - return nil -} - -// actionToTemplate returns the type and name of the action facing the user -// (slightly different from activities_model.ActionType) and the name of the template to use (based on availability) -func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType, - commentType issues_model.CommentType, reviewType issues_model.ReviewType, -) (typeName, name, template string) { - if issue.IsPull { - typeName = "pull" - } else { - typeName = "issue" - } - switch actionType { - case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest: - name = "new" - case activities_model.ActionCommentIssue, activities_model.ActionCommentPull: - name = "comment" - case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest: - name = "close" - case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest: - name = "reopen" - case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: - name = "merge" - case activities_model.ActionPullReviewDismissed: - name = "review_dismissed" - case activities_model.ActionPullRequestReadyForReview: - name = "ready_for_review" - default: - switch commentType { - case issues_model.CommentTypeReview: - switch reviewType { - case issues_model.ReviewTypeApprove: - name = "approve" - case issues_model.ReviewTypeReject: - name = "reject" - default: - name = "review" - } - case issues_model.CommentTypeCode: - name = "code" - case issues_model.CommentTypeAssignees: - name = "assigned" - case issues_model.CommentTypePullRequestPush: - name = "push" - default: - name = "default" - } - } - - template = typeName + "/" + name - ok := bodyTemplates.Lookup(template) != nil - if !ok && typeName != "issue" { - template = "issue/" + name - ok = bodyTemplates.Lookup(template) != nil - } - if !ok { - template = typeName + "/default" - ok = bodyTemplates.Lookup(template) != nil - } - if !ok { - template = "issue/default" - } - return typeName, name, template -} - func fromDisplayName(u *user_model.User) string { if setting.MailService.FromDisplayNameFormatTemplate != nil { var ctx bytes.Buffer diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index e269b1ca1e..8582185fd2 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -18,24 +18,7 @@ import ( "code.gitea.io/gitea/modules/setting" ) -func fallbackMailSubject(issue *issues_model.Issue) string { - return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) -} - -type mailCommentContext struct { - context.Context - Issue *issues_model.Issue - Doer *user_model.User - ActionType activities_model.ActionType - Content string - Comment *issues_model.Comment - ForceDoerNotification bool -} - -const ( - // MailBatchSize set the batch size used in mailIssueCommentBatch - MailBatchSize = 100 -) +const MailBatchSize = 100 // batch size used in mailIssueCommentBatch // mailIssueCommentToParticipants can be used for both new issue creation and comment. // This function sends two list of emails: @@ -199,3 +182,41 @@ func MailParticipants(ctx context.Context, issue *issues_model.Issue, doer *user } return nil } + +// SendIssueAssignedMail composes and sends issue assigned email +func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error { + if setting.MailService == nil { + // No mail service configured + return nil + } + + if err := issue.LoadRepo(ctx); err != nil { + log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err) + return err + } + + langMap := make(map[string][]*user_model.User) + for _, user := range recipients { + if !user.IsActive { + // don't send emails to inactive users + continue + } + langMap[user.Language] = append(langMap[user.Language], user) + } + + for lang, tos := range langMap { + msgs, err := composeIssueCommentMessages(&mailCommentContext{ + Context: ctx, + Issue: issue, + Doer: doer, + ActionType: activities_model.ActionType(0), + Content: content, + Comment: comment, + }, lang, tos, false, "issue assigned") + if err != nil { + return err + } + SendAsync(msgs...) + } + return nil +} diff --git a/services/mailer/mail_issue_common.go b/services/mailer/mail_issue_common.go new file mode 100644 index 0000000000..23ca4c3f15 --- /dev/null +++ b/services/mailer/mail_issue_common.go @@ -0,0 +1,325 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + "fmt" + "strconv" + "strings" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/renderhelper" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/emoji" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" + incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" + sender_service "code.gitea.io/gitea/services/mailer/sender" + "code.gitea.io/gitea/services/mailer/token" +) + +func fallbackMailSubject(issue *issues_model.Issue) string { + return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) +} + +type mailCommentContext struct { + context.Context + Issue *issues_model.Issue + Doer *user_model.User + ActionType activities_model.ActionType + Content string + Comment *issues_model.Comment + ForceDoerNotification bool +} + +func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) { + var ( + subject string + link string + prefix string + // Fall back subject for bad templates, make sure subject is never empty + fallback string + reviewComments []*issues_model.Comment + ) + + commentType := issues_model.CommentTypeComment + if ctx.Comment != nil { + commentType = ctx.Comment.Type + link = ctx.Issue.HTMLURL() + "#" + ctx.Comment.HashTag() + } else { + link = ctx.Issue.HTMLURL() + } + + reviewType := issues_model.ReviewTypeComment + if ctx.Comment != nil && ctx.Comment.Review != nil { + reviewType = ctx.Comment.Review.Type + } + + // This is the body of the new issue or comment, not the mail body + rctx := renderhelper.NewRenderContextRepoComment(ctx.Context, ctx.Issue.Repo).WithUseAbsoluteLink(true) + body, err := markdown.RenderString(rctx, + ctx.Content) + if err != nil { + return nil, err + } + + actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType) + + if actName != "new" { + prefix = "Re: " + } + fallback = prefix + fallbackMailSubject(ctx.Issue) + + if ctx.Comment != nil && ctx.Comment.Review != nil { + reviewComments = make([]*issues_model.Comment, 0, 10) + for _, lines := range ctx.Comment.Review.CodeComments { + for _, comments := range lines { + reviewComments = append(reviewComments, comments...) + } + } + } + locale := translation.NewLocale(lang) + + mailMeta := map[string]any{ + "locale": locale, + "FallbackSubject": fallback, + "Body": body, + "Link": link, + "Issue": ctx.Issue, + "Comment": ctx.Comment, + "IsPull": ctx.Issue.IsPull, + "User": ctx.Issue.Repo.MustOwner(ctx), + "Repo": ctx.Issue.Repo.FullName(), + "Doer": ctx.Doer, + "IsMention": fromMention, + "SubjectPrefix": prefix, + "ActionType": actType, + "ActionName": actName, + "ReviewComments": reviewComments, + "Language": locale.Language(), + "CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush, + } + + var mailSubject bytes.Buffer + if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil { + subject = sanitizeSubject(mailSubject.String()) + if subject == "" { + subject = fallback + } + } else { + log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err) + } + + subject = emoji.ReplaceAliases(subject) + + mailMeta["Subject"] = subject + + var mailBody bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err) + } + + // Make sure to compose independent messages to avoid leaking user emails + msgID := generateMessageIDForIssue(ctx.Issue, ctx.Comment, ctx.ActionType) + reference := generateMessageIDForIssue(ctx.Issue, nil, activities_model.ActionType(0)) + + var replyPayload []byte + if ctx.Comment != nil { + if ctx.Comment.Type.HasMailReplySupport() { + replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment) + } + } else { + replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Issue) + } + if err != nil { + return nil, err + } + + unsubscribePayload, err := incoming_payload.CreateReferencePayload(ctx.Issue) + if err != nil { + return nil, err + } + + msgs := make([]*sender_service.Message, 0, len(recipients)) + for _, recipient := range recipients { + msg := sender_service.NewMessageFrom( + recipient.Email, + fromDisplayName(ctx.Doer), + setting.MailService.FromEmail, + subject, + mailBody.String(), + ) + msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info) + + msg.SetHeader("Message-ID", msgID) + msg.SetHeader("In-Reply-To", reference) + + references := []string{reference} + listUnsubscribe := []string{"<" + ctx.Issue.HTMLURL() + ">"} + + if setting.IncomingEmail.Enabled { + if replyPayload != nil { + token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload) + if err != nil { + log.Error("CreateToken failed: %v", err) + } else { + replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1) + msg.ReplyTo = replyAddress + msg.SetHeader("List-Post", fmt.Sprintf("", replyAddress)) + + references = append(references, fmt.Sprintf("", token, setting.Domain)) + } + } + + token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload) + if err != nil { + log.Error("CreateToken failed: %v", err) + } else { + unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1) + listUnsubscribe = append(listUnsubscribe, "") + } + } + + msg.SetHeader("References", references...) + msg.SetHeader("List-Unsubscribe", listUnsubscribe...) + + for key, value := range generateAdditionalHeaders(ctx, actType, recipient) { + msg.SetHeader(key, value) + } + + msgs = append(msgs, msg) + } + + return msgs, nil +} + +// actionToTemplate returns the type and name of the action facing the user +// (slightly different from activities_model.ActionType) and the name of the template to use (based on availability) +func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType, + commentType issues_model.CommentType, reviewType issues_model.ReviewType, +) (typeName, name, template string) { + if issue.IsPull { + typeName = "pull" + } else { + typeName = "issue" + } + switch actionType { + case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest: + name = "new" + case activities_model.ActionCommentIssue, activities_model.ActionCommentPull: + name = "comment" + case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest: + name = "close" + case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest: + name = "reopen" + case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: + name = "merge" + case activities_model.ActionPullReviewDismissed: + name = "review_dismissed" + case activities_model.ActionPullRequestReadyForReview: + name = "ready_for_review" + default: + switch commentType { + case issues_model.CommentTypeReview: + switch reviewType { + case issues_model.ReviewTypeApprove: + name = "approve" + case issues_model.ReviewTypeReject: + name = "reject" + default: + name = "review" + } + case issues_model.CommentTypeCode: + name = "code" + case issues_model.CommentTypeAssignees: + name = "assigned" + case issues_model.CommentTypePullRequestPush: + name = "push" + default: + name = "default" + } + } + + template = typeName + "/" + name + ok := bodyTemplates.Lookup(template) != nil + if !ok && typeName != "issue" { + template = "issue/" + name + ok = bodyTemplates.Lookup(template) != nil + } + if !ok { + template = typeName + "/default" + ok = bodyTemplates.Lookup(template) != nil + } + if !ok { + template = "issue/default" + } + return typeName, name, template +} + +func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string { + var path string + if issue.IsPull { + path = "pulls" + } else { + path = "issues" + } + + var extra string + if comment != nil { + extra = fmt.Sprintf("/comment/%d", comment.ID) + } else { + switch actionType { + case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest: + extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6) + case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest: + extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6) + case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: + extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6) + case activities_model.ActionPullRequestReadyForReview: + extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6) + } + } + + return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain) +} + +func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string { + repo := ctx.Issue.Repo + + return map[string]string{ + // https://datatracker.ietf.org/doc/html/rfc2919 + "List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain), + + // https://datatracker.ietf.org/doc/html/rfc2369 + "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()), + + "X-Mailer": "Gitea", + "X-Gitea-Reason": reason, + "X-Gitea-Sender": ctx.Doer.Name, + "X-Gitea-Recipient": recipient.Name, + "X-Gitea-Recipient-Address": recipient.Email, + "X-Gitea-Repository": repo.Name, + "X-Gitea-Repository-Path": repo.FullName(), + "X-Gitea-Repository-Link": repo.HTMLURL(), + "X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10), + "X-Gitea-Issue-Link": ctx.Issue.HTMLURL(), + + "X-GitHub-Reason": reason, + "X-GitHub-Sender": ctx.Doer.Name, + "X-GitHub-Recipient": recipient.Name, + "X-GitHub-Recipient-Address": recipient.Email, + + "X-GitLab-NotificationReason": reason, + "X-GitLab-Project": repo.Name, + "X-GitLab-Project-Path": repo.FullName(), + "X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10), + } +} diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index 31316b0053..bfff73c39c 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -6,6 +6,7 @@ package mailer import ( "bytes" "context" + "fmt" "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" @@ -18,9 +19,11 @@ import ( sender_service "code.gitea.io/gitea/services/mailer/sender" ) -const ( - tplNewReleaseMail templates.TplName = "release" -) +const tplNewReleaseMail templates.TplName = "release" + +func generateMessageIDForRelease(release *repo_model.Release) string { + return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain) +} // MailNewRelease send new release notify to all repo watchers. func MailNewRelease(ctx context.Context, rel *repo_model.Release) { diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go index 5f80654bcd..b6b2d5ca07 100644 --- a/services/mailer/mail_repo.go +++ b/services/mailer/mail_repo.go @@ -12,10 +12,13 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" sender_service "code.gitea.io/gitea/services/mailer/sender" ) +const mailRepoTransferNotify templates.TplName = "notify/repo_transfer" + // SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error { if setting.MailService == nil { diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go index 5ca44442f3..1fbade7e23 100644 --- a/services/mailer/mail_team_invite.go +++ b/services/mailer/mail_team_invite.go @@ -18,9 +18,7 @@ import ( sender_service "code.gitea.io/gitea/services/mailer/sender" ) -const ( - tplTeamInviteMail templates.TplName = "team_invite" -) +const tplTeamInviteMail templates.TplName = "team_invite" // MailTeamInvite sends team invites func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_model.Team, invite *org_model.TeamInvite) error { diff --git a/services/mailer/mail_user.go b/services/mailer/mail_user.go new file mode 100644 index 0000000000..5a200a5fa7 --- /dev/null +++ b/services/mailer/mail_user.go @@ -0,0 +1,161 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "fmt" + + repo_model "code.gitea.io/gitea/models/repo" + 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/templates" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/translation" + sender_service "code.gitea.io/gitea/services/mailer/sender" +) + +const ( + mailAuthActivate templates.TplName = "auth/activate" + mailAuthActivateEmail templates.TplName = "auth/activate_email" + mailAuthResetPassword templates.TplName = "auth/reset_passwd" + mailAuthRegisterNotify templates.TplName = "auth/register_notify" + mailNotifyCollaborator templates.TplName = "notify/collaborator" +) + +// sendUserMail sends a mail to the user +func sendUserMail(language string, u *user_model.User, tpl templates.TplName, code, subject, info string) { + locale := translation.NewLocale(language) + data := map[string]any{ + "locale": locale, + "DisplayName": u.DisplayName(), + "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), + "ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale), + "Code": code, + "Language": locale.Language(), + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil { + log.Error("Template: %v", err) + return + } + + msg := sender_service.NewMessage(u.EmailTo(), subject, content.String()) + msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info) + + SendAsync(msg) +} + +// SendActivateAccountMail sends an activation mail to the user (new user registration) +func SendActivateAccountMail(locale translation.Locale, u *user_model.User) { + if setting.MailService == nil { + // No mail service configured + return + } + opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount} + sendUserMail(locale.Language(), u, mailAuthActivate, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.activate_account"), "activate account") +} + +// SendResetPasswordMail sends a password reset mail to the user +func SendResetPasswordMail(u *user_model.User) { + if setting.MailService == nil { + // No mail service configured + return + } + locale := translation.NewLocale(u.Language) + opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword} + sendUserMail(u.Language, u, mailAuthResetPassword, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.reset_password"), "recover account") +} + +// SendActivateEmailMail sends confirmation email to confirm new email address +func SendActivateEmailMail(u *user_model.User, email string) { + if setting.MailService == nil { + // No mail service configured + return + } + locale := translation.NewLocale(u.Language) + opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateEmail, NewEmail: email} + data := map[string]any{ + "locale": locale, + "DisplayName": u.DisplayName(), + "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), + "Code": user_model.GenerateUserTimeLimitCode(opts, u), + "Email": email, + "Language": locale.Language(), + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { + log.Error("Template: %v", err) + return + } + + msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String()) + msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID) + + SendAsync(msg) +} + +// SendRegisterNotifyMail triggers a notify e-mail by admin created a account. +func SendRegisterNotifyMail(u *user_model.User) { + if setting.MailService == nil || !u.IsActive { + // No mail service configured OR user is inactive + return + } + locale := translation.NewLocale(u.Language) + + data := map[string]any{ + "locale": locale, + "DisplayName": u.DisplayName(), + "Username": u.Name, + "Language": locale.Language(), + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil { + log.Error("Template: %v", err) + return + } + + msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String()) + msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID) + + SendAsync(msg) +} + +// SendCollaboratorMail sends mail notification to new collaborator. +func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) { + if setting.MailService == nil || !u.IsActive { + // No mail service configured OR the user is inactive + return + } + locale := translation.NewLocale(u.Language) + repoName := repo.FullName() + + subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName) + data := map[string]any{ + "locale": locale, + "Subject": subject, + "RepoName": repoName, + "Link": repo.HTMLURL(), + "Language": locale.Language(), + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil { + log.Error("Template: %v", err) + return + } + + msg := sender_service.NewMessage(u.EmailTo(), subject, content.String()) + msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID) + + SendAsync(msg) +} From dbed39d63292517918c3b1a74aeb71ac8910beb7 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 2 Mar 2025 21:01:28 -0800 Subject: [PATCH 04/47] Add migrations and doctor fixes (#33556) Fix #33535 --- models/actions/runner.go | 14 ++++++++++++++ models/actions/variable.go | 14 ++++++++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_24/v314.go | 19 +++++++++++++++++++ models/secret/secret.go | 14 ++++++++++++++ services/doctor/dbconsistency.go | 19 +++++++++++++++++++ 6 files changed, 81 insertions(+) create mode 100644 models/migrations/v1_24/v314.go diff --git a/models/actions/runner.go b/models/actions/runner.go index 798a647180..97db0ca7ea 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -337,3 +337,17 @@ func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) { } return res.RowsAffected() } + +func CountWrongRepoLevelRunners(ctx context.Context) (int64, error) { + var result int64 + _, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `action_runner` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result) + return result, err +} + +func UpdateWrongRepoLevelRunners(ctx context.Context) (int64, error) { + result, err := db.GetEngine(ctx).Exec("UPDATE `action_runner` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0") + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/models/actions/variable.go b/models/actions/variable.go index 163bb12c93..1929cffbd8 100644 --- a/models/actions/variable.go +++ b/models/actions/variable.go @@ -147,3 +147,17 @@ func GetVariablesOfRun(ctx context.Context, run *ActionRun) (map[string]string, return variables, nil } + +func CountWrongRepoLevelVariables(ctx context.Context) (int64, error) { + var result int64 + _, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `action_variable` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result) + return result, err +} + +func UpdateWrongRepoLevelVariables(ctx context.Context) (int64, error) { + result, err := db.GetEngine(ctx).Exec("UPDATE `action_variable` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0") + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 87d674a440..fd291c5692 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -374,6 +374,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.23.0-rc0 ends at migration ID number 311 (database version 312) newMigration(312, "Add DeleteBranchAfterMerge to AutoMerge", v1_24.AddDeleteBranchAfterMergeForAutoMerge), newMigration(313, "Move PinOrder from issue table to a new table issue_pin", v1_24.MovePinOrderToTableIssuePin), + newMigration(314, "Update OwnerID as zero for repository level action tables", v1_24.UpdateOwnerIDOfRepoLevelActionsTables), } return preparedMigrations } diff --git a/models/migrations/v1_24/v314.go b/models/migrations/v1_24/v314.go new file mode 100644 index 0000000000..e537be13b5 --- /dev/null +++ b/models/migrations/v1_24/v314.go @@ -0,0 +1,19 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_24 //nolint + +import ( + "xorm.io/xorm" +) + +func UpdateOwnerIDOfRepoLevelActionsTables(x *xorm.Engine) error { + if _, err := x.Exec("UPDATE `action_runner` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0"); err != nil { + return err + } + if _, err := x.Exec("UPDATE `action_variable` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0"); err != nil { + return err + } + _, err := x.Exec("UPDATE `secret` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0") + return err +} diff --git a/models/secret/secret.go b/models/secret/secret.go index ce0ad65a79..eab9cf0712 100644 --- a/models/secret/secret.go +++ b/models/secret/secret.go @@ -165,3 +165,17 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[ return secrets, nil } + +func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) { + var result int64 + _, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `secret` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result) + return result, err +} + +func UpdateWrongRepoLevelSecrets(ctx context.Context) (int64, error) { + result, err := db.GetEngine(ctx).Exec("UPDATE `secret` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0") + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/services/doctor/dbconsistency.go b/services/doctor/dbconsistency.go index 7cb7445148..62326ed07c 100644 --- a/services/doctor/dbconsistency.go +++ b/services/doctor/dbconsistency.go @@ -12,6 +12,7 @@ import ( issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/migrations" repo_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" ) @@ -164,6 +165,24 @@ func prepareDBConsistencyChecks() []consistencyCheck { Fixer: repo_model.DeleteOrphanedTopics, FixedMessage: "Removed", }, + { + Name: "Repository level Runners with non-zero owner_id", + Counter: actions_model.CountWrongRepoLevelRunners, + Fixer: actions_model.UpdateWrongRepoLevelRunners, + FixedMessage: "Corrected", + }, + { + Name: "Repository level Variables with non-zero owner_id", + Counter: actions_model.CountWrongRepoLevelVariables, + Fixer: actions_model.UpdateWrongRepoLevelVariables, + FixedMessage: "Corrected", + }, + { + Name: "Repository level Secrets with non-zero owner_id", + Counter: secret_model.CountWrongRepoLevelSecrets, + Fixer: secret_model.UpdateWrongRepoLevelSecrets, + FixedMessage: "Corrected", + }, } // TODO: function to recalc all counters From 216243eee299a973e4504fc210e07c168376934e Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 3 Mar 2025 13:36:10 +0800 Subject: [PATCH 05/47] Refactor error system (#33771) It should not expose `util.SilentWrap` or construct it manually. --- models/asymkey/error.go | 2 +- models/db/name.go | 2 +- models/repo/archiver.go | 6 ++-- models/user/must_change_password.go | 2 +- modules/packages/conda/metadata.go | 6 ++-- modules/translation/i18n/errors.go | 13 -------- modules/translation/i18n/format.go | 3 +- modules/translation/i18n/localestore.go | 3 +- modules/util/error.go | 40 ++++++++++++------------- routers/web/repo/actions/view.go | 2 +- services/actions/workflow.go | 10 +++---- services/release/release.go | 10 ++----- 12 files changed, 41 insertions(+), 58 deletions(-) delete mode 100644 modules/translation/i18n/errors.go diff --git a/models/asymkey/error.go b/models/asymkey/error.go index 2e65d76612..1ed6edd71a 100644 --- a/models/asymkey/error.go +++ b/models/asymkey/error.go @@ -25,7 +25,7 @@ func (err ErrKeyUnableVerify) Error() string { } // ErrKeyIsPrivate is returned when the provided key is a private key not a public key -var ErrKeyIsPrivate = util.NewSilentWrapErrorf(util.ErrInvalidArgument, "the provided key is a private key") +var ErrKeyIsPrivate = util.ErrorWrap(util.ErrInvalidArgument, "the provided key is a private key") // ErrKeyNotExist represents a "KeyNotExist" kind of error. type ErrKeyNotExist struct { diff --git a/models/db/name.go b/models/db/name.go index e2165fd76b..0e11c78372 100644 --- a/models/db/name.go +++ b/models/db/name.go @@ -77,7 +77,7 @@ func (err ErrNameCharsNotAllowed) Unwrap() error { func IsUsableName(reservedNames, reservedPatterns []string, name string) error { name = strings.TrimSpace(strings.ToLower(name)) if utf8.RuneCountInString(name) == 0 { - return util.SilentWrap{Message: "name is empty", Err: util.ErrInvalidArgument} + return util.NewInvalidArgumentErrorf("name is empty") } for i := range reservedNames { diff --git a/models/repo/archiver.go b/models/repo/archiver.go index 5a3eac9f14..d06e94e5ac 100644 --- a/models/repo/archiver.go +++ b/models/repo/archiver.go @@ -50,15 +50,15 @@ func (archiver *RepoArchiver) RelativePath() string { func repoArchiverForRelativePath(relativePath string) (*RepoArchiver, error) { parts := strings.SplitN(relativePath, "/", 3) if len(parts) != 3 { - return nil, util.SilentWrap{Message: fmt.Sprintf("invalid storage path: %s", relativePath), Err: util.ErrInvalidArgument} + return nil, util.NewInvalidArgumentErrorf("invalid storage path: must have 3 parts") } repoID, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { - return nil, util.SilentWrap{Message: fmt.Sprintf("invalid storage path: %s", relativePath), Err: util.ErrInvalidArgument} + return nil, util.NewInvalidArgumentErrorf("invalid storage path: invalid repo id") } commitID, archiveType := git.SplitArchiveNameType(parts[2]) if archiveType == git.ArchiveUnknown { - return nil, util.SilentWrap{Message: fmt.Sprintf("invalid storage path: %s", relativePath), Err: util.ErrInvalidArgument} + return nil, util.NewInvalidArgumentErrorf("invalid storage path: invalid archive type") } return &RepoArchiver{RepoID: repoID, CommitID: commitID, Type: archiveType}, nil } diff --git a/models/user/must_change_password.go b/models/user/must_change_password.go index 7eab08de89..686847c7d7 100644 --- a/models/user/must_change_password.go +++ b/models/user/must_change_password.go @@ -34,7 +34,7 @@ func SetMustChangePassword(ctx context.Context, all, mustChangePassword bool, in if !all { include = sliceTrimSpaceDropEmpty(include) if len(include) == 0 { - return 0, util.NewSilentWrapErrorf(util.ErrInvalidArgument, "no users to include provided") + return 0, util.ErrorWrap(util.ErrInvalidArgument, "no users to include provided") } cond = cond.And(builder.In("lower_name", include)) diff --git a/modules/packages/conda/metadata.go b/modules/packages/conda/metadata.go index 76ba95eace..5eb2890115 100644 --- a/modules/packages/conda/metadata.go +++ b/modules/packages/conda/metadata.go @@ -17,9 +17,9 @@ import ( ) var ( - ErrInvalidStructure = util.SilentWrap{Message: "package structure is invalid", Err: util.ErrInvalidArgument} - ErrInvalidName = util.SilentWrap{Message: "package name is invalid", Err: util.ErrInvalidArgument} - ErrInvalidVersion = util.SilentWrap{Message: "package version is invalid", Err: util.ErrInvalidArgument} + ErrInvalidStructure = util.NewInvalidArgumentErrorf("package structure is invalid") + ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid") + ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid") ) const ( diff --git a/modules/translation/i18n/errors.go b/modules/translation/i18n/errors.go deleted file mode 100644 index 7f64ccf908..0000000000 --- a/modules/translation/i18n/errors.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package i18n - -import ( - "code.gitea.io/gitea/modules/util" -) - -var ( - ErrLocaleAlreadyExist = util.SilentWrap{Message: "lang already exists", Err: util.ErrAlreadyExist} - ErrUncertainArguments = util.SilentWrap{Message: "arguments to i18n should not contain uncertain slices", Err: util.ErrInvalidArgument} -) diff --git a/modules/translation/i18n/format.go b/modules/translation/i18n/format.go index e5e221831f..450509333f 100644 --- a/modules/translation/i18n/format.go +++ b/modules/translation/i18n/format.go @@ -4,6 +4,7 @@ package i18n import ( + "errors" "fmt" "reflect" ) @@ -30,7 +31,7 @@ func Format(format string, args ...any) (msg string, err error) { fmtArgs = append(fmtArgs, val.Index(i).Interface()) } } else { - err = ErrUncertainArguments + err = errors.New("arguments to i18n should not contain uncertain slices") break } } else { diff --git a/modules/translation/i18n/localestore.go b/modules/translation/i18n/localestore.go index b422996984..4f1ae7e13d 100644 --- a/modules/translation/i18n/localestore.go +++ b/modules/translation/i18n/localestore.go @@ -4,6 +4,7 @@ package i18n import ( + "errors" "fmt" "html/template" "slices" @@ -41,7 +42,7 @@ func NewLocaleStore() LocaleStore { // AddLocaleByIni adds locale by ini into the store func (store *localeStore) AddLocaleByIni(langName, langDesc string, source, moreSource []byte) error { if _, ok := store.localeMap[langName]; ok { - return ErrLocaleAlreadyExist + return errors.New("lang has already been added") } store.langNames = append(store.langNames, langName) diff --git a/modules/util/error.go b/modules/util/error.go index 13b592a9f8..8e67d5a82f 100644 --- a/modules/util/error.go +++ b/modules/util/error.go @@ -22,74 +22,74 @@ var ( ErrUnprocessableContent = errors.New("unprocessable content") ) -// SilentWrap provides a simple wrapper for a wrapped error where the wrapped error message plays no part in the error message +// errorWrapper provides a simple wrapper for a wrapped error where the wrapped error message plays no part in the error message // Especially useful for "untyped" errors created with "errors.New(…)" that can be classified as 'invalid argument', 'permission denied', 'exists already', or 'does not exist' -type SilentWrap struct { +type errorWrapper struct { Message string Err error } // Error returns the message -func (w SilentWrap) Error() string { +func (w errorWrapper) Error() string { return w.Message } // Unwrap returns the underlying error -func (w SilentWrap) Unwrap() error { +func (w errorWrapper) Unwrap() error { return w.Err } -type LocaleWrap struct { +type LocaleWrapper struct { err error TrKey string TrArgs []any } // Error returns the message -func (w LocaleWrap) Error() string { +func (w LocaleWrapper) Error() string { return w.err.Error() } // Unwrap returns the underlying error -func (w LocaleWrap) Unwrap() error { +func (w LocaleWrapper) Unwrap() error { return w.err } -// NewSilentWrapErrorf returns an error that formats as the given text but unwraps as the provided error -func NewSilentWrapErrorf(unwrap error, message string, args ...any) error { +// ErrorWrap returns an error that formats as the given text but unwraps as the provided error +func ErrorWrap(unwrap error, message string, args ...any) error { if len(args) == 0 { - return SilentWrap{Message: message, Err: unwrap} + return errorWrapper{Message: message, Err: unwrap} } - return SilentWrap{Message: fmt.Sprintf(message, args...), Err: unwrap} + return errorWrapper{Message: fmt.Sprintf(message, args...), Err: unwrap} } // NewInvalidArgumentErrorf returns an error that formats as the given text but unwraps as an ErrInvalidArgument func NewInvalidArgumentErrorf(message string, args ...any) error { - return NewSilentWrapErrorf(ErrInvalidArgument, message, args...) + return ErrorWrap(ErrInvalidArgument, message, args...) } // NewPermissionDeniedErrorf returns an error that formats as the given text but unwraps as an ErrPermissionDenied func NewPermissionDeniedErrorf(message string, args ...any) error { - return NewSilentWrapErrorf(ErrPermissionDenied, message, args...) + return ErrorWrap(ErrPermissionDenied, message, args...) } // NewAlreadyExistErrorf returns an error that formats as the given text but unwraps as an ErrAlreadyExist func NewAlreadyExistErrorf(message string, args ...any) error { - return NewSilentWrapErrorf(ErrAlreadyExist, message, args...) + return ErrorWrap(ErrAlreadyExist, message, args...) } // NewNotExistErrorf returns an error that formats as the given text but unwraps as an ErrNotExist func NewNotExistErrorf(message string, args ...any) error { - return NewSilentWrapErrorf(ErrNotExist, message, args...) + return ErrorWrap(ErrNotExist, message, args...) } -// ErrWrapLocale wraps an err with a translation key and arguments -func ErrWrapLocale(err error, trKey string, trArgs ...any) error { - return LocaleWrap{err: err, TrKey: trKey, TrArgs: trArgs} +// ErrorWrapLocale wraps an err with a translation key and arguments +func ErrorWrapLocale(err error, trKey string, trArgs ...any) error { + return LocaleWrapper{err: err, TrKey: trKey, TrArgs: trArgs} } -func ErrAsLocale(err error) *LocaleWrap { - var e LocaleWrap +func ErrorAsLocale(err error) *LocaleWrapper { + var e LocaleWrapper if errors.As(err, &e) { return &e } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index b27f8e0e7a..4c39cb284f 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -789,7 +789,7 @@ func Run(ctx *context_module.Context) { return nil }) if err != nil { - if errLocale := util.ErrAsLocale(err); errLocale != nil { + if errLocale := util.ErrorAsLocale(err); errLocale != nil { ctx.Flash.Error(ctx.Tr(errLocale.TrKey, errLocale.TrArgs...)) ctx.Redirect(redirectURL) } else { diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 9aecad171b..ccdefa802d 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -141,14 +141,14 @@ func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionW func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error { if workflowID == "" { - return util.ErrWrapLocale( + return util.ErrorWrapLocale( util.NewNotExistErrorf("workflowID is empty"), "actions.workflow.not_found", workflowID, ) } if ref == "" { - return util.ErrWrapLocale( + return util.ErrorWrapLocale( util.NewNotExistErrorf("ref is empty"), "form.target_ref_not_exist", ref, ) @@ -158,7 +158,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() if cfg.IsWorkflowDisabled(workflowID) { - return util.ErrWrapLocale( + return util.ErrorWrapLocale( util.NewPermissionDeniedErrorf("workflow is disabled"), "actions.workflow.disabled", ) @@ -177,7 +177,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re runTargetCommit, err = gitRepo.GetBranchCommit(ref) } if err != nil { - return util.ErrWrapLocale( + return util.ErrorWrapLocale( util.NewNotExistErrorf("ref %q doesn't exist", ref), "form.target_ref_not_exist", ref, ) @@ -208,7 +208,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re } if len(workflows) == 0 { - return util.ErrWrapLocale( + return util.ErrorWrapLocale( util.NewNotExistErrorf("workflow %q doesn't exist", workflowID), "actions.workflow.not_found", workflowID, ) diff --git a/services/release/release.go b/services/release/release.go index 835a5943b1..b98ba7febb 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -296,10 +296,7 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo } for _, attach := range attachments { if attach.ReleaseID != rel.ID { - return util.SilentWrap{ - Message: "delete attachment of release permission denied", - Err: util.ErrPermissionDenied, - } + return util.NewPermissionDeniedErrorf("delete attachment of release permission denied") } deletedUUIDs.Add(attach.UUID) } @@ -321,10 +318,7 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo } for _, attach := range attachments { if attach.ReleaseID != rel.ID { - return util.SilentWrap{ - Message: "update attachment of release permission denied", - Err: util.ErrPermissionDenied, - } + return util.NewPermissionDeniedErrorf("update attachment of release permission denied") } } From 43c8d85f19e14e695eb129a8ff71bd106430b016 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 3 Mar 2025 07:03:42 +0100 Subject: [PATCH 06/47] Disable `vet` as part of `go test` (#33662) `go vet` implicitely runs as part of `go test`, but we already have `make lint-go-vet`, so we were essentially running it twice. This should hopefully make `go test` slightly faster. Ref: https://pkg.go.dev/cmd/go#hdr-Test_packages > As part of building a test binary, go test runs go vet --- .github/workflows/pull-compliance.yml | 2 +- Makefile | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index 7e988e0449..64090d6490 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -95,7 +95,7 @@ jobs: go-version-file: go.mod check-latest: true - run: make deps-backend deps-tools - - run: make lint-go-windows lint-go-vet + - run: make lint-go-windows lint-go-gitea-vet env: TAGS: bindata sqlite sqlite_unlock_notify GOOS: windows diff --git a/Makefile b/Makefile index d4b416156f..0cc9269771 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,7 @@ EXTRA_GOFLAGS ?= MAKE_VERSION := $(shell "$(MAKE)" -v | cat | head -n 1) MAKE_EVIDENCE_DIR := .make_evidence +GOTESTFLAGS ?= -vet=off ifeq ($(RACE_ENABLED),true) GOFLAGS += -race GOTESTFLAGS += -race @@ -311,10 +312,10 @@ lint-frontend: lint-js lint-css ## lint frontend files lint-frontend-fix: lint-js-fix lint-css-fix ## lint frontend files and fix issues .PHONY: lint-backend -lint-backend: lint-go lint-go-vet lint-go-gopls lint-editorconfig ## lint backend files +lint-backend: lint-go lint-go-gitea-vet lint-go-gopls lint-editorconfig ## lint backend files .PHONY: lint-backend-fix -lint-backend-fix: lint-go-fix lint-go-vet lint-editorconfig ## lint backend files and fix issues +lint-backend-fix: lint-go-fix lint-go-gitea-vet lint-editorconfig ## lint backend files and fix issues .PHONY: lint-js lint-js: node_modules ## lint js files @@ -365,9 +366,9 @@ lint-go-windows: @GOOS= GOARCH= $(GO) install $(GOLANGCI_LINT_PACKAGE) golangci-lint run -.PHONY: lint-go-vet -lint-go-vet: ## lint go files with vet - @echo "Running go vet..." +.PHONY: lint-go-gitea-vet +lint-go-gitea-vet: ## lint go files with gitea-vet + @echo "Running gitea-vet..." @GOOS= GOARCH= $(GO) build code.gitea.io/gitea-vet @$(GO) vet -vettool=gitea-vet ./... From f0f1737d4d533e06f482a63ff995ae79cc1c89dc Mon Sep 17 00:00:00 2001 From: Kerwin Bryant Date: Tue, 4 Mar 2025 03:49:15 +0800 Subject: [PATCH 07/47] Refactor markup and pdf-viewer to use new init framework (#33772) 1. Add some "render-content" classes to "markup" elements when the content is rendered 2. Use correct "markup" wrapper for "preview" (but not set that class on the tab) 3. Remove incorrect "markup" class from LFS file view, because there is no markup content * "edit-diff" is also removed because it does nothing 5. Use "initPdfViewer" for PDF viewer 6. Remove incorrect "content" class from milestone markup 7. Init all ".markup" elements by new init framework --------- Co-authored-by: wxiaoguang --- templates/org/home.tmpl | 2 +- templates/projects/list.tmpl | 4 +- templates/repo/editor/edit.tmpl | 4 +- templates/repo/issue/fields/markdown.tmpl | 2 +- templates/repo/issue/milestone_issues.tmpl | 2 +- templates/repo/issue/milestones.tmpl | 4 +- templates/repo/release/list.tmpl | 2 +- templates/repo/settings/lfs_file.tmpl | 4 +- templates/repo/view_file.tmpl | 2 +- templates/repo/wiki/view.tmpl | 8 +- templates/shared/combomarkdowneditor.tmpl | 2 +- templates/user/dashboard/feeds.tmpl | 2 +- templates/user/dashboard/milestones.tmpl | 6 +- templates/user/profile.tmpl | 2 +- web_src/css/editor/fileeditor.css | 9 -- web_src/css/markup/content.css | 2 +- web_src/css/shared/milestone.css | 2 +- web_src/js/features/repo-editor.ts | 4 +- web_src/js/features/repo-issue-edit.ts | 3 - web_src/js/features/repo-wiki.ts | 4 +- web_src/js/index.ts | 3 +- web_src/js/markup/asciicast.ts | 18 ++- web_src/js/markup/codecopy.ts | 17 ++- web_src/js/markup/content.ts | 25 ++-- web_src/js/markup/math.ts | 42 ++++--- web_src/js/markup/mermaid.ts | 108 +++++++++--------- web_src/js/markup/tasklist.ts | 126 ++++++++++----------- web_src/js/modules/observer.ts | 3 + web_src/js/render/pdf.ts | 10 +- 29 files changed, 195 insertions(+), 227 deletions(-) diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 826642db42..cffdfabfaa 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -6,7 +6,7 @@
{{if .ProfileReadmeContent}} -
{{.ProfileReadmeContent}}
+
{{.ProfileReadmeContent}}
{{end}} {{template "shared/repo_search" .}} {{template "explore/repo_list" .}} diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl index 5d40653dc6..48083811e7 100644 --- a/templates/projects/list.tmpl +++ b/templates/projects/list.tmpl @@ -74,9 +74,7 @@ {{end}}
{{if .Description}} -
- {{.RenderedContent}} -
+
{{.RenderedContent}}
{{end}} {{end}} diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl index 577a2be9ad..ae8a60c20c 100644 --- a/templates/repo/editor/edit.tmpl +++ b/templates/repo/editor/edit.tmpl @@ -45,10 +45,10 @@ data-line-wrap-extensions="{{.LineWrapExtensions}}">{{.FileContent}}
-
+
{{ctx.Locale.Tr "loading"}}
-
+
diff --git a/templates/repo/issue/fields/markdown.tmpl b/templates/repo/issue/fields/markdown.tmpl index da8f5e6bdf..dbf4b71ba8 100644 --- a/templates/repo/issue/fields/markdown.tmpl +++ b/templates/repo/issue/fields/markdown.tmpl @@ -1,3 +1,3 @@
-
{{ctx.RenderUtils.MarkdownToHtml .item.Attributes.value}}
+
{{ctx.RenderUtils.MarkdownToHtml .item.Attributes.value}}
diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl index abb4e3290d..ac5d7f16dd 100644 --- a/templates/repo/issue/milestone_issues.tmpl +++ b/templates/repo/issue/milestone_issues.tmpl @@ -22,7 +22,7 @@ {{end}}
{{if .Milestone.RenderedContent}} -
+
{{.Milestone.RenderedContent}}
{{end}} diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl index e7dfe08ee0..5701c1faa6 100644 --- a/templates/repo/issue/milestones.tmpl +++ b/templates/repo/issue/milestones.tmpl @@ -81,9 +81,7 @@ {{end}}
{{if .Content}} -
- {{.RenderedContent}} -
+
{{.RenderedContent}}
{{end}} {{end}} diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index 041890ca9c..88bd85ef4d 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -64,7 +64,7 @@ | {{ctx.Locale.Tr "repo.release.ahead.commits" $release.NumCommitsBehind}} {{ctx.Locale.Tr "repo.release.ahead.target" $release.TargetBehind}} {{end}}

-
+
{{$release.RenderedNote}}
diff --git a/templates/repo/settings/lfs_file.tmpl b/templates/repo/settings/lfs_file.tmpl index f6fac05b69..9f72d764ae 100644 --- a/templates/repo/settings/lfs_file.tmpl +++ b/templates/repo/settings/lfs_file.tmpl @@ -13,7 +13,7 @@
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}} -
+
{{if .IsFileTooLarge}} {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} {{else if not .FileSize}} @@ -31,7 +31,7 @@ {{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}} {{else if .IsPDFFile}} -
+
{{else}} {{ctx.Locale.Tr "repo.file_view_raw"}} {{end}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 4907d87301..9f1b2a5e8f 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -108,7 +108,7 @@ {{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}} {{else if .IsPDFFile}} -
+
{{else}} {{ctx.Locale.Tr "repo.file_view_raw"}} {{end}} diff --git a/templates/repo/wiki/view.tmpl b/templates/repo/wiki/view.tmpl index 843a977e3e..efb614280a 100644 --- a/templates/repo/wiki/view.tmpl +++ b/templates/repo/wiki/view.tmpl @@ -63,18 +63,18 @@
{{if .sidebarTocContent}} -