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;