From b5326a431f8b5c1c0fde524a50d75a4e19833e68 Mon Sep 17 00:00:00 2001 From: Chl <42654312+xlii-chl@users.noreply.github.com> Date: Tue, 25 Jun 2024 19:09:13 +0200 Subject: [PATCH 01/59] Optimization of labels handling in issue_search (#26460) This PR enhances the labels handling in issue_search by optimizing the SQL query and de-duplicate the IDs when generating the query string. --------- Co-authored-by: techknowlogick --- models/issues/issue_search.go | 26 ++++++++++++++++++++++---- models/issues/label.go | 21 ++++++++++++++++----- models/issues/label_test.go | 21 +++++++++++++++++++++ 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index c1d7d921a948..b238f831ae54 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -6,6 +6,7 @@ package issues import ( "context" "fmt" + "strconv" "strings" "code.gitea.io/gitea/models/db" @@ -13,6 +14,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" "xorm.io/builder" @@ -116,14 +118,30 @@ func applyLabelsCondition(sess *xorm.Session, opts *IssuesOptions) { if opts.LabelIDs[0] == 0 { sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_label)") } else { - for i, labelID := range opts.LabelIDs { + // We sort and deduplicate the labels' ids + IncludedLabelIDs := make(container.Set[int64]) + ExcludedLabelIDs := make(container.Set[int64]) + for _, labelID := range opts.LabelIDs { if labelID > 0 { - sess.Join("INNER", fmt.Sprintf("issue_label il%d", i), - fmt.Sprintf("issue.id = il%[1]d.issue_id AND il%[1]d.label_id = %[2]d", i, labelID)) + IncludedLabelIDs.Add(labelID) } else if labelID < 0 { // 0 is not supported here, so just ignore it - sess.Where("issue.id not in (select issue_id from issue_label where label_id = ?)", -labelID) + ExcludedLabelIDs.Add(-labelID) } } + // ... and use them in a subquery of the form : + // where (select count(*) from issue_label where issue_id=issue.id and label_id in (2, 4, 6)) = 3 + // This equality is guaranteed thanks to unique index (issue_id,label_id) on table issue_label. + if len(IncludedLabelIDs) > 0 { + subquery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")). + And(builder.In("label_id", IncludedLabelIDs.Values())) + sess.Where(builder.Eq{strconv.Itoa(len(IncludedLabelIDs)): subquery}) + } + // or (select count(*)...) = 0 for excluded labels + if len(ExcludedLabelIDs) > 0 { + subquery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")). + And(builder.In("label_id", ExcludedLabelIDs.Values())) + sess.Where(builder.Eq{"0": subquery}) + } } } diff --git a/models/issues/label.go b/models/issues/label.go index 2397a29e357e..2d7acb7f0c53 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -7,6 +7,7 @@ package issues import ( "context" "fmt" + "slices" "strconv" "strings" @@ -142,9 +143,8 @@ func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) { // LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked func (l *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) { - var labelQuerySlice []string + labelQuerySlice := []int64{} labelSelected := false - labelID := strconv.FormatInt(l.ID, 10) labelScope := l.ExclusiveScope() for i, s := range currentSelectedLabels { if s == l.ID { @@ -155,15 +155,26 @@ func (l *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, curr } else if s != 0 { // Exclude other labels in the same scope from selection if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] { - labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10)) + labelQuerySlice = append(labelQuerySlice, s) } } } + if !labelSelected { - labelQuerySlice = append(labelQuerySlice, labelID) + labelQuerySlice = append(labelQuerySlice, l.ID) } l.IsSelected = labelSelected - l.QueryString = strings.Join(labelQuerySlice, ",") + + // Sort and deduplicate the ids to avoid the crawlers asking for the + // same thing with simply a different order of parameters + slices.Sort(labelQuerySlice) + labelQuerySlice = slices.Compact(labelQuerySlice) + // Quick conversion (strings.Join() doesn't accept slices of Int64) + labelQuerySliceStrings := make([]string, len(labelQuerySlice)) + for i, x := range labelQuerySlice { + labelQuerySliceStrings[i] = strconv.FormatInt(x, 10) + } + l.QueryString = strings.Join(labelQuerySliceStrings, ",") } // BelongsToOrg returns true if label is an organization label diff --git a/models/issues/label_test.go b/models/issues/label_test.go index 517a3cf1abd4..396de809e1d8 100644 --- a/models/issues/label_test.go +++ b/models/issues/label_test.go @@ -23,6 +23,27 @@ func TestLabel_CalOpenIssues(t *testing.T) { assert.EqualValues(t, 2, label.NumOpenIssues) } +func TestLabel_LoadSelectedLabelsAfterClick(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + // Loading the label id:8 which have a scope and an exclusivity + label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8}) + + // First test : with negative and scope + label.LoadSelectedLabelsAfterClick([]int64{1, -8}, []string{"", "scope"}) + assert.Equal(t, "1", label.QueryString) + assert.Equal(t, true, label.IsSelected) + + // Second test : with duplicates + label.LoadSelectedLabelsAfterClick([]int64{1, 7, 1, 7, 7}, []string{"", "scope", "", "scope", "scope"}) + assert.Equal(t, "1,8", label.QueryString) + assert.Equal(t, false, label.IsSelected) + + // Third test : empty set + label.LoadSelectedLabelsAfterClick([]int64{}, []string{}) + assert.False(t, label.IsSelected) + assert.Equal(t, "8", label.QueryString) +} + func TestLabel_ExclusiveScope(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7}) From 35ce7a5e0e03f9a2ce14a6f34ef6ac839e36aacd Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Wed, 26 Jun 2024 00:26:17 +0000 Subject: [PATCH 02/59] [skip ci] Updated translations via Crowdin --- options/locale/locale_fr-FR.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index 44bfdfa26f83..ae32b861f675 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -2562,7 +2562,7 @@ release.stable=Stable release.compare=Comparer release.edit=Éditer release.ahead.commits=%d révisions -release.ahead.target=à %s depuis cette publication +release.ahead.target=sur %s depuis cette publication tag.ahead.target=à %s depuis cette étiquette release.source_code=Code source release.new_subheader=Les publications vous aide à organiser les versions marquantes de votre projet. From a88f718c1055c7b5a3c3e77e04d433c77ab6d12e Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 27 Jun 2024 01:01:20 +0800 Subject: [PATCH 03/59] Refactor dropzone (#31482) Refactor the legacy code and remove some jQuery calls. --- .../js/features/comp/ComboMarkdownEditor.js | 22 ++- web_src/js/features/dropzone.js | 164 ++++++++++++------ web_src/js/features/repo-editor.js | 12 +- web_src/js/features/repo-issue-edit.js | 114 ++---------- web_src/js/features/repo-issue.js | 27 +-- web_src/js/index.js | 2 - web_src/js/utils/dom.js | 14 ++ web_src/js/utils/dom.test.js | 13 +- 8 files changed, 184 insertions(+), 184 deletions(-) diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index f40b0bdc17bc..bd11c8383c35 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -11,6 +11,7 @@ import {initTextExpander} from './TextExpander.js'; import {showErrorToast} from '../../modules/toast.js'; import {POST} from '../../modules/fetch.js'; import {initTextareaMarkdown} from './EditorMarkdown.js'; +import {initDropzone} from '../dropzone.js'; let elementIdCounter = 0; @@ -47,7 +48,7 @@ class ComboMarkdownEditor { this.prepareEasyMDEToolbarActions(); this.setupContainer(); this.setupTab(); - this.setupDropzone(); + await this.setupDropzone(); // textarea depends on dropzone this.setupTextarea(); await this.switchToUserPreference(); @@ -114,13 +115,30 @@ class ComboMarkdownEditor { } } - setupDropzone() { + async setupDropzone() { const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container'); if (dropzoneParentContainer) { this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone'); + if (this.dropzone) this.attachedDropzoneInst = await initDropzone(this.dropzone); } } + dropzoneGetFiles() { + if (!this.dropzone) return null; + return Array.from(this.dropzone.querySelectorAll('.files [name=files]'), (el) => el.value); + } + + dropzoneReloadFiles() { + if (!this.dropzone) return; + this.attachedDropzoneInst.emit('reload'); + } + + dropzoneSubmitReload() { + if (!this.dropzone) return; + this.attachedDropzoneInst.emit('submit'); + this.attachedDropzoneInst.emit('reload'); + } + setupTab() { const tabs = this.container.querySelectorAll('.tabular.menu > .item'); diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js index b3acaf5e6f09..8d70fc774b19 100644 --- a/web_src/js/features/dropzone.js +++ b/web_src/js/features/dropzone.js @@ -1,14 +1,14 @@ -import $ from 'jquery'; import {svg} from '../svg.js'; import {htmlEscape} from 'escape-goat'; import {clippie} from 'clippie'; import {showTemporaryTooltip} from '../modules/tippy.js'; -import {POST} from '../modules/fetch.js'; +import {GET, POST} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; +import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.js'; const {csrfToken, i18n} = window.config; -export async function createDropzone(el, opts) { +async function createDropzone(el, opts) { const [{Dropzone}] = await Promise.all([ import(/* webpackChunkName: "dropzone" */'dropzone'), import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'), @@ -16,65 +16,119 @@ export async function createDropzone(el, opts) { return new Dropzone(el, opts); } -export function initGlobalDropzone() { - for (const el of document.querySelectorAll('.dropzone')) { - initDropzone(el); - } +function addCopyLink(file) { + // Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard + // The "" element has a hardcoded cursor: pointer because the default is overridden by .dropzone + const copyLinkEl = createElementFromHTML(` +`); + copyLinkEl.addEventListener('click', async (e) => { + e.preventDefault(); + let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; + if (file.type?.startsWith('image/')) { + fileMarkdown = `!${fileMarkdown}`; + } else if (file.type?.startsWith('video/')) { + fileMarkdown = ``; + } + const success = await clippie(fileMarkdown); + showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); + }); + file.previewTemplate.append(copyLinkEl); } -export function initDropzone(el) { - const $dropzone = $(el); - const _promise = createDropzone(el, { - url: $dropzone.data('upload-url'), +/** + * @param {HTMLElement} dropzoneEl + */ +export async function initDropzone(dropzoneEl) { + const listAttachmentsUrl = dropzoneEl.closest('[data-attachment-url]')?.getAttribute('data-attachment-url'); + const removeAttachmentUrl = dropzoneEl.getAttribute('data-remove-url'); + const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url'); + + let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event + let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone + const opts = { + url: dropzoneEl.getAttribute('data-upload-url'), headers: {'X-Csrf-Token': csrfToken}, - maxFiles: $dropzone.data('max-file'), - maxFilesize: $dropzone.data('max-size'), - acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), + acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')) ? null : dropzoneEl.getAttribute('data-accepts'), addRemoveLinks: true, - dictDefaultMessage: $dropzone.data('default-message'), - dictInvalidFileType: $dropzone.data('invalid-input-type'), - dictFileTooBig: $dropzone.data('file-too-big'), - dictRemoveFile: $dropzone.data('remove-file'), + dictDefaultMessage: dropzoneEl.getAttribute('data-default-message'), + dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type'), + dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big'), + dictRemoveFile: dropzoneEl.getAttribute('data-remove-file'), timeout: 0, thumbnailMethod: 'contain', thumbnailWidth: 480, thumbnailHeight: 480, - init() { - this.on('success', (file, data) => { - file.uuid = data.uuid; - const $input = $(``).val(data.uuid); - $dropzone.find('.files').append($input); - // Create a "Copy Link" element, to conveniently copy the image - // or file link as Markdown to the clipboard - const copyLinkElement = document.createElement('div'); - copyLinkElement.className = 'tw-text-center'; - // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone - copyLinkElement.innerHTML = `${svg('octicon-copy', 14, 'copy link')} Copy link`; - copyLinkElement.addEventListener('click', async (e) => { - e.preventDefault(); - let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; - if (file.type.startsWith('image/')) { - fileMarkdown = `!${fileMarkdown}`; - } else if (file.type.startsWith('video/')) { - fileMarkdown = ``; - } - const success = await clippie(fileMarkdown); - showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); - }); - file.previewTemplate.append(copyLinkElement); - }); - this.on('removedfile', (file) => { - $(`#${file.uuid}`).remove(); - if ($dropzone.data('remove-url')) { - POST($dropzone.data('remove-url'), { - data: new URLSearchParams({file: file.uuid}), - }); - } - }); - this.on('error', function (file, message) { - showErrorToast(message); - this.removeFile(file); - }); - }, + }; + if (dropzoneEl.hasAttribute('data-max-file')) opts.maxFiles = Number(dropzoneEl.getAttribute('data-max-file')); + if (dropzoneEl.hasAttribute('data-max-size')) opts.maxFilesize = Number(dropzoneEl.getAttribute('data-max-size')); + + // there is a bug in dropzone: if a non-image file is uploaded, then it tries to request the file from server by something like: + // "http://localhost:3000/owner/repo/issues/[object%20Event]" + // the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '' + const dzInst = await createDropzone(dropzoneEl, opts); + dzInst.on('success', (file, data) => { + file.uuid = data.uuid; + fileUuidDict[file.uuid] = {submitted: false}; + const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${data.uuid}`, value: data.uuid}); + dropzoneEl.querySelector('.files').append(input); + addCopyLink(file); + }); + + dzInst.on('removedfile', async (file) => { + if (disableRemovedfileEvent) return; + document.querySelector(`#dropzone-file-${file.uuid}`)?.remove(); + // when the uploaded file number reaches the limit, there is no uuid in the dict, and it doesn't need to be removed from server + if (removeAttachmentUrl && fileUuidDict[file.uuid] && !fileUuidDict[file.uuid].submitted) { + await POST(removeAttachmentUrl, {data: new URLSearchParams({file: file.uuid})}); + } + }); + + dzInst.on('submit', () => { + for (const fileUuid of Object.keys(fileUuidDict)) { + fileUuidDict[fileUuid].submitted = true; + } }); + + dzInst.on('reload', async () => { + try { + const resp = await GET(listAttachmentsUrl); + const respData = await resp.json(); + // do not trigger the "removedfile" event, otherwise the attachments would be deleted from server + disableRemovedfileEvent = true; + dzInst.removeAllFiles(true); + disableRemovedfileEvent = false; + + dropzoneEl.querySelector('.files').innerHTML = ''; + for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove(); + fileUuidDict = {}; + for (const attachment of respData) { + const imgSrc = `${attachmentBaseLinkUrl}/${attachment.uuid}`; + dzInst.emit('addedfile', attachment); + dzInst.emit('thumbnail', attachment, imgSrc); + dzInst.emit('complete', attachment); + addCopyLink(attachment); + fileUuidDict[attachment.uuid] = {submitted: true}; + const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${attachment.uuid}`, value: attachment.uuid}); + dropzoneEl.querySelector('.files').append(input); + } + if (!dropzoneEl.querySelector('.dz-preview')) { + dropzoneEl.classList.remove('dz-started'); + } + } catch (error) { + // TODO: if listing the existing attachments failed, it should stop from operating the content or attachments, + // otherwise the attachments might be lost. + showErrorToast(`Failed to load attachments: ${error}`); + console.error(error); + } + }); + + dzInst.on('error', (file, message) => { + showErrorToast(`Dropzone upload error: ${message}`); + dzInst.removeFile(file); + }); + + if (listAttachmentsUrl) dzInst.emit('reload'); + return dzInst; } diff --git a/web_src/js/features/repo-editor.js b/web_src/js/features/repo-editor.js index aa9ca657b098..f25da911df1c 100644 --- a/web_src/js/features/repo-editor.js +++ b/web_src/js/features/repo-editor.js @@ -5,6 +5,7 @@ import {hideElem, queryElems, showElem} from '../utils/dom.js'; import {initMarkupContent} from '../markup/content.js'; import {attachRefIssueContextPopup} from './contextpopup.js'; import {POST} from '../modules/fetch.js'; +import {initDropzone} from './dropzone.js'; function initEditPreviewTab($form) { const $tabMenu = $form.find('.repo-editor-menu'); @@ -41,8 +42,11 @@ function initEditPreviewTab($form) { } export function initRepoEditor() { - const $editArea = $('.repository.editor textarea#edit_area'); - if (!$editArea.length) return; + const dropzoneUpload = document.querySelector('.page-content.repository.editor.upload .dropzone'); + if (dropzoneUpload) initDropzone(dropzoneUpload); + + const editArea = document.querySelector('.page-content.repository.editor textarea#edit_area'); + if (!editArea) return; for (const el of queryElems('.js-quick-pull-choice-option')) { el.addEventListener('input', () => { @@ -108,7 +112,7 @@ export function initRepoEditor() { initEditPreviewTab($form); (async () => { - const editor = await createCodeEditor($editArea[0], filenameInput); + const editor = await createCodeEditor(editArea, filenameInput); // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage // to enable or disable the commit button @@ -142,7 +146,7 @@ export function initRepoEditor() { commitButton?.addEventListener('click', (e) => { // A modal which asks if an empty file should be committed - if (!$editArea.val()) { + if (!editArea.value) { $('#edit-empty-content-modal').modal({ onApprove() { $('.edit.form').trigger('submit'); diff --git a/web_src/js/features/repo-issue-edit.js b/web_src/js/features/repo-issue-edit.js index 5fafdcf17c10..8bc0c02bcb40 100644 --- a/web_src/js/features/repo-issue-edit.js +++ b/web_src/js/features/repo-issue-edit.js @@ -1,15 +1,12 @@ import $ from 'jquery'; import {handleReply} from './repo-issue.js'; import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; -import {createDropzone} from './dropzone.js'; -import {GET, POST} from '../modules/fetch.js'; +import {POST} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; import {hideElem, showElem} from '../utils/dom.js'; import {attachRefIssueContextPopup} from './contextpopup.js'; import {initCommentContent, initMarkupContent} from '../markup/content.js'; -const {csrfToken} = window.config; - async function onEditContent(event) { event.preventDefault(); @@ -20,114 +17,27 @@ async function onEditContent(event) { let comboMarkdownEditor; - /** - * @param {HTMLElement} dropzone - */ - const setupDropzone = async (dropzone) => { - if (!dropzone) return null; - - let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event - let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone - const dz = await createDropzone(dropzone, { - url: dropzone.getAttribute('data-upload-url'), - headers: {'X-Csrf-Token': csrfToken}, - maxFiles: dropzone.getAttribute('data-max-file'), - maxFilesize: dropzone.getAttribute('data-max-size'), - acceptedFiles: ['*/*', ''].includes(dropzone.getAttribute('data-accepts')) ? null : dropzone.getAttribute('data-accepts'), - addRemoveLinks: true, - dictDefaultMessage: dropzone.getAttribute('data-default-message'), - dictInvalidFileType: dropzone.getAttribute('data-invalid-input-type'), - dictFileTooBig: dropzone.getAttribute('data-file-too-big'), - dictRemoveFile: dropzone.getAttribute('data-remove-file'), - timeout: 0, - thumbnailMethod: 'contain', - thumbnailWidth: 480, - thumbnailHeight: 480, - init() { - this.on('success', (file, data) => { - file.uuid = data.uuid; - fileUuidDict[file.uuid] = {submitted: false}; - const input = document.createElement('input'); - input.id = data.uuid; - input.name = 'files'; - input.type = 'hidden'; - input.value = data.uuid; - dropzone.querySelector('.files').append(input); - }); - this.on('removedfile', async (file) => { - document.querySelector(`#${file.uuid}`)?.remove(); - if (disableRemovedfileEvent) return; - if (dropzone.getAttribute('data-remove-url') && !fileUuidDict[file.uuid].submitted) { - try { - await POST(dropzone.getAttribute('data-remove-url'), {data: new URLSearchParams({file: file.uuid})}); - } catch (error) { - console.error(error); - } - } - }); - this.on('submit', () => { - for (const fileUuid of Object.keys(fileUuidDict)) { - fileUuidDict[fileUuid].submitted = true; - } - }); - this.on('reload', async () => { - try { - const response = await GET(editContentZone.getAttribute('data-attachment-url')); - const data = await response.json(); - // do not trigger the "removedfile" event, otherwise the attachments would be deleted from server - disableRemovedfileEvent = true; - dz.removeAllFiles(true); - dropzone.querySelector('.files').innerHTML = ''; - for (const el of dropzone.querySelectorAll('.dz-preview')) el.remove(); - fileUuidDict = {}; - disableRemovedfileEvent = false; - - for (const attachment of data) { - const imgSrc = `${dropzone.getAttribute('data-link-url')}/${attachment.uuid}`; - dz.emit('addedfile', attachment); - dz.emit('thumbnail', attachment, imgSrc); - dz.emit('complete', attachment); - fileUuidDict[attachment.uuid] = {submitted: true}; - dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%'; - const input = document.createElement('input'); - input.id = attachment.uuid; - input.name = 'files'; - input.type = 'hidden'; - input.value = attachment.uuid; - dropzone.querySelector('.files').append(input); - } - if (!dropzone.querySelector('.dz-preview')) { - dropzone.classList.remove('dz-started'); - } - } catch (error) { - console.error(error); - } - }); - }, - }); - dz.emit('reload'); - return dz; - }; - const cancelAndReset = (e) => { e.preventDefault(); showElem(renderContent); hideElem(editContentZone); - comboMarkdownEditor.attachedDropzoneInst?.emit('reload'); + comboMarkdownEditor.dropzoneReloadFiles(); }; const saveAndRefresh = async (e) => { e.preventDefault(); + renderContent.classList.add('is-loading'); showElem(renderContent); hideElem(editContentZone); - const dropzoneInst = comboMarkdownEditor.attachedDropzoneInst; try { const params = new URLSearchParams({ content: comboMarkdownEditor.value(), context: editContentZone.getAttribute('data-context'), content_version: editContentZone.getAttribute('data-content-version'), }); - for (const fileInput of dropzoneInst?.element.querySelectorAll('.files [name=files]')) params.append('files[]', fileInput.value); + for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) { + params.append('files[]', file); + } const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params}); const data = await response.json(); @@ -155,12 +65,14 @@ async function onEditContent(event) { } else { content.querySelector('.dropzone-attachments').outerHTML = data.attachments; } - dropzoneInst?.emit('submit'); - dropzoneInst?.emit('reload'); + comboMarkdownEditor.dropzoneSubmitReload(); initMarkupContent(); initCommentContent(); } catch (error) { + showErrorToast(`Failed to save the content: ${error}`); console.error(error); + } finally { + renderContent.classList.remove('is-loading'); } }; @@ -168,7 +80,6 @@ async function onEditContent(event) { if (!comboMarkdownEditor) { editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML; comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); - comboMarkdownEditor.attachedDropzoneInst = await setupDropzone(editContentZone.querySelector('.dropzone')); editContentZone.querySelector('.ui.cancel.button').addEventListener('click', cancelAndReset); editContentZone.querySelector('.ui.primary.button').addEventListener('click', saveAndRefresh); } @@ -176,6 +87,7 @@ async function onEditContent(event) { // Show write/preview tab and copy raw content as needed showElem(editContentZone); hideElem(renderContent); + // FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data if (!comboMarkdownEditor.value()) { comboMarkdownEditor.value(rawContent.textContent); } @@ -196,8 +108,8 @@ export function initRepoIssueCommentEdit() { let editor; if (this.classList.contains('quote-reply-diff')) { - const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply'); - editor = await handleReply($replyBtn); + const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); + editor = await handleReply(replyBtn); } else { // for normal issue/comment page editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index a754e2ae9a5e..57c4f19163b9 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -5,7 +5,6 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js'; import {setFileFolding} from './file-fold.js'; import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; import {toAbsoluteUrl} from '../utils.js'; -import {initDropzone} from './dropzone.js'; import {GET, POST} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; @@ -410,21 +409,13 @@ export function initRepoIssueComments() { }); } -export async function handleReply($el) { - hideElem($el); - const $form = $el.closest('.comment-code-cloud').find('.comment-form'); - showElem($form); - - const $textarea = $form.find('textarea'); - let editor = getComboMarkdownEditor($textarea); - if (!editor) { - // FIXME: the initialization of the dropzone is not consistent. - // When the page is loaded, the dropzone is initialized by initGlobalDropzone, but the editor is not initialized. - // When the form is submitted and partially reload, none of them is initialized. - const dropzone = $form.find('.dropzone')[0]; - if (!dropzone.dropzone) initDropzone(dropzone); - editor = await initComboMarkdownEditor($form.find('.combo-markdown-editor')); - } +export async function handleReply(el) { + const form = el.closest('.comment-code-cloud').querySelector('.comment-form'); + const textarea = form.querySelector('textarea'); + + hideElem(el); + showElem(form); + const editor = getComboMarkdownEditor(textarea) ?? await initComboMarkdownEditor(form.querySelector('.combo-markdown-editor')); editor.focus(); return editor; } @@ -486,7 +477,7 @@ export function initRepoPullRequestReview() { $(document).on('click', 'button.comment-form-reply', async function (e) { e.preventDefault(); - await handleReply($(this)); + await handleReply(this); }); const $reviewBox = $('.review-box-panel'); @@ -554,8 +545,6 @@ export function initRepoPullRequestReview() { $td.find("input[name='line']").val(idx); $td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed'); $td.find("input[name='path']").val(path); - - initDropzone($td.find('.dropzone')[0]); const editor = await initComboMarkdownEditor($td.find('.combo-markdown-editor')); editor.focus(); } catch (error) { diff --git a/web_src/js/index.js b/web_src/js/index.js index 35d69706355b..8aff052664b6 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -91,7 +91,6 @@ import { initGlobalDeleteButton, initGlobalShowModal, } from './features/common-button.js'; -import {initGlobalDropzone} from './features/dropzone.js'; import {initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.js'; initGiteaFomantic(); @@ -135,7 +134,6 @@ onDomReady(() => { initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalCopyToClipboardListener, - initGlobalDropzone, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm, initGlobalDeleteButton, diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 7289f19cbfef..57c7c8796acf 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -304,3 +304,17 @@ export function createElementFromHTML(htmlString) { div.innerHTML = htmlString.trim(); return div.firstChild; } + +export function createElementFromAttrs(tagName, attrs) { + const el = document.createElement(tagName); + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === null) continue; + if (value === true) { + el.toggleAttribute(key, value); + } else { + el.setAttribute(key, String(value)); + } + // TODO: in the future we could make it also support "textContent" and "innerHTML" properties if needed + } + return el; +} diff --git a/web_src/js/utils/dom.test.js b/web_src/js/utils/dom.test.js index fd7d97cad5e3..b9212ec284a9 100644 --- a/web_src/js/utils/dom.test.js +++ b/web_src/js/utils/dom.test.js @@ -1,5 +1,16 @@ -import {createElementFromHTML} from './dom.js'; +import {createElementFromAttrs, createElementFromHTML} from './dom.js'; test('createElementFromHTML', () => { expect(createElementFromHTML('foobar').outerHTML).toEqual('foobar'); }); + +test('createElementFromAttrs', () => { + const el = createElementFromAttrs('button', { + id: 'the-id', + class: 'cls-1 cls-2', + 'data-foo': 'the-data', + disabled: true, + required: null, + }); + expect(el.outerHTML).toEqual(''); +}); From 00fc29aee1245ab5b00653f701d2264dd9a9ade7 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 27 Jun 2024 07:41:59 +0800 Subject: [PATCH 04/59] Refactor issue label selection (#31497) Follow #26460 --- models/issues/issue_search.go | 26 +++++++++++++------------- models/issues/label.go | 30 +++++++++++++----------------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index b238f831ae54..e9f116bfc6e8 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -118,29 +118,29 @@ func applyLabelsCondition(sess *xorm.Session, opts *IssuesOptions) { if opts.LabelIDs[0] == 0 { sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_label)") } else { - // We sort and deduplicate the labels' ids - IncludedLabelIDs := make(container.Set[int64]) - ExcludedLabelIDs := make(container.Set[int64]) + // deduplicate the label IDs for inclusion and exclusion + includedLabelIDs := make(container.Set[int64]) + excludedLabelIDs := make(container.Set[int64]) for _, labelID := range opts.LabelIDs { if labelID > 0 { - IncludedLabelIDs.Add(labelID) + includedLabelIDs.Add(labelID) } else if labelID < 0 { // 0 is not supported here, so just ignore it - ExcludedLabelIDs.Add(-labelID) + excludedLabelIDs.Add(-labelID) } } // ... and use them in a subquery of the form : // where (select count(*) from issue_label where issue_id=issue.id and label_id in (2, 4, 6)) = 3 // This equality is guaranteed thanks to unique index (issue_id,label_id) on table issue_label. - if len(IncludedLabelIDs) > 0 { - subquery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")). - And(builder.In("label_id", IncludedLabelIDs.Values())) - sess.Where(builder.Eq{strconv.Itoa(len(IncludedLabelIDs)): subquery}) + if len(includedLabelIDs) > 0 { + subQuery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")). + And(builder.In("label_id", includedLabelIDs.Values())) + sess.Where(builder.Eq{strconv.Itoa(len(includedLabelIDs)): subQuery}) } // or (select count(*)...) = 0 for excluded labels - if len(ExcludedLabelIDs) > 0 { - subquery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")). - And(builder.In("label_id", ExcludedLabelIDs.Values())) - sess.Where(builder.Eq{"0": subquery}) + if len(excludedLabelIDs) > 0 { + subQuery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")). + And(builder.In("label_id", excludedLabelIDs.Values())) + sess.Where(builder.Eq{"0": subQuery}) } } } diff --git a/models/issues/label.go b/models/issues/label.go index 2d7acb7f0c53..2530f71004de 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -12,6 +12,7 @@ import ( "strings" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/timeutil" @@ -143,37 +144,32 @@ func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) { // LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked func (l *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) { - labelQuerySlice := []int64{} + labelQueryParams := container.Set[string]{} labelSelected := false - labelScope := l.ExclusiveScope() - for i, s := range currentSelectedLabels { - if s == l.ID { + exclusiveScope := l.ExclusiveScope() + for i, curSel := range currentSelectedLabels { + if curSel == l.ID { labelSelected = true - } else if -s == l.ID { + } else if -curSel == l.ID { labelSelected = true l.IsExcluded = true - } else if s != 0 { + } else if curSel != 0 { // Exclude other labels in the same scope from selection - if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] { - labelQuerySlice = append(labelQuerySlice, s) + if curSel < 0 || exclusiveScope == "" || exclusiveScope != currentSelectedExclusiveScopes[i] { + labelQueryParams.Add(strconv.FormatInt(curSel, 10)) } } } if !labelSelected { - labelQuerySlice = append(labelQuerySlice, l.ID) + labelQueryParams.Add(strconv.FormatInt(l.ID, 10)) } l.IsSelected = labelSelected // Sort and deduplicate the ids to avoid the crawlers asking for the // same thing with simply a different order of parameters - slices.Sort(labelQuerySlice) - labelQuerySlice = slices.Compact(labelQuerySlice) - // Quick conversion (strings.Join() doesn't accept slices of Int64) - labelQuerySliceStrings := make([]string, len(labelQuerySlice)) - for i, x := range labelQuerySlice { - labelQuerySliceStrings[i] = strconv.FormatInt(x, 10) - } + labelQuerySliceStrings := labelQueryParams.Values() + slices.Sort(labelQuerySliceStrings) // the sort is still needed because the underlying map of Set doesn't guarantee order l.QueryString = strings.Join(labelQuerySliceStrings, ",") } @@ -187,7 +183,7 @@ func (l *Label) BelongsToRepo() bool { return l.RepoID > 0 } -// Return scope substring of label name, or empty string if none exists +// ExclusiveScope returns scope substring of label name, or empty string if none exists func (l *Label) ExclusiveScope() string { if !l.Exclusive { return "" From 9bc5552c11f6aca08c8c873a0561882b3e099350 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Thu, 27 Jun 2024 17:31:49 +0800 Subject: [PATCH 05/59] Improve attachment upload methods (#30513) * Use dropzone to handle file uploading for all cases, including pasting and dragging * Merge duplicate code, use consistent behavior for link generating Close #20130 --------- Co-authored-by: silverwind Co-authored-by: wxiaoguang --- .../js/features/comp/ComboMarkdownEditor.js | 10 +- web_src/js/features/comp/EditorMarkdown.js | 4 +- .../comp/{Paste.js => EditorUpload.js} | 128 ++++++++++-------- web_src/js/features/comp/EditorUpload.test.js | 14 ++ web_src/js/features/dropzone.js | 62 ++++++--- web_src/js/utils.js | 14 +- web_src/js/utils.test.js | 18 ++- web_src/js/utils/dom.js | 12 -- web_src/js/utils/image.js | 7 +- web_src/js/utils/image.test.js | 1 + 10 files changed, 171 insertions(+), 99 deletions(-) rename web_src/js/features/comp/{Paste.js => EditorUpload.js} (52%) create mode 100644 web_src/js/features/comp/EditorUpload.test.js diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index bd11c8383c35..21779e32a8ef 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -3,7 +3,7 @@ import '@github/text-expander-element'; import $ from 'jquery'; import {attachTribute} from '../tribute.js'; import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js'; -import {initEasyMDEPaste, initTextareaPaste} from './Paste.js'; +import {initEasyMDEPaste, initTextareaUpload} from './EditorUpload.js'; import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; import {renderPreviewPanelContent} from '../repo-editor.js'; import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; @@ -11,7 +11,7 @@ import {initTextExpander} from './TextExpander.js'; import {showErrorToast} from '../../modules/toast.js'; import {POST} from '../../modules/fetch.js'; import {initTextareaMarkdown} from './EditorMarkdown.js'; -import {initDropzone} from '../dropzone.js'; +import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.js'; let elementIdCounter = 0; @@ -111,7 +111,7 @@ class ComboMarkdownEditor { initTextareaMarkdown(this.textarea); if (this.dropzone) { - initTextareaPaste(this.textarea, this.dropzone); + initTextareaUpload(this.textarea, this.dropzone); } } @@ -130,13 +130,13 @@ class ComboMarkdownEditor { dropzoneReloadFiles() { if (!this.dropzone) return; - this.attachedDropzoneInst.emit('reload'); + this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles); } dropzoneSubmitReload() { if (!this.dropzone) return; this.attachedDropzoneInst.emit('submit'); - this.attachedDropzoneInst.emit('reload'); + this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles); } setupTab() { diff --git a/web_src/js/features/comp/EditorMarkdown.js b/web_src/js/features/comp/EditorMarkdown.js index cf412e3807a3..9ec71aba7444 100644 --- a/web_src/js/features/comp/EditorMarkdown.js +++ b/web_src/js/features/comp/EditorMarkdown.js @@ -1,4 +1,6 @@ -import {triggerEditorContentChanged} from './Paste.js'; +export function triggerEditorContentChanged(target) { + target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); +} function handleIndentSelection(textarea, e) { const selStart = textarea.selectionStart; diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/EditorUpload.js similarity index 52% rename from web_src/js/features/comp/Paste.js rename to web_src/js/features/comp/EditorUpload.js index c72434c4cc65..8861abfe03a5 100644 --- a/web_src/js/features/comp/Paste.js +++ b/web_src/js/features/comp/EditorUpload.js @@ -1,19 +1,29 @@ -import {htmlEscape} from 'escape-goat'; -import {POST} from '../../modules/fetch.js'; import {imageInfo} from '../../utils/image.js'; -import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js'; +import {replaceTextareaSelection} from '../../utils/dom.js'; import {isUrl} from '../../utils/url.js'; - -async function uploadFile(file, uploadUrl) { - const formData = new FormData(); - formData.append('file', file, file.name); - - const res = await POST(uploadUrl, {data: formData}); - return await res.json(); -} - -export function triggerEditorContentChanged(target) { - target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); +import {triggerEditorContentChanged} from './EditorMarkdown.js'; +import { + DropzoneCustomEventRemovedFile, + DropzoneCustomEventUploadDone, + generateMarkdownLinkForAttachment, +} from '../dropzone.js'; + +let uploadIdCounter = 0; + +function uploadFile(dropzoneEl, file) { + return new Promise((resolve) => { + const curUploadId = uploadIdCounter++; + file._giteaUploadId = curUploadId; + const dropzoneInst = dropzoneEl.dropzone; + const onUploadDone = ({file}) => { + if (file._giteaUploadId === curUploadId) { + dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone); + resolve(); + } + }; + dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone); + dropzoneInst.handleFiles([file]); + }); } class TextareaEditor { @@ -82,48 +92,25 @@ class CodeMirrorEditor { } } -async function handleClipboardImages(editor, dropzone, images, e) { - const uploadUrl = dropzone.getAttribute('data-upload-url'); - const filesContainer = dropzone.querySelector('.files'); - - if (!dropzone || !uploadUrl || !filesContainer || !images.length) return; - +async function handleUploadFiles(editor, dropzoneEl, files, e) { e.preventDefault(); - e.stopPropagation(); - - for (const img of images) { - const name = img.name.slice(0, img.name.lastIndexOf('.')); + for (const file of files) { + const name = file.name.slice(0, file.name.lastIndexOf('.')); + const {width, dppx} = await imageInfo(file); + const placeholder = `[${name}](uploading ...)`; - const placeholder = `![${name}](uploading ...)`; editor.insertPlaceholder(placeholder); - - const {uuid} = await uploadFile(img, uploadUrl); - const {width, dppx} = await imageInfo(img); - - let text; - if (width > 0 && dppx > 1) { - // Scale down images from HiDPI monitors. This uses the tag because it's the only - // method to change image size in Markdown that is supported by all implementations. - // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" - const url = `attachments/${uuid}`; - text = `${htmlEscape(name)}`; - } else { - // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" - // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" - const url = `/attachments/${uuid}`; - text = `![${name}](${url})`; - } - editor.replacePlaceholder(placeholder, text); - - const input = document.createElement('input'); - input.setAttribute('name', 'files'); - input.setAttribute('type', 'hidden'); - input.setAttribute('id', uuid); - input.value = uuid; - filesContainer.append(input); + await uploadFile(dropzoneEl, file); // the "file" will get its "uuid" during the upload + editor.replacePlaceholder(placeholder, generateMarkdownLinkForAttachment(file, {width, dppx})); } } +export function removeAttachmentLinksFromMarkdown(text, fileUuid) { + text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), ''); + text = text.replace(new RegExp(`]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); + return text; +} + function handleClipboardText(textarea, e, {text, isShiftDown}) { // pasting with "shift" means "paste as original content" in most applications if (isShiftDown) return; // let the browser handle it @@ -139,16 +126,37 @@ function handleClipboardText(textarea, e, {text, isShiftDown}) { // else, let the browser handle it } -export function initEasyMDEPaste(easyMDE, dropzone) { +// extract text and images from "paste" event +function getPastedContent(e) { + const images = []; + for (const item of e.clipboardData?.items ?? []) { + if (item.type?.startsWith('image/')) { + images.push(item.getAsFile()); + } + } + const text = e.clipboardData?.getData?.('text') ?? ''; + return {text, images}; +} + +export function initEasyMDEPaste(easyMDE, dropzoneEl) { + const editor = new CodeMirrorEditor(easyMDE.codemirror); easyMDE.codemirror.on('paste', (_, e) => { const {images} = getPastedContent(e); - if (images.length) { - handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e); - } + if (!images.length) return; + handleUploadFiles(editor, dropzoneEl, images, e); + }); + easyMDE.codemirror.on('drop', (_, e) => { + if (!e.dataTransfer.files.length) return; + handleUploadFiles(editor, dropzoneEl, e.dataTransfer.files, e); + }); + dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => { + const oldText = easyMDE.codemirror.getValue(); + const newText = removeAttachmentLinksFromMarkdown(oldText, fileUuid); + if (oldText !== newText) easyMDE.codemirror.setValue(newText); }); } -export function initTextareaPaste(textarea, dropzone) { +export function initTextareaUpload(textarea, dropzoneEl) { let isShiftDown = false; textarea.addEventListener('keydown', (e) => { if (e.shiftKey) isShiftDown = true; @@ -159,9 +167,17 @@ export function initTextareaPaste(textarea, dropzone) { textarea.addEventListener('paste', (e) => { const {images, text} = getPastedContent(e); if (images.length) { - handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); + handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e); } else if (text) { handleClipboardText(textarea, e, {text, isShiftDown}); } }); + textarea.addEventListener('drop', (e) => { + if (!e.dataTransfer.files.length) return; + handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e); + }); + dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => { + const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid); + if (textarea.value !== newText) textarea.value = newText; + }); } diff --git a/web_src/js/features/comp/EditorUpload.test.js b/web_src/js/features/comp/EditorUpload.test.js new file mode 100644 index 000000000000..caecfb91ea2d --- /dev/null +++ b/web_src/js/features/comp/EditorUpload.test.js @@ -0,0 +1,14 @@ +import {removeAttachmentLinksFromMarkdown} from './EditorUpload.js'; + +test('removeAttachmentLinksFromMarkdown', () => { + expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b'); + expect(removeAttachmentLinksFromMarkdown('a [x](attachments/foo) b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a ![x](attachments/foo) b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a [x](/attachments/foo) b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a ![x](/attachments/foo) b', 'foo')).toBe('a b'); + + expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); +}); diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js index 8d70fc774b19..f25a6137186f 100644 --- a/web_src/js/features/dropzone.js +++ b/web_src/js/features/dropzone.js @@ -5,9 +5,15 @@ import {showTemporaryTooltip} from '../modules/tippy.js'; import {GET, POST} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.js'; +import {isImageFile, isVideoFile} from '../utils.js'; const {csrfToken, i18n} = window.config; +// dropzone has its owner event dispatcher (emitter) +export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files'; +export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file'; +export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done'; + async function createDropzone(el, opts) { const [{Dropzone}] = await Promise.all([ import(/* webpackChunkName: "dropzone" */'dropzone'), @@ -16,6 +22,26 @@ async function createDropzone(el, opts) { return new Dropzone(el, opts); } +export function generateMarkdownLinkForAttachment(file, {width, dppx} = {}) { + let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; + if (isImageFile(file)) { + fileMarkdown = `!${fileMarkdown}`; + if (width > 0 && dppx > 1) { + // Scale down images from HiDPI monitors. This uses the tag because it's the only + // method to change image size in Markdown that is supported by all implementations. + // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" + fileMarkdown = `${htmlEscape(file.name)}`; + } else { + // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" + // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" + fileMarkdown = `![${file.name}](/attachments/${file.uuid})`; + } + } else if (isVideoFile(file)) { + fileMarkdown = ``; + } + return fileMarkdown; +} + function addCopyLink(file) { // Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard // The "" element has a hardcoded cursor: pointer because the default is overridden by .dropzone @@ -25,13 +51,7 @@ function addCopyLink(file) { `); copyLinkEl.addEventListener('click', async (e) => { e.preventDefault(); - let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; - if (file.type?.startsWith('image/')) { - fileMarkdown = `!${fileMarkdown}`; - } else if (file.type?.startsWith('video/')) { - fileMarkdown = ``; - } - const success = await clippie(fileMarkdown); + const success = await clippie(generateMarkdownLinkForAttachment(file)); showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); }); file.previewTemplate.append(copyLinkEl); @@ -68,16 +88,19 @@ export async function initDropzone(dropzoneEl) { // "http://localhost:3000/owner/repo/issues/[object%20Event]" // the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '' const dzInst = await createDropzone(dropzoneEl, opts); - dzInst.on('success', (file, data) => { - file.uuid = data.uuid; + dzInst.on('success', (file, resp) => { + file.uuid = resp.uuid; fileUuidDict[file.uuid] = {submitted: false}; - const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${data.uuid}`, value: data.uuid}); + const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid}); dropzoneEl.querySelector('.files').append(input); addCopyLink(file); + dzInst.emit(DropzoneCustomEventUploadDone, {file}); }); dzInst.on('removedfile', async (file) => { if (disableRemovedfileEvent) return; + + dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid}); document.querySelector(`#dropzone-file-${file.uuid}`)?.remove(); // when the uploaded file number reaches the limit, there is no uuid in the dict, and it doesn't need to be removed from server if (removeAttachmentUrl && fileUuidDict[file.uuid] && !fileUuidDict[file.uuid].submitted) { @@ -91,7 +114,7 @@ export async function initDropzone(dropzoneEl) { } }); - dzInst.on('reload', async () => { + dzInst.on(DropzoneCustomEventReloadFiles, async () => { try { const resp = await GET(listAttachmentsUrl); const respData = await resp.json(); @@ -104,13 +127,14 @@ export async function initDropzone(dropzoneEl) { for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove(); fileUuidDict = {}; for (const attachment of respData) { - const imgSrc = `${attachmentBaseLinkUrl}/${attachment.uuid}`; - dzInst.emit('addedfile', attachment); - dzInst.emit('thumbnail', attachment, imgSrc); - dzInst.emit('complete', attachment); - addCopyLink(attachment); - fileUuidDict[attachment.uuid] = {submitted: true}; - const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${attachment.uuid}`, value: attachment.uuid}); + const file = {name: attachment.name, uuid: attachment.uuid, size: attachment.size}; + const imgSrc = `${attachmentBaseLinkUrl}/${file.uuid}`; + dzInst.emit('addedfile', file); + dzInst.emit('thumbnail', file, imgSrc); + dzInst.emit('complete', file); + addCopyLink(file); // it is from server response, so no "type" + fileUuidDict[file.uuid] = {submitted: true}; + const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${file.uuid}`, value: file.uuid}); dropzoneEl.querySelector('.files').append(input); } if (!dropzoneEl.querySelector('.dz-preview')) { @@ -129,6 +153,6 @@ export async function initDropzone(dropzoneEl) { dzInst.removeFile(file); }); - if (listAttachmentsUrl) dzInst.emit('reload'); + if (listAttachmentsUrl) dzInst.emit(DropzoneCustomEventReloadFiles); return dzInst; } diff --git a/web_src/js/utils.js b/web_src/js/utils.js index ce0fb66343b1..2d40fa20a8c7 100644 --- a/web_src/js/utils.js +++ b/web_src/js/utils.js @@ -1,14 +1,16 @@ import {encode, decode} from 'uint8-to-base64'; // transform /path/to/file.ext to file.ext -export function basename(path = '') { +export function basename(path) { const lastSlashIndex = path.lastIndexOf('/'); return lastSlashIndex < 0 ? path : path.substring(lastSlashIndex + 1); } // transform /path/to/file.ext to .ext -export function extname(path = '') { +export function extname(path) { + const lastSlashIndex = path.lastIndexOf('/'); const lastPointIndex = path.lastIndexOf('.'); + if (lastSlashIndex > lastPointIndex) return ''; return lastPointIndex < 0 ? '' : path.substring(lastPointIndex); } @@ -142,3 +144,11 @@ export function serializeXml(node) { } export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function isImageFile({name, type}) { + return /\.(jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/'); +} + +export function isVideoFile({name, type}) { + return /\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/'); +} diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js index 2754e41c433e..1ec3d3630b5b 100644 --- a/web_src/js/utils.test.js +++ b/web_src/js/utils.test.js @@ -1,7 +1,7 @@ import { basename, extname, isObject, stripTags, parseIssueHref, parseUrl, translateMonth, translateDay, blobToDataURI, - toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, + toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, } from './utils.js'; test('basename', () => { @@ -15,6 +15,7 @@ test('extname', () => { expect(extname('/path/')).toEqual(''); expect(extname('/path')).toEqual(''); expect(extname('file.js')).toEqual('.js'); + expect(extname('/my.path/file')).toEqual(''); }); test('isObject', () => { @@ -112,3 +113,18 @@ test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => { expect(Array.from(decodeURLEncodedBase64('YQ'))).toEqual(Array.from(uint8array('a'))); expect(Array.from(decodeURLEncodedBase64('YQ=='))).toEqual(Array.from(uint8array('a'))); }); + +test('file detection', () => { + for (const name of ['a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) { + expect(isImageFile({name})).toBeTruthy(); + } + for (const name of ['', 'a.jpg.x', '/path.png/x', 'webp']) { + expect(isImageFile({name})).toBeFalsy(); + } + for (const name of ['a.mpg', '/a.mpeg', '.file.mp4', '.webm', 'file.mkv']) { + expect(isVideoFile({name})).toBeTruthy(); + } + for (const name of ['', 'a.mpg.x', '/path.mp4/x', 'webm']) { + expect(isVideoFile({name})).toBeFalsy(); + } +}); diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 57c7c8796acf..6a38968899f9 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -262,18 +262,6 @@ export function isElemVisible(element) { return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); } -// extract text and images from "paste" event -export function getPastedContent(e) { - const images = []; - for (const item of e.clipboardData?.items ?? []) { - if (item.type?.startsWith('image/')) { - images.push(item.getAsFile()); - } - } - const text = e.clipboardData?.getData?.('text') ?? ''; - return {text, images}; -} - // replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this export function replaceTextareaSelection(textarea, text) { const before = textarea.value.slice(0, textarea.selectionStart ?? undefined); diff --git a/web_src/js/utils/image.js b/web_src/js/utils/image.js index ed5d98e35ad0..c71d71594121 100644 --- a/web_src/js/utils/image.js +++ b/web_src/js/utils/image.js @@ -19,11 +19,10 @@ export async function pngChunks(blob) { return chunks; } -// decode a image and try to obtain width and dppx. If will never throw but instead +// decode a image and try to obtain width and dppx. It will never throw but instead // return default values. export async function imageInfo(blob) { - let width = 0; // 0 means no width could be determined - let dppx = 1; // 1 dot per pixel for non-HiDPI screens + let width = 0, dppx = 1; // dppx: 1 dot per pixel for non-HiDPI screens if (blob.type === 'image/png') { // only png is supported currently try { @@ -41,6 +40,8 @@ export async function imageInfo(blob) { } } } catch {} + } else { + return {}; // no image info for non-image files } return {width, dppx}; diff --git a/web_src/js/utils/image.test.js b/web_src/js/utils/image.test.js index ba4758250c7f..af56ed2331fb 100644 --- a/web_src/js/utils/image.test.js +++ b/web_src/js/utils/image.test.js @@ -26,4 +26,5 @@ test('imageInfo', async () => { expect(await imageInfo(await dataUriToBlob(pngNoPhys))).toEqual({width: 1, dppx: 1}); expect(await imageInfo(await dataUriToBlob(pngPhys))).toEqual({width: 2, dppx: 2}); expect(await imageInfo(await dataUriToBlob(pngEmpty))).toEqual({width: 0, dppx: 1}); + expect(await imageInfo(await dataUriToBlob(`data:image/gif;base64,`))).toEqual({}); }); From c1fe6fbcc36bd29549019274d39f273b3e37755d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 27 Jun 2024 21:58:38 +0800 Subject: [PATCH 06/59] Make toast support preventDuplicates (#31501) make preventDuplicates default to true, users get a clear UI feedback and know that "a new message appears". Fixes: https://github.com/go-gitea/gitea/issues/26651 --------- Co-authored-by: silverwind --- templates/devtest/gitea-ui.tmpl | 11 ----------- templates/devtest/toast.tmpl | 15 +++++++++++++++ web_src/css/modules/animations.css | 10 ++++++---- web_src/css/modules/toast.css | 26 ++++++++++++++++++++------ web_src/js/features/repo-diff.js | 8 ++------ web_src/js/modules/toast.js | 23 ++++++++++++++++++++--- web_src/js/standalone/devtest.js | 21 ++++++++++++--------- web_src/js/utils/dom.js | 11 +++++++++++ 8 files changed, 86 insertions(+), 39 deletions(-) create mode 100644 templates/devtest/toast.tmpl diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl index ea293fd3b41b..06d0e36569a1 100644 --- a/templates/devtest/gitea-ui.tmpl +++ b/templates/devtest/gitea-ui.tmpl @@ -182,15 +182,6 @@ -
-

Toast

-
- - - -
-
-

ComboMarkdownEditor

ps: no JS code attached, so just a layout
@@ -201,7 +192,5 @@
- -
{{template "base/footer" .}} diff --git a/templates/devtest/toast.tmpl b/templates/devtest/toast.tmpl new file mode 100644 index 000000000000..412f23964a2b --- /dev/null +++ b/templates/devtest/toast.tmpl @@ -0,0 +1,15 @@ +{{template "base/head" .}} + +
+

Toast

+
+ + + + +
+
+ + + +{{template "base/footer" .}} diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css index a86c9234aa33..481e997d4fcd 100644 --- a/web_src/css/modules/animations.css +++ b/web_src/css/modules/animations.css @@ -92,20 +92,22 @@ code.language-math.is-loading::after { } } -@keyframes pulse { +/* 1p5 means 1-point-5. it can't use "pulse" here, otherwise the animation is not right (maybe due to some conflicts */ +@keyframes pulse-1p5 { 0% { transform: scale(1); } 50% { - transform: scale(1.8); + transform: scale(1.5); } 100% { transform: scale(1); } } -.pulse { - animation: pulse 2s linear; +/* pulse animation for scale(1.5) in 200ms */ +.pulse-1p5-200 { + animation: pulse-1p5 200ms linear; } .ui.modal, diff --git a/web_src/css/modules/toast.css b/web_src/css/modules/toast.css index 2a9f78e01756..1145f3b1b58b 100644 --- a/web_src/css/modules/toast.css +++ b/web_src/css/modules/toast.css @@ -22,17 +22,31 @@ overflow-wrap: anywhere; } -.toast-close, -.toast-icon { - color: currentcolor; +.toast-close { border-radius: var(--border-radius); - background: transparent; - border: none; - display: flex; width: 30px; height: 30px; justify-content: center; +} + +.toast-icon { + display: inline-flex; + width: 30px; + height: 30px; align-items: center; + justify-content: center; +} + +.toast-duplicate-number::before { + content: "("; +} +.toast-duplicate-number { + display: inline-block; + margin-right: 5px; + user-select: none; +} +.toast-duplicate-number::after { + content: ")"; } .toast-close:hover { diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.js index cd01232a7e53..279f6da75729 100644 --- a/web_src/js/features/repo-diff.js +++ b/web_src/js/features/repo-diff.js @@ -7,7 +7,7 @@ import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js'; import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.js'; import {initImageDiff} from './imagediff.js'; import {showErrorToast} from '../modules/toast.js'; -import {submitEventSubmitter, queryElemSiblings, hideElem, showElem} from '../utils/dom.js'; +import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce} from '../utils/dom.js'; import {POST, GET} from '../modules/fetch.js'; const {pageData, i18n} = window.config; @@ -26,11 +26,7 @@ function initRepoDiffReviewButton() { const num = parseInt(counter.getAttribute('data-pending-comment-number')) + 1 || 1; counter.setAttribute('data-pending-comment-number', num); counter.textContent = num; - - reviewBox.classList.remove('pulse'); - requestAnimationFrame(() => { - reviewBox.classList.add('pulse'); - }); + animateOnce(reviewBox, 'pulse-1p5-200'); }); }); } diff --git a/web_src/js/modules/toast.js b/web_src/js/modules/toast.js index d12d203718ba..627e24a1ff95 100644 --- a/web_src/js/modules/toast.js +++ b/web_src/js/modules/toast.js @@ -1,5 +1,6 @@ import {htmlEscape} from 'escape-goat'; import {svg} from '../svg.js'; +import {animateOnce, showElem} from '../utils/dom.js'; import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown const levels = { @@ -21,13 +22,28 @@ const levels = { }; // See https://github.com/apvarun/toastify-js#api for options -function showToast(message, level, {gravity, position, duration, useHtmlBody, ...other} = {}) { +function showToast(message, level, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other} = {}) { + const body = useHtmlBody ? String(message) : htmlEscape(message); + const key = `${level}-${body}`; + + // prevent showing duplicate toasts with same level and message, and give a visual feedback for end users + if (preventDuplicates) { + const toastEl = document.querySelector(`.toastify[data-toast-unique-key="${CSS.escape(key)}"]`); + if (toastEl) { + const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number'); + showElem(toastDupNumEl); + toastDupNumEl.textContent = String(Number(toastDupNumEl.textContent) + 1); + animateOnce(toastDupNumEl, 'pulse-1p5-200'); + return; + } + } + const {icon, background, duration: levelDuration} = levels[level ?? 'info']; const toast = Toastify({ text: `
${svg(icon)}
-
${useHtmlBody ? message : htmlEscape(message)}
- +
1${body}
+ `, escapeMarkup: false, gravity: gravity ?? 'top', @@ -39,6 +55,7 @@ function showToast(message, level, {gravity, position, duration, useHtmlBody, .. toast.showToast(); toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast()); + toast.toastElement.setAttribute('data-toast-unique-key', key); return toast; } diff --git a/web_src/js/standalone/devtest.js b/web_src/js/standalone/devtest.js index 8dbba554acb8..d3e3e13a8790 100644 --- a/web_src/js/standalone/devtest.js +++ b/web_src/js/standalone/devtest.js @@ -1,11 +1,14 @@ import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.js'; -document.querySelector('#info-toast').addEventListener('click', () => { - showInfoToast('success 😀'); -}); -document.querySelector('#warning-toast').addEventListener('click', () => { - showWarningToast('warning 😐'); -}); -document.querySelector('#error-toast').addEventListener('click', () => { - showErrorToast('error 🙁'); -}); +function initDevtestToast() { + const levelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast}; + for (const el of document.querySelectorAll('.toast-test-button')) { + el.addEventListener('click', () => { + const level = el.getAttribute('data-toast-level'); + const message = el.getAttribute('data-toast-message'); + levelMap[level](message); + }); + } +} + +initDevtestToast(); diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 6a38968899f9..9bdb2332362a 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -306,3 +306,14 @@ export function createElementFromAttrs(tagName, attrs) { } return el; } + +export function animateOnce(el, animationClassName) { + return new Promise((resolve) => { + el.addEventListener('animationend', function onAnimationEnd() { + el.classList.remove(animationClassName); + el.removeEventListener('animationend', onAnimationEnd); + resolve(); + }, {once: true}); + el.classList.add(animationClassName); + }); +} From d655ff18b3183807dc568b133035c7c555e3b595 Mon Sep 17 00:00:00 2001 From: charles <30816317+charles7668@users.noreply.github.com> Date: Thu, 27 Jun 2024 22:04:05 +0800 Subject: [PATCH 07/59] Fix avatar radius problem on the new issue page (#31506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close #31502 Related to #31419. In this PR, the avatar width is set to 3em, but the height is not set, so the image is not squared. When object-fit is set to contain, it can't maintain the radius of the image. Result: ![圖片](https://github.com/go-gitea/gitea/assets/30816317/bceb98aa-b0f7-4753-bc8b-3b9c41dfd55a) --- web_src/css/repo.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 28b84d3af421..f37ea3110a46 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -535,7 +535,7 @@ td .commit-summary { min-width: 100px; } -#new-issue .avatar { +#new-issue .comment .avatar { width: 3em; } From 62b373896869da769992fbad06d13606c57c5585 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 28 Jun 2024 07:59:22 +0200 Subject: [PATCH 08/59] Fix JS error with disabled attachment and easymde (#31511) Not sure if this is a regression from https://github.com/go-gitea/gitea/pull/30513, but when attachments are disabled, `this.dropzone` is null and the code had failed in `initEasyMDEPaste` trying to access `dropzoneEl.dropzone`. --- web_src/js/features/comp/ComboMarkdownEditor.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index 21779e32a8ef..f511adab1a1c 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -264,7 +264,9 @@ class ComboMarkdownEditor { }); this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights); await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true}); - initEasyMDEPaste(this.easyMDE, this.dropzone); + if (this.dropzone) { + initEasyMDEPaste(this.easyMDE, this.dropzone); + } hideElem(this.textareaMarkdownToolbar); } From df805d6ed0458dbec258d115238fde794ed4d0ce Mon Sep 17 00:00:00 2001 From: Royce Remer Date: Fri, 28 Jun 2024 01:42:57 -0700 Subject: [PATCH 09/59] Support legacy _links LFS batch responses (#31513) Support legacy _links LFS batch response. Fixes #31512. This is backwards-compatible change to the LFS client so that, upon mirroring from an upstream which has a batch api, it can download objects whether the responses contain the `_links` field or its successor the `actions` field. When Gitea must fallback to the legacy `_links` field a logline is emitted at INFO level which looks like this: ``` ...s/lfs/http_client.go:188:performOperation() [I] is using a deprecated batch schema response! ``` I've only run `test-backend` with this code, but added a new test to cover this case. Additionally I have a fork with this change deployed which I've confirmed syncs LFS from Gitea<-Artifactory (which has legacy `_links`) as well as from Gitea<-Gitea (which has the modern `actions`). Signed-off-by: Royce Remer --- modules/lfs/http_client.go | 4 ++++ modules/lfs/http_client_test.go | 16 ++++++++++++++++ modules/lfs/shared.go | 1 + 3 files changed, 21 insertions(+) diff --git a/modules/lfs/http_client.go b/modules/lfs/http_client.go index f5ddd38b0911..7ee2449b0ec4 100644 --- a/modules/lfs/http_client.go +++ b/modules/lfs/http_client.go @@ -180,6 +180,10 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc } } else { link, ok := object.Actions["download"] + if !ok { + // no actions block in response, try legacy response schema + link, ok = object.Links["download"] + } if !ok { log.Debug("%+v", object) return errors.New("missing action 'download'") diff --git a/modules/lfs/http_client_test.go b/modules/lfs/http_client_test.go index 7431132f760f..ec90f5375d1b 100644 --- a/modules/lfs/http_client_test.go +++ b/modules/lfs/http_client_test.go @@ -59,6 +59,17 @@ func lfsTestRoundtripHandler(req *http.Request) *http.Response { }, }, } + } else if strings.Contains(url, "legacy-batch-request-download") { + batchResponse = &BatchResponse{ + Transfer: "dummy", + Objects: []*ObjectResponse{ + { + Links: map[string]*Link{ + "download": {}, + }, + }, + }, + } } else if strings.Contains(url, "valid-batch-request-upload") { batchResponse = &BatchResponse{ Transfer: "dummy", @@ -229,6 +240,11 @@ func TestHTTPClientDownload(t *testing.T) { endpoint: "https://unknown-actions-map.io", expectederror: "missing action 'download'", }, + // case 11 + { + endpoint: "https://legacy-batch-request-download.io", + expectederror: "", + }, } for n, c := range cases { diff --git a/modules/lfs/shared.go b/modules/lfs/shared.go index 80f4fed00d40..675d2328b7d1 100644 --- a/modules/lfs/shared.go +++ b/modules/lfs/shared.go @@ -47,6 +47,7 @@ type BatchResponse struct { type ObjectResponse struct { Pointer Actions map[string]*Link `json:"actions,omitempty"` + Links map[string]*Link `json:"_links,omitempty"` Error *ObjectError `json:"error,omitempty"` } From 08579d6cbb65399dec408f3b90bc9a9e285e6206 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 28 Jun 2024 18:15:51 +0200 Subject: [PATCH 10/59] Add initial typescript config and use it for eslint,vitest,playwright (#31186) This enables eslint to use the typescript parser and resolver which brings some benefits that eslint rules now have type information available and a tsconfig.json is required for the upcoming typescript migration as well. Notable changes done: - Add typescript parser and resolver - Move the vue-specific config into the root file - Enable `vue-scoped-css/enforce-style-type` rule, there was only one violation and I added a inline disable there. - Fix new lint errors that were detected because of the parser change - Update `i/no-unresolved` to remove now-unnecessary workaround for the resolver - Disable `i/no-named-as-default` as it seems to raise bogus issues in the webpack config - Change vitest config to typescript - Change playwright config to typescript - Add `eslint-plugin-playwright` and fix issues - Add `tsc` linting to `make lint-js` --- .eslintrc.yaml | 31 +++- Makefile | 6 +- package-lock.json | 169 ++++++++++++++++-- package.json | 4 + playwright.config.js => playwright.config.ts | 19 +- tests/e2e/README.md | 2 +- tests/e2e/e2e_test.go | 4 +- ...xample.test.e2e.js => example.test.e2e.ts} | 13 +- tests/e2e/{utils_e2e.js => utils_e2e.ts} | 7 +- tsconfig.json | 30 ++++ vitest.config.js => vitest.config.ts | 0 web_src/js/components/.eslintrc.yaml | 22 --- web_src/js/components/RepoActionView.vue | 2 +- web_src/js/features/repo-code.js | 4 +- web_src/js/webcomponents/overflow-menu.js | 2 +- 15 files changed, 249 insertions(+), 66 deletions(-) rename playwright.config.js => playwright.config.ts (81%) rename tests/e2e/{example.test.e2e.js => example.test.e2e.ts} (86%) rename tests/e2e/{utils_e2e.js => utils_e2e.ts} (88%) create mode 100644 tsconfig.json rename vitest.config.js => vitest.config.ts (100%) delete mode 100644 web_src/js/components/.eslintrc.yaml diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 4e3da6cab440..55a0f556fc71 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -6,9 +6,20 @@ ignorePatterns: - /web_src/fomantic - /public/assets/js +parser: "@typescript-eslint/parser" + parserOptions: sourceType: module ecmaVersion: latest + project: true + extraFileExtensions: [".vue"] + +settings: + import/extensions: [".js", ".ts"] + import/parsers: + "@typescript-eslint/parser": [".js", ".ts"] + import/resolver: + typescript: true plugins: - "@eslint-community/eslint-plugin-eslint-comments" @@ -103,6 +114,22 @@ overrides: - files: ["web_src/js/modules/fetch.js", "web_src/js/standalone/**/*"] rules: no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression] + - files: ["**/*.vue"] + plugins: + - eslint-plugin-vue + - eslint-plugin-vue-scoped-css + extends: + - plugin:vue/vue3-recommended + - plugin:vue-scoped-css/vue3-recommended + rules: + vue/attributes-order: [0] + vue/html-closing-bracket-spacing: [2, {startTag: never, endTag: never, selfClosingTag: never}] + vue/max-attributes-per-line: [0] + vue/singleline-html-element-content-newline: [0] + - files: ["tests/e2e/**"] + plugins: + - eslint-plugin-playwright + extends: plugin:playwright/recommended rules: "@eslint-community/eslint-comments/disable-enable-pair": [2] @@ -264,7 +291,7 @@ rules: i/no-internal-modules: [0] i/no-mutable-exports: [0] i/no-named-as-default-member: [0] - i/no-named-as-default: [2] + i/no-named-as-default: [0] i/no-named-default: [0] i/no-named-export: [0] i/no-namespace: [0] @@ -274,7 +301,7 @@ rules: i/no-restricted-paths: [0] i/no-self-import: [2] i/no-unassigned-import: [0] - i/no-unresolved: [2, {commonjs: true, ignore: ["\\?.+$", ^vitest/]}] + i/no-unresolved: [2, {commonjs: true, ignore: ["\\?.+$"]}] i/no-unused-modules: [2, {unusedExports: true}] i/no-useless-path-segments: [2, {commonjs: true}] i/no-webpack-loader-syntax: [2] diff --git a/Makefile b/Makefile index 51577a48f0da..2a6b61348ab4 100644 --- a/Makefile +++ b/Makefile @@ -375,11 +375,13 @@ lint-backend-fix: lint-go-fix lint-go-vet lint-editorconfig .PHONY: lint-js lint-js: node_modules - npx eslint --color --max-warnings=0 --ext js,vue $(ESLINT_FILES) + npx eslint --color --max-warnings=0 --ext js,ts,vue $(ESLINT_FILES) + npx tsc .PHONY: lint-js-fix lint-js-fix: node_modules - npx eslint --color --max-warnings=0 --ext js,vue $(ESLINT_FILES) --fix + npx eslint --color --max-warnings=0 --ext js,ts,vue $(ESLINT_FILES) --fix + npx tsc .PHONY: lint-css lint-css: node_modules diff --git a/package-lock.json b/package-lock.json index 2f7a200ed274..3102d0223303 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "tippy.js": "6.3.7", "toastify-js": "1.12.0", "tributejs": "5.1.3", + "typescript": "5.5.2", "uint8-to-base64": "0.2.0", "vanilla-colorful": "0.7.2", "vue": "3.4.29", @@ -68,13 +69,16 @@ "@stoplight/spectral-cli": "6.11.1", "@stylistic/eslint-plugin-js": "2.2.1", "@stylistic/stylelint-plugin": "2.1.2", + "@typescript-eslint/parser": "7.14.1", "@vitejs/plugin-vue": "5.0.5", "eslint": "8.57.0", + "eslint-import-resolver-typescript": "3.6.1", "eslint-plugin-array-func": "4.0.0", "eslint-plugin-github": "5.0.1", "eslint-plugin-i": "2.29.1", "eslint-plugin-no-jquery": "3.0.1", "eslint-plugin-no-use-extend-native": "0.5.0", + "eslint-plugin-playwright": "1.6.2", "eslint-plugin-regexp": "2.6.0", "eslint-plugin-sonarjs": "1.0.3", "eslint-plugin-unicorn": "54.0.0", @@ -2399,15 +2403,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.13.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.13.1.tgz", - "integrity": "sha512-1ELDPlnLvDQ5ybTSrMhRTFDfOQEOXNM+eP+3HT/Yq7ruWpciQw+Avi73pdEbA4SooCawEWo3dtYbF68gN7Ed1A==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.14.1.tgz", + "integrity": "sha512-8lKUOebNLcR0D7RvlcloOacTOWzOqemWEWkKSVpMZVF/XVcwjPR+3MD08QzbW9TCGJ+DwIc6zUSGZ9vd8cO1IA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "7.13.1", - "@typescript-eslint/types": "7.13.1", - "@typescript-eslint/typescript-estree": "7.13.1", - "@typescript-eslint/visitor-keys": "7.13.1", + "@typescript-eslint/scope-manager": "7.14.1", + "@typescript-eslint/types": "7.14.1", + "@typescript-eslint/typescript-estree": "7.14.1", + "@typescript-eslint/visitor-keys": "7.14.1", "debug": "^4.3.4" }, "engines": { @@ -2426,6 +2431,98 @@ } } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.14.1.tgz", + "integrity": "sha512-gPrFSsoYcsffYXTOZ+hT7fyJr95rdVe4kGVX1ps/dJ+DfmlnjFN/GcMxXcVkeHDKqsq6uAcVaQaIi3cFffmAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.14.1", + "@typescript-eslint/visitor-keys": "7.14.1" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.14.1.tgz", + "integrity": "sha512-mL7zNEOQybo5R3AavY+Am7KLv8BorIv7HCYS5rKoNZKQD9tsfGUpO4KdAn3sSUvTiS4PQkr2+K0KJbxj8H9NDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.14.1.tgz", + "integrity": "sha512-k5d0VuxViE2ulIO6FbxxSZaxqDVUyMbXcidC8rHvii0I56XZPv8cq+EhMns+d/EVIL41sMXqRbK3D10Oza1bbA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.14.1", + "@typescript-eslint/visitor-keys": "7.14.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.14.1.tgz", + "integrity": "sha512-Crb+F75U1JAEtBeQGxSKwI60hZmmzaqA3z9sYsVm8X7W5cwLEm5bRe0/uXS6+MR/y8CVpKSR/ontIAIEPFcEkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.14.1", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "7.13.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.1.tgz", @@ -5353,6 +5450,31 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz", + "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "enhanced-resolve": "^5.12.0", + "eslint-module-utils": "^2.7.4", + "fast-glob": "^3.3.1", + "get-tsconfig": "^4.5.0", + "is-core-module": "^2.11.0", + "is-glob": "^4.0.3" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*" + } + }, "node_modules/eslint-module-utils": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", @@ -5701,6 +5823,30 @@ "node": ">=6.0.0" } }, + "node_modules/eslint-plugin-playwright": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-1.6.2.tgz", + "integrity": "sha512-mraN4Em3b5jLt01q7qWPyLg0Q5v3KAWfJSlEWwldyUXoa7DSPrBR4k6B6LROLqipsG8ndkwWMdjl1Ffdh15tag==", + "dev": true, + "workspaces": [ + "examples" + ], + "dependencies": { + "globals": "^13.23.0" + }, + "engines": { + "node": ">=16.6.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0", + "eslint-plugin-jest": ">=25" + }, + "peerDependenciesMeta": { + "eslint-plugin-jest": { + "optional": true + } + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", @@ -11867,11 +12013,10 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "devOptional": true, - "peer": true, + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", + "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 2efebb8df8f1..ec1500fb8b0c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "tippy.js": "6.3.7", "toastify-js": "1.12.0", "tributejs": "5.1.3", + "typescript": "5.5.2", "uint8-to-base64": "0.2.0", "vanilla-colorful": "0.7.2", "vue": "3.4.29", @@ -67,13 +68,16 @@ "@stoplight/spectral-cli": "6.11.1", "@stylistic/eslint-plugin-js": "2.2.1", "@stylistic/stylelint-plugin": "2.1.2", + "@typescript-eslint/parser": "7.14.1", "@vitejs/plugin-vue": "5.0.5", "eslint": "8.57.0", + "eslint-import-resolver-typescript": "3.6.1", "eslint-plugin-array-func": "4.0.0", "eslint-plugin-github": "5.0.1", "eslint-plugin-i": "2.29.1", "eslint-plugin-no-jquery": "3.0.1", "eslint-plugin-no-use-extend-native": "0.5.0", + "eslint-plugin-playwright": "1.6.2", "eslint-plugin-regexp": "2.6.0", "eslint-plugin-sonarjs": "1.0.3", "eslint-plugin-unicorn": "54.0.0", diff --git a/playwright.config.js b/playwright.config.ts similarity index 81% rename from playwright.config.js rename to playwright.config.ts index bdd303ae2534..d1cd299e2518 100644 --- a/playwright.config.js +++ b/playwright.config.ts @@ -1,15 +1,12 @@ -// @ts-check import {devices} from '@playwright/test'; +import {env} from 'node:process'; +import type {PlaywrightTestConfig} from '@playwright/test'; -const BASE_URL = process.env.GITEA_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000'; +const BASE_URL = env.GITEA_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000'; -/** - * @see https://playwright.dev/docs/test-configuration - * @type {import('@playwright/test').PlaywrightTestConfig} - */ export default { testDir: './tests/e2e/', - testMatch: /.*\.test\.e2e\.js/, // Match any .test.e2e.js files + testMatch: /.*\.test\.e2e\.ts/, // Match any .test.e2e.ts files /* Maximum time one test can run for. */ timeout: 30 * 1000, @@ -24,13 +21,13 @@ export default { }, /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: Boolean(process.env.CI), + forbidOnly: Boolean(env.CI), /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, + retries: env.CI ? 2 : 0, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: process.env.CI ? 'list' : [['list'], ['html', {outputFolder: 'tests/e2e/reports/', open: 'never'}]], + reporter: env.CI ? 'list' : [['list'], ['html', {outputFolder: 'tests/e2e/reports/', open: 'never'}]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { @@ -98,4 +95,4 @@ export default { outputDir: 'tests/e2e/test-artifacts/', /* Folder for test artifacts such as screenshots, videos, traces, etc. */ snapshotDir: 'tests/e2e/test-snapshots/', -}; +} satisfies PlaywrightTestConfig; diff --git a/tests/e2e/README.md b/tests/e2e/README.md index e5fd1ca6c07f..db083793d8b5 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -65,7 +65,7 @@ TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME= ## Running individual tests -Example command to run `example.test.e2e.js` test file: +Example command to run `example.test.e2e.ts` test file: _Note: unlike integration tests, this filtering is at the file level, not function_ diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 60528a1a78ef..d6d27e66be18 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -73,10 +73,10 @@ func TestMain(m *testing.M) { os.Exit(exitVal) } -// TestE2e should be the only test e2e necessary. It will collect all "*.test.e2e.js" files in this directory and build a test for each. +// TestE2e should be the only test e2e necessary. It will collect all "*.test.e2e.ts" files in this directory and build a test for each. func TestE2e(t *testing.T) { // Find the paths of all e2e test files in test directory. - searchGlob := filepath.Join(filepath.Dir(setting.AppPath), "tests", "e2e", "*.test.e2e.js") + searchGlob := filepath.Join(filepath.Dir(setting.AppPath), "tests", "e2e", "*.test.e2e.ts") paths, err := filepath.Glob(searchGlob) if err != nil { t.Fatal(err) diff --git a/tests/e2e/example.test.e2e.js b/tests/e2e/example.test.e2e.ts similarity index 86% rename from tests/e2e/example.test.e2e.js rename to tests/e2e/example.test.e2e.ts index 57c69a291787..32813b393446 100644 --- a/tests/e2e/example.test.e2e.js +++ b/tests/e2e/example.test.e2e.ts @@ -1,19 +1,18 @@ -// @ts-check import {test, expect} from '@playwright/test'; -import {login_user, save_visual, load_logged_in_context} from './utils_e2e.js'; +import {login_user, save_visual, load_logged_in_context} from './utils_e2e.ts'; test.beforeAll(async ({browser}, workerInfo) => { await login_user(browser, workerInfo, 'user2'); }); -test('Load Homepage', async ({page}) => { +test('homepage', async ({page}) => { const response = await page.goto('/'); await expect(response?.status()).toBe(200); // Status OK await expect(page).toHaveTitle(/^Gitea: Git with a cup of tea\s*$/); await expect(page.locator('.logo')).toHaveAttribute('src', '/assets/img/logo.svg'); }); -test('Test Register Form', async ({page}, workerInfo) => { +test('register', async ({page}, workerInfo) => { const response = await page.goto('/user/sign_up'); await expect(response?.status()).toBe(200); // Status OK await page.type('input[name=user_name]', `e2e-test-${workerInfo.workerIndex}`); @@ -29,7 +28,7 @@ test('Test Register Form', async ({page}, workerInfo) => { save_visual(page); }); -test('Test Login Form', async ({page}, workerInfo) => { +test('login', async ({page}, workerInfo) => { const response = await page.goto('/user/login'); await expect(response?.status()).toBe(200); // Status OK @@ -37,14 +36,14 @@ test('Test Login Form', async ({page}, workerInfo) => { await page.type('input[name=password]', `password`); await page.click('form button.ui.primary.button:visible'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle await expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`); save_visual(page); }); -test('Test Logged In User', async ({browser}, workerInfo) => { +test('logged in user', async ({browser}, workerInfo) => { const context = await load_logged_in_context(browser, workerInfo, 'user2'); const page = await context.newPage(); diff --git a/tests/e2e/utils_e2e.js b/tests/e2e/utils_e2e.ts similarity index 88% rename from tests/e2e/utils_e2e.js rename to tests/e2e/utils_e2e.ts index d60c78b16e04..5678c9c9d0ee 100644 --- a/tests/e2e/utils_e2e.js +++ b/tests/e2e/utils_e2e.ts @@ -1,4 +1,5 @@ import {expect} from '@playwright/test'; +import {env} from 'node:process'; const ARTIFACTS_PATH = `tests/e2e/test-artifacts`; const LOGIN_PASSWORD = 'password'; @@ -20,7 +21,7 @@ export async function login_user(browser, workerInfo, user) { await page.type('input[name=password]', LOGIN_PASSWORD); await page.click('form button.ui.primary.button:visible'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle await expect(page.url(), {message: `Failed to login user ${user}`}).toBe(`${workerInfo.project.use.baseURL}/`); @@ -44,8 +45,8 @@ export async function load_logged_in_context(browser, workerInfo, user) { export async function save_visual(page) { // Optionally include visual testing - if (process.env.VISUAL_TEST) { - await page.waitForLoadState('networkidle'); + if (env.VISUAL_TEST) { + await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle // Mock page/version string await page.locator('footer div.ui.left').evaluate((node) => node.innerHTML = 'MOCK'); await expect(page).toHaveScreenshot({ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000000..7ddbada76548 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "include": [ + "*", + "tests/e2e/**/*", + "tools/**/*", + "web_src/js/**/*", + ], + "compilerOptions": { + "target": "es2020", + "module": "node16", + "moduleResolution": "node16", + "lib": ["dom", "dom.iterable", "dom.asynciterable", "esnext"], + "allowImportingTsExtensions": true, + "allowJs": true, + "allowSyntheticDefaultImports": true, + "alwaysStrict": true, + "esModuleInterop": true, + "isolatedModules": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "stripInternal": true, + "strict": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": false, + "exactOptionalPropertyTypes": false, + } +} diff --git a/vitest.config.js b/vitest.config.ts similarity index 100% rename from vitest.config.js rename to vitest.config.ts diff --git a/web_src/js/components/.eslintrc.yaml b/web_src/js/components/.eslintrc.yaml deleted file mode 100644 index a79e96f330dd..000000000000 --- a/web_src/js/components/.eslintrc.yaml +++ /dev/null @@ -1,22 +0,0 @@ -plugins: - - eslint-plugin-vue - - eslint-plugin-vue-scoped-css - -extends: - - ../../../.eslintrc.yaml - - plugin:vue/vue3-recommended - - plugin:vue-scoped-css/vue3-recommended - -parserOptions: - sourceType: module - ecmaVersion: latest - -env: - browser: true - -rules: - vue/attributes-order: [0] - vue/html-closing-bracket-spacing: [2, {startTag: never, endTag: never, selfClosingTag: never}] - vue/max-attributes-per-line: [0] - vue/singleline-html-element-content-newline: [0] - vue-scoped-css/enforce-style-type: [0] diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 7f6524c7e3dd..97dc0d950f01 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -797,7 +797,7 @@ export function initRepositoryActionView() { } - - - - {{if .IsMention}} -

- You are receiving this because @{{.Doer.Name}} mentioned you. -

- {{end}} -

-

- @{{.Doer.Name}} - {{if not (eq .Doer.FullName "")}} - ({{.Doer.FullName}}) - {{end}} - {{if eq .ActionName "new"}} - created - {{else if eq .ActionName "close"}} - closed - {{else if eq .ActionName "reopen"}} - reopened - {{else}} - updated - {{end}} - {{.Repo}}#{{.Issue.Index}}. -

- {{if not (eq .Body "")}} -

Message content

-
- {{.Body}} - {{end}} -

-
-

- View it on Gitea. -

- - -``` - -This template produces something along these lines: - -### Subject - -> [mike/stuff] @rhonda commented on pull request #38: New color palette - -### Mail body - -> [@rhonda](#) (Rhonda Myers) updated [mike/stuff#38](#). -> -> #### Message content -> -> \_********************************\_******************************** -> -> Mike, I think we should tone down the blues a little. -> \_********************************\_******************************** -> -> [View it on Gitea](#). - -## Advanced - -The template system contains several functions that can be used to further process and format -the messages. Here's a list of some of them: - -| Name | Parameters | Available | Usage | -| ---------------- | ----------- | --------- | ------------------------------------------------------------------- | -| `AppUrl` | - | Any | Gitea's URL | -| `AppName` | - | Any | Set from `app.ini`, usually "Gitea" | -| `AppDomain` | - | Any | Gitea's host name | -| `EllipsisString` | string, int | Any | Truncates a string to the specified length; adds ellipsis as needed | -| `SanitizeHTML` | string | Body only | Sanitizes text by removing any dangerous HTML tags from it | -| `SafeHTML` | string | Body only | Takes the input as HTML, can be used for outputing raw HTML content | - -These are _functions_, not metadata, so they have to be used: - -```html -Like this: {{SanitizeHTML "Escapetext"}} -Or this: {{"Escapetext" | SanitizeHTML}} -Or this: {{AppUrl}} -But not like this: {{.AppUrl}} -``` diff --git a/docs/content/administration/mail-templates.zh-cn.md b/docs/content/administration/mail-templates.zh-cn.md deleted file mode 100644 index 3c7c2a939728..000000000000 --- a/docs/content/administration/mail-templates.zh-cn.md +++ /dev/null @@ -1,261 +0,0 @@ ---- -date: "2023-05-23T09:00:00+08:00" -title: "邮件模板" -slug: "mail-templates" -sidebar_position: 45 -toc: false -draft: false -aliases: - - /zh-cn/mail-templates -menu: - sidebar: - parent: "administration" - name: "邮件模板" - sidebar_position: 45 - identifier: "mail-templates" ---- - -# 邮件模板 - -为了定制特定操作的电子邮件主题和内容,可以使用模板来自定义 Gitea。这些功能的模板位于 [`custom` 目录](administration/customizing-gitea.md) 下。 -如果没有自定义的替代方案,Gitea 将使用内部模板作为默认模板。 - -自定义模板在 Gitea 启动时加载。对它们的更改在 Gitea 重新启动之前不会被识别。 - -## 支持模板的邮件通知 - -目前,以下通知事件使用模板: - -| 操作名称 | 用途 | -| ----------- | ------------------------------------------------------------------------------------------------------------ | -| `new` | 创建了新的工单或合并请求。 | -| `comment` | 在现有工单或合并请求中创建了新的评论。 | -| `close` | 关闭了工单或合并请求。 | -| `reopen` | 重新打开了工单或合并请求。 | -| `review` | 在合并请求中进行审查的首要评论。 | -| `approve` | 对合并请求进行批准的首要评论。 | -| `reject` | 对合并请求提出更改请求的审查的首要评论。 | -| `code` | 关于合并请求的代码的单个评论。 | -| `assigned` | 用户被分配到工单或合并请求。 | -| `default` | 未包括在上述类别中的任何操作,或者当对应类别的模板不存在时使用的模板。 | - -特定消息类型的模板路径为: - -```sh -custom/templates/mail/{操作类型}/{操作名称}.tmpl -``` - -其中 `{操作类型}` 是 `issue` 或 `pull`(针对合并请求),`{操作名称}` 是上述列出的操作名称之一。 - -例如,有关合并请求中的评论的电子邮件的特定模板是: - -```sh -custom/templates/mail/pull/comment.tmpl -``` - -然而,并不需要为每个操作类型/名称组合创建模板。 -使用回退系统来选择适当的模板。在此列表中,将使用 _第一个存在的_ 模板: - -- 所需**操作类型**和**操作名称**的特定模板。 -- 操作类型为 `issue` 和所需**操作名称**的模板。 -- 所需**操作类型**和操作名称为 `default` 的模板。 -- 操作类型为` issue` 和操作名称为 `default` 的模板。 - -唯一必需的模板是操作类型为 `issue` 操作名称为 `default` 的模板,除非用户在 `custom` 目录中覆盖了它。 - -## 模板语法 - -邮件模板是 UTF-8 编码的文本文件,需要遵循以下格式之一: - -``` -用于主题行的文本和宏 ------------- -用于邮件正文的文本和宏 -``` - -或者 - -``` -用于邮件正文的文本和宏 -``` - -指定 _主题_ 部分是可选的(因此也是虚线分隔符)。在使用时,_主题_ 和 _邮件正文_ 模板之间的分隔符需要至少三个虚线;分隔符行中不允许使用其他字符。 - -_主题_ 和 _邮件正文_ 由 [Golang的模板引擎](https://go.dev/pkg/text/template/) 解析,并提供了为每个通知组装的 _元数据上下文_。上下文包含以下元素: - -| 名称 | 类型 | 可用性 | 用途 | -| -------------------- | ------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `.FallbackSubject` | string | 始终可用 | 默认主题行。参见下文。 | -| `.Subject` | string | 仅在正文中可用 | 解析后的 _主题_。 | -| `.Body` | string | 始终可用 | 工单、合并请求或评论的消息,从 Markdown 解析为 HTML 并进行了清理。请勿与 _邮件正文_ 混淆。 | -| `.Link` | string | 始终可用 | 源工单、合并请求或评论的地址。 | -| `.Issue` | models.Issue | 始终可用 | 产生通知的工单(或合并请求)。要获取特定于合并请求的数据(例如 `HasMerged`),可以使用 `.Issue.PullRequest`,但需要注意,如果工单 _不是_ 合并请求,则该字段将为 `nil`。 | -| `.Comment` | models.Comment | 如果适用 | 如果通知是针对添加到工单或合并请求的评论,则其中包含有关评论的信息。 | -| `.IsPull` | bool | 始终可用 | 如果邮件通知与合并请求关联(即 `.Issue.PullRequest` 不为 `nil` ),则为 `true`。 | -| `.Repo` | string | 始终可用 | 仓库的名称,包括所有者名称(例如 `mike/stuff`) | -| `.User` | models.User | 始终可用 | 事件来源仓库的所有者。要获取用户名(例如 `mike`),可以使用 `.User.Name`。 | -| `.Doer` | models.User | 始终可用 | 执行触发通知事件的操作的用户。要获取用户名(例如 `rhonda`),可以使用 `.Doer.Name`。 | -| `.IsMention` | bool | 始终可用 | 如果此通知仅是因为在评论中提到了用户而生成的,并且收件人未订阅源,则为 `true`。如果收件人已订阅工单或仓库,则为 `false`。 | -| `.SubjectPrefix` | string | 始终可用 | 如果通知是关于除工单或合并请求创建之外的其他内容,则为 `Re:`;否则为空字符串。 | -| `.ActionType` | string | 始终可用 | `"issue"` 或 `"pull"`。它将与实际的 _操作类型_ 对应,与选择的模板无关。 | -| `.ActionName` | string | 始终可用 | 它将是上述操作类型之一(`new` ,`comment` 等),并与选择的模板对应。 | -| `.ReviewComments` | []models.Comment | 始终可用 | 审查中的代码评论列表。评论文本将在 `.RenderedContent` 中,引用的代码将在 `.Patch` 中。 | - -所有名称区分大小写。 - -### 模板中的主题部分 - -用于邮件主题的模板引擎是 Golang 的 [`text/template`](https://go.dev/pkg/text/template/)。 -有关语法的详细信息,请参阅链接的文档。 - -主题构建的步骤如下: - -- 根据通知类型和可用的模板选择一个模板。 -- 解析并解析模板(例如,将 `{{.Issue.Index}}` 转换为工单或合并请求的编号)。 -- 将所有空格字符(例如 `TAB`,`LF` 等)转换为普通空格。 -- 删除所有前导、尾随和多余的空格。 -- 将字符串截断为前 256 个字母(字符)。 - -如果最终结果为空字符串,**或者**没有可用的主题模板(即所选模板不包含主题部分),将使用Gitea的**内部默认值**。 - -内部默认(回退)主题相当于: - -``` -{{.SubjectPrefix}}[{{.Repo}}] {{.Issue.Title}} (#{{.Issue.Index}}) -``` - -例如:`Re: [mike/stuff] New color palette (#38)` - -即使存在有效的主题模板,Gitea的默认主题也可以在模板的元数据中作为 `.FallbackSubject` 找到。 - -### 模板中的邮件正文部分 - -用于邮件正文的模板引擎是 Golang 的 [`html/template`](https://go.dev/pkg/html/template/)。 -有关语法的详细信息,请参阅链接的文档。 - -邮件正文在邮件主题之后进行解析,因此还有一个额外的 _元数据_ 字段,即在考虑所有情况之后实际呈现的主题。 - -期望的结果是 HTML(包括结构元素,如``,``等)。可以通过 ` - - - {{if .IsMention}} -

- 您收到此邮件是因为 @{{.Doer.Name}} 提到了您。 -

- {{end}} -

-

- @{{.Doer.Name}} - {{if not (eq .Doer.FullName "")}} - ({{.Doer.FullName}}) - {{end}} - {{if eq .ActionName "new"}} - 创建了 - {{else if eq .ActionName "close"}} - 关闭了 - {{else if eq .ActionName "reopen"}} - 重新打开了 - {{else}} - 更新了 - {{end}} - {{.Repo}}#{{.Issue.Index}}。 -

- {{if not (eq .Body "")}} -

消息内容:

-
- {{.Body}} - {{end}} -

-
-

- 在 Gitea 上查看。 -

- - -``` - -该模板将生成以下内容: - -### 主题 - -> [mike/stuff] @rhonda 在合并请求 #38 上进行了评论:New color palette - -### 邮件正文 - -> [@rhonda](#)(Rhonda Myers)更新了 [mike/stuff#38](#)。 -> -> #### 消息内容 -> -> \_********************************\_******************************** -> -> Mike, I think we should tone down the blues a little. -> -> \_********************************\_******************************** -> -> [在 Gitea 上查看](#)。 - -## 高级用法 - -模板系统包含一些函数,可用于进一步处理和格式化消息。以下是其中一些函数的列表: - -| 函数名 | 参数 | 可用于 | 用法 | -|------------------| ----------- | ------------ | ------------------------------ | -| `AppUrl` | - | 任何地方 | Gitea 的 URL | -| `AppName` | - | 任何地方 | 从 `app.ini` 中设置,通常为 "Gitea" | -| `AppDomain` | - | 任何地方 | Gitea 的主机名 | -| `EllipsisString` | string, int | 任何地方 | 将字符串截断为指定长度;根据需要添加省略号 | -| `SanitizeHTML` | string | 仅正文部分 | 通过删除其中的危险 HTML 标签对文本进行清理 | -| `SafeHTML` | string | 仅正文部分 | 将输入作为 HTML 处理;可用于输出原始的 HTML 内容 | - -这些都是 _函数_,而不是元数据,因此必须按以下方式使用: - -```html -像这样使用: {{SanitizeHTML "Escapetext"}} -或者这样使用: {{"Escapetext" | SanitizeHTML}} -或者这样使用: {{AppUrl}} -但不要像这样使用: {{.AppUrl}} -``` diff --git a/docs/content/administration/repo-indexer.en-us.md b/docs/content/administration/repo-indexer.en-us.md deleted file mode 100644 index aa822229111c..000000000000 --- a/docs/content/administration/repo-indexer.en-us.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -date: "2019-09-06T01:35:00-03:00" -title: "Repository indexer" -slug: "repo-indexer" -sidebar_position: 45 -toc: false -draft: false -aliases: - - /en-us/repo-indexer -menu: - sidebar: - parent: "administration" - name: "Repository indexer" - sidebar_position: 45 - identifier: "repo-indexer" ---- - -# Repository indexer - -## Builtin repository code search without indexer - -Users could do repository-level code search without setting up a repository indexer. -The builtin code search is based on the `git grep` command, which is fast and efficient for small repositories. -Better code search support could be achieved by setting up the repository indexer. - -## Setting up the repository indexer - -Gitea can search through the files of the repositories by enabling this function in your [`app.ini`](administration/config-cheat-sheet.md): - -```ini -[indexer] -; ... -REPO_INDEXER_ENABLED = true -REPO_INDEXER_PATH = indexers/repos.bleve -MAX_FILE_SIZE = 1048576 -REPO_INDEXER_INCLUDE = -REPO_INDEXER_EXCLUDE = resources/bin/** -``` - -Please bear in mind that indexing the contents can consume a lot of system resources, especially when the index is created for the first time or globally updated (e.g. after upgrading Gitea). - -### Choosing the files for indexing by size - -The `MAX_FILE_SIZE` option will make the indexer skip all files larger than the specified value. - -### Choosing the files for indexing by path - -Gitea applies glob pattern matching from the [`gobwas/glob` library](https://github.com/gobwas/glob) to choose which files will be included in the index. - -Limiting the list of files prevents the indexes from becoming polluted with derived or irrelevant files (e.g. lss, sym, map, etc.), so the search results are more relevant. It can also help reduce the index size. - -`REPO_INDEXER_EXCLUDE_VENDORED` (default: true) excludes vendored files from index. - -`REPO_INDEXER_INCLUDE` (default: empty) is a comma separated list of glob patterns to **include** in the index. An empty list means "_include all files_". -`REPO_INDEXER_EXCLUDE` (default: empty) is a comma separated list of glob patterns to **exclude** from the index. Files that match this list will not be indexed. `REPO_INDEXER_EXCLUDE` takes precedence over `REPO_INDEXER_INCLUDE`. - -Pattern matching works as follows: - -- To match all files with a `.txt` extension no matter what directory, use `**.txt`. -- To match all files with a `.txt` extension _only at the root level of the repository_, use `*.txt`. -- To match all files inside `resources/bin` and below, use `resources/bin/**`. -- To match all files _immediately inside_ `resources/bin`, use `resources/bin/*`. -- To match all files named `Makefile`, use `**Makefile`. -- Matching a directory has no effect; the pattern `resources/bin` will not include/exclude files inside that directory; `resources/bin/**` will. -- All files and patterns are normalized to lower case, so `**Makefile`, `**makefile` and `**MAKEFILE` are equivalent. diff --git a/docs/content/administration/repo-indexer.zh-cn.md b/docs/content/administration/repo-indexer.zh-cn.md deleted file mode 100644 index d70a617ae124..000000000000 --- a/docs/content/administration/repo-indexer.zh-cn.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -date: "2023-05-23T09:00:00+08:00" -title: "仓库索引器" -slug: "repo-indexer" -sidebar_position: 45 -toc: false -draft: false -aliases: - - /zh-cn/repo-indexer -menu: - sidebar: - parent: "administration" - name: "仓库索引器" - sidebar_position: 45 - identifier: "repo-indexer" ---- - -# 仓库索引器 - -## 设置仓库索引器 - -通过在您的 [`app.ini`](administration/config-cheat-sheet.md) 中启用此功能,Gitea 可以通过仓库的文件进行搜索: - -```ini -[indexer] -; ... -REPO_INDEXER_ENABLED = true -REPO_INDEXER_PATH = indexers/repos.bleve -MAX_FILE_SIZE = 1048576 -REPO_INDEXER_INCLUDE = -REPO_INDEXER_EXCLUDE = resources/bin/** -``` - -请记住,索引内容可能会消耗大量系统资源,特别是在首次创建索引或全局更新索引时(例如升级 Gitea 之后)。 - -### 按大小选择要索引的文件 - -`MAX_FILE_SIZE` 选项将使索引器跳过所有大于指定值的文件。 - -### 按路径选择要索引的文件 - -Gitea使用 [`gobwas/glob` 库](https://github.com/gobwas/glob) 中的 glob 模式匹配来选择要包含在索引中的文件。 - -限制文件列表可以防止索引被派生或无关的文件(例如 lss、sym、map 等)污染,从而使搜索结果更相关。这还有助于减小索引的大小。 - -`REPO_INDEXER_EXCLUDE_VENDORED`(默认值为 true)将排除供应商文件不包含在索引中。 - -`REPO_INDEXER_INCLUDE`(默认值为空)是一个逗号分隔的 glob 模式列表,用于在索引中**包含**的文件。空列表表示“_包含所有文件_”。 -`REPO_INDEXER_EXCLUDE`(默认值为空)是一个逗号分隔的 glob 模式列表,用于从索引中**排除**的文件。与该列表匹配的文件将不会被索引。`REPO_INDEXER_EXCLUDE` 优先于 `REPO_INDEXER_INCLUDE`。 - -模式匹配工作方式如下: - -- 要匹配所有带有 `.txt` 扩展名的文件,无论在哪个目录中,请使用 `**.txt`。 -- 要匹配仅在仓库的根级别中具有 `.txt` 扩展名的所有文件,请使用 `*.txt`。 -- 要匹配 `resources/bin` 目录及其子目录中的所有文件,请使用 `resources/bin/**`。 -- 要匹配位于 `resources/bin` 目录下的所有文件,请使用 `resources/bin/*`。 -- 要匹配所有名为 `Makefile` 的文件,请使用 `**Makefile`。 -- 匹配目录没有效果;模式 `resources/bin` 不会包含/排除该目录中的文件;`resources/bin/**` 会。 -- 所有文件和模式都规范化为小写,因此 `**Makefile`、`**makefile` 和 `**MAKEFILE` 是等效的。 diff --git a/docs/content/administration/reverse-proxies.en-us.md b/docs/content/administration/reverse-proxies.en-us.md deleted file mode 100644 index dff58c10eb71..000000000000 --- a/docs/content/administration/reverse-proxies.en-us.md +++ /dev/null @@ -1,394 +0,0 @@ ---- -date: "2018-05-22T11:00:00+00:00" -title: "Reverse Proxies" -slug: "reverse-proxies" -sidebar_position: 16 -toc: false -draft: false -aliases: - - /en-us/reverse-proxies -menu: - sidebar: - parent: "administration" - name: "Reverse Proxies" - sidebar_position: 16 - identifier: "reverse-proxies" ---- - -# Reverse Proxies - -## General configuration - -1. Set `[server] ROOT_URL = https://git.example.com/` in your `app.ini` file. -2. Make the reverse-proxy pass `https://git.example.com/foo` to `http://gitea:3000/foo`. -3. Make sure the reverse-proxy does not decode the URI. The request `https://git.example.com/a%2Fb` should be passed as `http://gitea:3000/a%2Fb`. -4. Make sure `Host` and `X-Fowarded-Proto` headers are correctly passed to Gitea to make Gitea see the real URL being visited. - -### Use a sub-path - -Usually it's **not recommended** to put Gitea in a sub-path, it's not widely used and may have some issues in rare cases. - -To make Gitea work with a sub-path (eg: `https://common.example.com/gitea/`), -there are some extra requirements besides the general configuration above: - -1. Use `[server] ROOT_URL = https://common.example.com/gitea/` in your `app.ini` file. -2. Make the reverse-proxy pass `https://common.example.com/gitea/foo` to `http://gitea:3000/foo`. -3. The container registry requires a fixed sub-path `/v2` at the root level which must be configured: - - Make the reverse-proxy pass `https://common.example.com/v2` to `http://gitea:3000/v2`. - - Make sure the URI and headers are also correctly passed (see the general configuration above). - -## Nginx - -If you want Nginx to serve your Gitea instance, add the following `server` section to the `http` section of `nginx.conf`. - -Make sure `client_max_body_size` is large enough, otherwise there would be "413 Request Entity Too Large" error when uploading large files. - -```nginx -server { - ... - location / { - client_max_body_size 512M; - proxy_pass http://localhost:3000; - proxy_set_header Connection $http_connection; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -## Nginx with a sub-path - -In case you already have a site, and you want Gitea to share the domain name, -you can setup Nginx to serve Gitea under a sub-path by adding the following `server` section -into the `http` section of `nginx.conf`: - -```nginx -server { - ... - location ~ ^/(gitea|v2)($|/) { - client_max_body_size 512M; - - # make nginx use unescaped URI, keep "%2F" as-is, remove the "/gitea" sub-path prefix, pass "/v2" as-is. - rewrite ^ $request_uri; - rewrite ^(/gitea)?(/.*) $2 break; - proxy_pass http://127.0.0.1:3000$uri; - - # other common HTTP headers, see the "Nginx" config section above - proxy_set_header Connection $http_connection; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -Then you **MUST** set something like `[server] ROOT_URL = http://git.example.com/gitea/` correctly in your configuration. - -## Nginx and serve static resources directly - -We can tune the performance in splitting requests into categories static and dynamic. - -CSS files, JavaScript files, images and web fonts are static content. -The front page, a repository view or issue list is dynamic content. - -Nginx can serve static resources directly and proxy only the dynamic requests to Gitea. -Nginx is optimized for serving static content, while the proxying of large responses might be the opposite of that -(see [https://serverfault.com/q/587386](https://serverfault.com/q/587386)). - -Download a snapshot of the Gitea source repository to `/path/to/gitea/`. -After this, run `make frontend` in the repository directory to generate the static resources. We are only interested in the `public/` directory for this task, so you can delete the rest. -(You will need to have [Node with npm](https://nodejs.org/en/download/) and `make` installed to generate the static resources) - -Depending on the scale of your user base, you might want to split the traffic to two distinct servers, -or use a cdn for the static files. - -### Single node and single domain - -Set `[server] STATIC_URL_PREFIX = /_/static` in your configuration. - -```nginx -server { - listen 80; - server_name git.example.com; - - location /_/static/assets/ { - alias /path/to/gitea/public/; - } - - location / { - proxy_pass http://localhost:3000; - } -} -``` - -### Two nodes and two domains - -Set `[server] STATIC_URL_PREFIX = http://cdn.example.com/gitea` in your configuration. - -```nginx -# application server running Gitea -server { - listen 80; - server_name git.example.com; - - location / { - proxy_pass http://localhost:3000; - } -} -``` - -```nginx -# static content delivery server -server { - listen 80; - server_name cdn.example.com; - - location /gitea/ { - alias /path/to/gitea/public/; - } - - location / { - return 404; - } -} -``` - -## Apache HTTPD - -If you want Apache HTTPD to serve your Gitea instance, you can add the following to your Apache HTTPD configuration (usually located at `/etc/apache2/httpd.conf` in Ubuntu): - -```apacheconf - - ... - ProxyPreserveHost On - ProxyRequests off - AllowEncodedSlashes NoDecode - ProxyPass / http://localhost:3000/ nocanon - RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} - -``` - -Note: The following Apache HTTPD mods must be enabled: `proxy`, `proxy_http`. - -If you wish to use Let's Encrypt with webroot validation, add the line `ProxyPass /.well-known !` before `ProxyPass` to disable proxying these requests to Gitea. - -## Apache HTTPD with a sub-path - -In case you already have a site, and you want Gitea to share the domain name, you can setup Apache HTTPD to serve Gitea under a sub-path by adding the following to you Apache HTTPD configuration (usually located at `/etc/apache2/httpd.conf` in Ubuntu): - -```apacheconf - - ... - - Order allow,deny - Allow from all - - AllowEncodedSlashes NoDecode - # Note: no trailing slash after either /git or port - ProxyPass /git http://localhost:3000 nocanon - ProxyPreserveHost On - RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} - -``` - -Then you **MUST** set something like `[server] ROOT_URL = http://git.example.com/git/` correctly in your configuration. - -Note: The following Apache HTTPD mods must be enabled: `proxy`, `proxy_http`. - -## Caddy - -If you want Caddy to serve your Gitea instance, you can add the following server block to your Caddyfile: - -``` -git.example.com { - reverse_proxy localhost:3000 -} -``` - -## Caddy with a sub-path - -In case you already have a site, and you want Gitea to share the domain name, you can setup Caddy to serve Gitea under a sub-path by adding the following to your server block in your Caddyfile: - -``` -git.example.com { - route /git/* { - uri strip_prefix /git - reverse_proxy localhost:3000 - } -} -``` - -Then set `[server] ROOT_URL = http://git.example.com/git/` in your configuration. - -## IIS - -If you wish to run Gitea with IIS. You will need to setup IIS with URL Rewrite as reverse proxy. - -1. Setup an empty website in IIS, named let's say, `Gitea Proxy`. -2. Follow the first two steps in [Microsoft's Technical Community Guide to Setup IIS with URL Rewrite](https://techcommunity.microsoft.com/t5/iis-support-blog/setup-iis-with-url-rewrite-as-a-reverse-proxy-for-real-world/ba-p/846222#M343). That is: - -- Install Application Request Routing (ARR for short) either by using the Microsoft Web Platform Installer 5.1 (WebPI) or downloading the extension from [IIS.net](https://www.iis.net/downloads/microsoft/application-request-routing) -- Once the module is installed in IIS, you will see a new Icon in the IIS Administration Console called URL Rewrite. -- Open the IIS Manager Console and click on the `Gitea Proxy` Website from the tree view on the left. Select and double click the URL Rewrite Icon from the middle pane to load the URL Rewrite interface. -- Choose the `Add Rule` action from the right pane of the management console and select the `Reverse Proxy Rule` from the `Inbound and Outbound Rules` category. -- In the Inbound Rules section, set the server name to be the host that Gitea is running on with its port. e.g. if you are running Gitea on the localhost with port 3000, the following should work: `127.0.0.1:3000` -- Enable SSL Offloading -- In the Outbound Rules, ensure `Rewrite the domain names of the links in HTTP response` is set and set the `From:` field as above and the `To:` to your external hostname, say: `git.example.com` -- Now edit the `web.config` for your website to match the following: (changing `127.0.0.1:3000` and `git.example.com` as appropriate) - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -## HAProxy - -If you want HAProxy to serve your Gitea instance, you can add the following to your HAProxy configuration - -add an acl in the frontend section to redirect calls to gitea.example.com to the correct backend - -``` -frontend http-in - ... - acl acl_gitea hdr(host) -i gitea.example.com - use_backend gitea if acl_gitea - ... -``` - -add the previously defined backend section - -``` -backend gitea - server localhost:3000 check -``` - -If you redirect the http content to https, the configuration work the same way, just remember that the connection between HAProxy and Gitea will be done via http so you do not have to enable https in Gitea's configuration. - -## HAProxy with a sub-path - -In case you already have a site, and you want Gitea to share the domain name, you can setup HAProxy to serve Gitea under a sub-path by adding the following to you HAProxy configuration: - -``` -frontend http-in - ... - acl acl_gitea path_beg /gitea - use_backend gitea if acl_gitea - ... -``` - -With that configuration http://example.com/gitea/ will redirect to your Gitea instance. - -then for the backend section - -``` -backend gitea - http-request replace-path /gitea\/?(.*) \/\1 - server localhost:3000 check -``` - -The added http-request will automatically add a trailing slash if needed and internally remove /gitea from the path to allow it to work correctly with Gitea by setting properly http://example.com/gitea as the root. - -Then you **MUST** set something like `[server] ROOT_URL = http://example.com/gitea/` correctly in your configuration. - -## Traefik - -If you want traefik to serve your Gitea instance, you can add the following label section to your `docker-compose.yaml` (Assuming the provider is docker). - -```yaml -gitea: - image: gitea/gitea - ... - labels: - - "traefik.enable=true" - - "traefik.http.routers.gitea.rule=Host(`example.com`)" - - "traefik.http.services.gitea-websecure.loadbalancer.server.port=3000" -``` - -This config assumes that you are handling HTTPS on the traefik side and using HTTP between Gitea and traefik. - -## Traefik with a sub-path - -In case you already have a site, and you want Gitea to share the domain name, you can setup Traefik to serve Gitea under a sub-path by adding the following to your `docker-compose.yaml` (Assuming the provider is docker) : - -```yaml -gitea: - image: gitea/gitea - ... - labels: - - "traefik.enable=true" - - "traefik.http.routers.gitea.rule=Host(`example.com`) && PathPrefix(`/gitea`)" - - "traefik.http.services.gitea-websecure.loadbalancer.server.port=3000" - - "traefik.http.middlewares.gitea-stripprefix.stripprefix.prefixes=/gitea" - - "traefik.http.routers.gitea.middlewares=gitea-stripprefix" -``` - -This config assumes that you are handling HTTPS on the traefik side and using HTTP between Gitea and traefik. - -Then you **MUST** set something like `[server] ROOT_URL = http://example.com/gitea/` correctly in your configuration. diff --git a/docs/content/administration/reverse-proxies.zh-cn.md b/docs/content/administration/reverse-proxies.zh-cn.md deleted file mode 100644 index 4c7de782c757..000000000000 --- a/docs/content/administration/reverse-proxies.zh-cn.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -date: "2018-05-22T11:00:00+00:00" -title: "反向代理" -slug: "reverse-proxies" -sidebar_position: 16 -toc: false -draft: false -aliases: - - /zh-cn/reverse-proxies -menu: - sidebar: - parent: "administration" - name: "反向代理" - sidebar_position: 16 - identifier: "reverse-proxies" ---- - -# 反向代理 - -## 使用 Nginx 作为反向代理服务 - -如果您想使用 Nginx 作为 Gitea 的反向代理服务,您可以参照以下 `nginx.conf` 配置中 `server` 的 `http` 部分: - -``` -server { - listen 80; - server_name git.example.com; - - location / { - proxy_pass http://localhost:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -## 使用 Nginx 作为反向代理服务并将 Gitea 路由至一个子路径 - -如果您已经有一个域名并且想与 Gitea 共享该域名,您可以增加以下 `nginx.conf` 配置中 `server` 的 `http` 部分,为 Gitea 添加路由规则: - -``` -server { - listen 80; - server_name git.example.com; - - # 注意: /git/ 最后需要有一个路径符号 - location /git/ { - # 注意: 反向代理后端 URL 的最后需要有一个路径符号 - proxy_pass http://localhost:3000/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -然后您**必须**在 Gitea 的配置文件中正确的添加类似 `[server] ROOT_URL = http://git.example.com/git/` 的配置项。 - -## 使用 Apache HTTPD 作为反向代理服务 - -如果您想使用 Apache HTTPD 作为 Gitea 的反向代理服务,您可以为您的 Apache HTTPD 作如下配置(在 Ubuntu 中,配置文件通常在 `/etc/apache2/httpd.conf` 目录下): - -``` - - ... - ProxyPreserveHost On - ProxyRequests off - AllowEncodedSlashes NoDecode - ProxyPass / http://localhost:3000/ nocanon - -``` - -注:必须启用以下 Apache HTTPD 组件:`proxy`, `proxy_http` - -## 使用 Apache HTTPD 作为反向代理服务并将 Gitea 路由至一个子路径 - -如果您已经有一个域名并且想与 Gitea 共享该域名,您可以增加以下配置为 Gitea 添加路由规则(在 Ubuntu 中,配置文件通常在 `/etc/apache2/httpd.conf` 目录下): - -``` - - ... - - Order allow,deny - Allow from all - - AllowEncodedSlashes NoDecode - # 注意: 路径和 URL 后面都不要写路径符号 '/' - ProxyPass /git http://localhost:3000 nocanon - -``` - -然后您**必须**在 Gitea 的配置文件中正确的添加类似 `[server] ROOT_URL = http://git.example.com/git/` 的配置项。 - -注:必须启用以下 Apache HTTPD 组件:`proxy`, `proxy_http` - -## 使用 Caddy 作为反向代理服务 - -如果您想使用 Caddy 作为 Gitea 的反向代理服务,您可以在 `Caddyfile` 中添加如下配置: - -``` -git.example.com { - proxy / http://localhost:3000 -} -``` - -## 使用 Caddy 作为反向代理服务并将 Gitea 路由至一个子路径 - -如果您已经有一个域名并且想与 Gitea 共享该域名,您可以在您的 `Caddyfile` 文件中增加以下配置,为 Gitea 添加路由规则: - -``` -git.example.com { - # 注意: 路径 /git/ 最后需要有路径符号 - proxy /git/ http://localhost:3000 -} -``` - -然后您**必须**在 Gitea 的配置文件中正确的添加类似 `[server] ROOT_URL = http://git.example.com/git/` 的配置项。 - -## 使用 Traefik 作为反向代理服务 - -如果您想使用 traefik 作为 Gitea 的反向代理服务,您可以在 `docker-compose.yaml` 中添加 label 部分(假设使用 docker 作为 traefik 的 provider): - -```yaml -gitea: - image: gitea/gitea - ... - labels: - - "traefik.enable=true" - - "traefik.http.routers.gitea.rule=Host(`example.com`)" - - "traefik.http.services.gitea-websecure.loadbalancer.server.port=3000" -``` - -这份配置假设您使用 traefik 来处理 HTTPS 服务,并在其和 Gitea 之间使用 HTTP 进行通信。 diff --git a/docs/content/administration/search-engines-indexation.en-us.md b/docs/content/administration/search-engines-indexation.en-us.md deleted file mode 100644 index 664940697d07..000000000000 --- a/docs/content/administration/search-engines-indexation.en-us.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -date: "2019-12-31T13:55:00+05:00" -title: "Search Engines Indexation" -slug: "search-engines-indexation" -sidebar_position: 60 -toc: false -draft: false -aliases: - - /en-us/search-engines-indexation -menu: - sidebar: - parent: "administration" - name: "Search Engines Indexation" - sidebar_position: 60 - identifier: "search-engines-indexation" ---- - -# Search engines indexation of your Gitea installation - -By default your Gitea installation will be indexed by search engines. -If you don't want your repository to be visible for search engines read further. - -## Block search engines indexation using robots.txt - -To make Gitea serve a custom `robots.txt` (default: empty 404) for top level installations, -create a file with path `public/robots.txt` in the [`custom` folder or `CustomPath`](administration/customizing-gitea.md) - -Examples on how to configure the `robots.txt` can be found at [https://moz.com/learn/seo/robotstxt](https://moz.com/learn/seo/robotstxt). - -```txt -User-agent: * -Disallow: / -``` - -If you installed Gitea in a subdirectory, you will need to create or edit the `robots.txt` in the top level directory. - -```txt -User-agent: * -Disallow: /gitea/ -``` diff --git a/docs/content/administration/search-engines-indexation.zh-cn.md b/docs/content/administration/search-engines-indexation.zh-cn.md deleted file mode 100644 index 904e6de11bb5..000000000000 --- a/docs/content/administration/search-engines-indexation.zh-cn.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -date: "2023-05-23T09:00:00+08:00" -title: "搜索引擎索引" -slug: "search-engines-indexation" -sidebar_position: 60 -toc: false -draft: false -aliases: - - /zh-cn/search-engines-indexation -menu: - sidebar: - parent: "administration" - name: "搜索引擎索引" - sidebar_position: 60 - identifier: "search-engines-indexation" ---- - -# Gitea 安装的搜索引擎索引 - -默认情况下,您的 Gitea 安装将被搜索引擎索引。 -如果您不希望您的仓库对搜索引擎可见,请进一步阅读。 - -## 使用 robots.txt 阻止搜索引擎索引 - -为了使 Gitea 为顶级安装提供自定义的`robots.txt`(默认为空的 404),请在 [`custom`文件夹或`CustomPath`](administration/customizing-gitea.md)中创建一个名为 `public/robots.txt` 的文件。 - -有关如何配置 `robots.txt` 的示例,请参考 [https://moz.com/learn/seo/robotstxt](https://moz.com/learn/seo/robotstxt)。 - -```txt -User-agent: * -Disallow: / -``` - -如果您将Gitea安装在子目录中,则需要在顶级目录中创建或编辑 `robots.txt`。 - -```txt -User-agent: * -Disallow: /gitea/ -``` diff --git a/docs/content/administration/signing.en-us.md b/docs/content/administration/signing.en-us.md deleted file mode 100644 index 837af14bb1f9..000000000000 --- a/docs/content/administration/signing.en-us.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -date: "2019-08-17T10:20:00+01:00" -title: "GPG Commit Signatures" -slug: "signing" -sidebar_position: 50 -toc: false -draft: false -aliases: - - /en-us/signing -menu: - sidebar: - parent: "administration" - name: "GPG Commit Signatures" - sidebar_position: 50 - identifier: "signing" ---- - -# GPG Commit Signatures - -Gitea will verify GPG commit signatures in the provided tree by -checking if the commits are signed by a key within the Gitea database, -or if the commit matches the default key for Git. - -Keys are not checked to determine if they have expired or revoked. -Keys are also not checked with keyservers. - -A commit will be marked with a grey unlocked icon if no key can be -found to verify it. If a commit is marked with a red unlocked icon, -it is reported to be signed with a key with an id. - -Please note: The signer of a commit does not have to be an author or -committer of a commit. - -This functionality requires Git >= 1.7.9 but for full functionality -this requires Git >= 2.0.0. - -## Automatic Signing - -There are a number of places where Gitea will generate commits itself: - -- Repository Initialisation -- Wiki Changes -- CRUD actions using the editor or the API -- Merges from Pull Requests - -Depending on configuration and server trust you may want Gitea to -sign these commits. - -## Installing and generating a GPG key for Gitea - -It is up to a server administrator to determine how best to install -a signing key. Gitea generates all its commits using the server `git` -command at present - and therefore the server `gpg` will be used for -signing (if configured.) Administrators should review best-practices -for GPG - in particular it is probably advisable to only install a -signing secret subkey without the master signing and certifying secret -key. - -## General Configuration - -Gitea's configuration for signing can be found with the -`[repository.signing]` section of `app.ini`: - -```ini -... -[repository.signing] -SIGNING_KEY = default -SIGNING_NAME = -SIGNING_EMAIL = -INITIAL_COMMIT = always -CRUD_ACTIONS = pubkey, twofa, parentsigned -WIKI = never -MERGES = pubkey, twofa, basesigned, commitssigned - -... -``` - -### `SIGNING_KEY` - -The first option to discuss is the `SIGNING_KEY`. There are three main -options: - -- `none` - this prevents Gitea from signing any commits -- `default` - Gitea will default to the key configured within `git config` -- `KEYID` - Gitea will sign commits with the gpg key with the ID - `KEYID`. In this case you should provide a `SIGNING_NAME` and - `SIGNING_EMAIL` to be displayed for this key. - -The `default` option will interrogate `git config` for -`commit.gpgsign` option - if this is set, then it will use the results -of the `user.signingkey`, `user.name` and `user.email` as appropriate. - -Please note: by adjusting Git's `config` file within Gitea's -repositories, `SIGNING_KEY=default` could be used to provide different -signing keys on a per-repository basis. However, this is clearly not an -ideal UI and therefore subject to change. - -**Since 1.17**, Gitea runs git in its own home directory `[git].HOME_PATH` (default to `%(APP_DATA_PATH)/home`) -and uses its own config `{[git].HOME_PATH}/.gitconfig`. -If you have your own customized git config for Gitea, you should set these configs in system git config (aka `/etc/gitconfig`) -or the Gitea internal git config `{[git].HOME_PATH}/.gitconfig`. -Related home files for git command (like `.gnupg`) should also be put in Gitea's git home directory `[git].HOME_PATH`. -If you like to keep the `.gnupg` directory outside of `{[git].HOME_PATH}/`, consider setting the `$GNUPGHOME` environment variable to your preferred location. - -### `INITIAL_COMMIT` - -This option determines whether Gitea should sign the initial commit -when creating a repository. The possible values are: - -- `never`: Never sign -- `pubkey`: Only sign if the user has a public key -- `twofa`: Only sign if the user logs in with two factor authentication -- `always`: Always sign - -Options other than `never` and `always` can be combined as a comma -separated list. The commit will be signed if all selected options are true. - -### `WIKI` - -This options determines if Gitea should sign commits to the Wiki. -The possible values are: - -- `never`: Never sign -- `pubkey`: Only sign if the user has a public key -- `twofa`: Only sign if the user logs in with two-factor authentication -- `parentsigned`: Only sign if the parent commit is signed. -- `always`: Always sign - -Options other than `never` and `always` can be combined as a comma -separated list. The commit will be signed if all selected options are true. - -### `CRUD_ACTIONS` - -This option determines if Gitea should sign commits from the web -editor or API CRUD actions. The possible values are: - -- `never`: Never sign -- `pubkey`: Only sign if the user has a public key -- `twofa`: Only sign if the user logs in with two-factor authentication -- `parentsigned`: Only sign if the parent commit is signed. -- `always`: Always sign - -Options other than `never` and `always` can be combined as a comma -separated list. The change will be signed if all selected options are true. - -### `MERGES` - -This option determines if Gitea should sign merge commits from PRs. -The possible options are: - -- `never`: Never sign -- `pubkey`: Only sign if the user has a public key -- `twofa`: Only sign if the user logs in with two-factor authentication -- `basesigned`: Only sign if the parent commit in the base repo is signed. -- `headsigned`: Only sign if the head commit in the head branch is signed. -- `commitssigned`: Only sign if all the commits in the head branch to the merge point are signed. -- `approved`: Only sign approved merges to a protected branch. -- `always`: Always sign - -Options other than `never` and `always` can be combined as a comma -separated list. The merge will be signed if all selected options are true. - -## Obtaining the Public Key of the Signing Key - -The public key used to sign Gitea's commits can be obtained from the API at: - -```sh -/api/v1/signing-key.gpg -``` - -In cases where there is a repository specific key this can be obtained from: - -```sh -/api/v1/repos/:username/:reponame/signing-key.gpg -``` diff --git a/docs/content/administration/signing.zh-cn.md b/docs/content/administration/signing.zh-cn.md deleted file mode 100644 index 5910b1bf7836..000000000000 --- a/docs/content/administration/signing.zh-cn.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -date: "2023-05-23T09:00:00+08:00" -title: "GPG 提交签名" -slug: "signing" -sidebar_position: 50 -toc: false -draft: false -aliases: - - /zh-cn/signing -menu: - sidebar: - parent: "administration" - name: "GPG 提交签名" - sidebar_position: 50 - identifier: "signing" ---- - -# GPG 提交签名 - -Gitea 将通过检查提交是否由 Gitea 数据库中的密钥签名,或者提交是否与 Git 的默认密钥匹配,来验证提供的树中的 GPG 提交签名。 - -密钥不会被检查以确定它们是否已过期或撤销。密钥也不会与密钥服务器进行检查。 - -如果找不到用于验证提交的密钥,提交将被标记为灰色的未锁定图标。如果提交被标记为红色的未锁定图标,则表示它使用带有 ID 的密钥签名。 - -请注意:提交的签署者不必是提交的作者或提交者。 - -此功能要求 Git >= 1.7.9,但要实现全部功能,需要 Git >= 2.0.0。 - -## 自动签名 - -有许多地方 Gitea 会生成提交: - -- 仓库初始化 -- Wiki 更改 -- 使用编辑器或 API 进行的 CRUD 操作 -- 从合并请求进行合并 - -根据配置和服务器信任,您可能希望 Gitea 对这些提交进行签名。 - -## 安装和生成 Gitea 的 GPG 密钥 - -如何安装签名密钥由服务器管理员决定。Gitea 目前使用服务器的 `git` 命令生成所有提交,因此将使用服务器的 `gpg` 进行签名(如果配置了)。管理员应该审查 GPG 的最佳实践 - 特别是可能建议仅安装签名的子密钥,而不是主签名和认证的密钥。 - -## 通用配置 - -Gitea 的签名配置可以在 `app.ini` 的 `[repository.signing]` 部分找到: - -```ini -... -[repository.signing] -SIGNING_KEY = default -SIGNING_NAME = -SIGNING_EMAIL = -INITIAL_COMMIT = always -CRUD_ACTIONS = pubkey, twofa, parentsigned -WIKI = never -MERGES = pubkey, twofa, basesigned, commitssigned - -... -``` - -### `SIGNING_KEY` - -首先讨论的选项是 `SIGNING_KEY`。有三个主要选项: - -- `none` - 这将阻止 Gitea 对任何提交进行签名 -- `default` - Gitea 将使用 `git config` 中配置的默认密钥 -- `KEYID` - Gitea 将使用具有 ID `KEYID` 的 GPG 密钥对提交进行签名。在这种情况下,您应该提供 `SIGNING_NAME` 和 `SIGNING_EMAIL`,以便显示此密钥的信息。 - -`default` 选项将读取 `git config` 中的 `commit.gpgsign` 选项 - 如果设置了该选项,它将使用 `user.signingkey`、`user.name` 和 `user.email` 的结果。 - -请注意:通过在 Gitea 的仓库中调整 Git 的 `config` 文件,可以使用 `SIGNING_KEY=default` 为每个仓库提供不同的签名密钥。然而,这显然不是一个理想的用户界面,因此可能会发生更改。 - -**自 1.17 起**,Gitea 在自己的主目录 `[git].HOME_PATH`(默认为 `%(APP_DATA_PATH)/home`)中运行 git,并使用自己的配置文件 `{[git].HOME_PATH}/.gitconfig`。 -如果您有自己定制的 Gitea git 配置,您应该将这些配置设置在系统 git 配置文件中(例如 `/etc/gitconfig`)或者 Gitea 的内部 git 配置文件 `{[git].HOME_PATH}/.gitconfig` 中。 -与 git 命令相关的主目录文件(如 `.gnupg`)也应该放在 Gitea 的 git 主目录 `[git].HOME_PATH` 中。 -如果您希望将 `.gnupg` 目录放在 `{[git].HOME_PATH}/` 之外的位置,请考虑设置 `$GNUPGHOME` 环境变量为您首选的位置。 - -### `INITIAL_COMMIT` - -此选项确定在创建仓库时,Gitea 是否应该对初始提交进行签名。可能的取值有: - -- `never`:从不签名 -- `pubkey`:仅在用户拥有公钥时进行签名 -- `twofa`:仅在用户使用 2FA 登录时进行签名 -- `always`:始终签名 - -除了 `never` 和 `always` 之外的选项可以组合为逗号分隔的列表。如果所有选择的选项都为 true,则提交将被签名。 - -### `WIKI` - -此选项确定 Gitea 是否应该对 Wiki 的提交进行签名。可能的取值有: - -- `never`:从不签名 -- `pubkey`:仅在用户拥有公钥时进行签名 -- `twofa`:仅在用户使用 2FA 登录时进行签名 -- `parentsigned`:仅在父提交已签名时进行签名。 -- `always`:始终签名 - -除了 `never` 和 `always` 之外的选项可以组合为逗号分隔的列表。如果所有选择的选项都为 true,则提交将被签名。 - -### `CRUD_ACTIONS` - -此选项确定 Gitea 是否应该对 Web 编辑器或 API CRUD 操作的提交进行签名。可能的取值有: - -- `never`:从不签名 -- `pubkey`:仅在用户拥有公钥时进行签名 -- `twofa`:仅在用户使用 2FA 登录时进行签名 -- `parentsigned`:仅在父提交已签名时进行签名。 -- `always`:始终签名 - -除了 `never` 和 `always` 之外的选项可以组合为逗号分隔的列表。如果所有选择的选项都为 true,则更改将被签名。 - -### `MERGES` - -此选项确定 Gitea 是否应该对 PR 的合并提交进行签名。可能的选项有: - -- `never`:从不签名 -- `pubkey`:仅在用户拥有公钥时进行签名 -- `twofa`:仅在用户使用 2FA 登录时进行签名 -- `basesigned`:仅在基础仓库中的父提交已签名时进行签名。 -- `headsigned`:仅在头分支中的头提交已签名时进行签名。 -- `commitssigned`:仅在头分支中的所有提交到合并点的提交都已签名时进行签名。 -- `approved`:仅对已批准的合并到受保护分支的提交进行签名。 -- `always`:始终签名 - -除了 `never` 和 `always` 之外的选项可以组合为逗号分隔的列表。如果所有选择的选项都为 true,则合并将被签名。 - -## 获取签名密钥的公钥 - -用于签署 Gitea 提交的公钥可以通过 API 获取: - -```sh -/api/v1/signing-key.gpg -``` - -在存在特定于仓库的密钥的情况下,可以通过以下方式获取: - -```sh -/api/v1/repos/:username/:reponame/signing-key.gpg -``` diff --git a/docs/content/contributing.en-us.md b/docs/content/contributing.en-us.md deleted file mode 100644 index 8cd2e2bd89f2..000000000000 --- a/docs/content/contributing.en-us.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -date: "2021-01-22T00:00:00+02:00" -title: "Contributing" -slug: "contributing" -sidebar_position: 35 -toc: false -draft: false -menu: - sidebar: - name: "Contributing" - sidebar_position: 50 - identifier: "contributing" ---- diff --git a/docs/content/contributing/_index.en-us.md b/docs/content/contributing/_index.en-us.md deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/docs/content/contributing/_index.zh-cn.md b/docs/content/contributing/_index.zh-cn.md deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/docs/content/contributing/guidelines-backend.en-us.md b/docs/content/contributing/guidelines-backend.en-us.md deleted file mode 100644 index 3159a5ff7d14..000000000000 --- a/docs/content/contributing/guidelines-backend.en-us.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -date: "2021-11-01T23:41:00+08:00" -title: "Guidelines for Backend Development" -slug: "guidelines-backend" -sidebar_position: 20 -toc: false -draft: false -aliases: - - /en-us/guidelines-backend -menu: - sidebar: - parent: "contributing" - name: "Guidelines for Backend" - sidebar_position: 20 - identifier: "guidelines-backend" ---- - -# Guidelines for Backend Development - -## Background - -Gitea uses Golang as the backend programming language. It uses many third-party packages and also write some itself. -For example, Gitea uses [Chi](https://github.com/go-chi/chi) as basic web framework. [Xorm](https://xorm.io) is an ORM framework that is used to interact with the database. -So it's very important to manage these packages. Please take the below guidelines before you start to write backend code. - -## Package Design Guideline - -### Packages List - -To maintain understandable code and avoid circular dependencies it is important to have a good code structure. The Gitea backend is divided into the following parts: - -- `build`: Scripts to help build Gitea. -- `cmd`: All Gitea actual sub commands includes web, doctor, serv, hooks, admin and etc. `web` will start the web service. `serv` and `hooks` will be invoked by Git or OpenSSH. Other sub commands could help to maintain Gitea. -- `tests`: Common test utility functions - - `tests/integration`: Integration tests, to test back-end regressions - - `tests/e2e`: E2e tests, to test front-end and back-end compatibility and visual regressions. -- `models`: Contains the data structures used by xorm to construct database tables. It also contains functions to query and update the database. Dependencies to other Gitea code should be avoided. You can make exceptions in cases such as logging. - - `models/db`: Basic database operations. All other `models/xxx` packages should depend on this package. The `GetEngine` function should only be invoked from `models/`. - - `models/fixtures`: Sample data used in unit tests and integration tests. One `yml` file means one table which will be loaded into database when beginning the tests. - - `models/migrations`: Stores database migrations between versions. PRs that change a database structure **MUST** also have a migration step. -- `modules`: Different modules to handle specific functionality in Gitea. Work in Progress: Some of them should be moved to `services`, in particular those that depend on models because they rely on the database. - - `modules/setting`: Store all system configurations read from ini files and has been referenced by everywhere. But they should be used as function parameters when possible. - - `modules/git`: Package to interactive with `Git` command line or Gogit package. -- `public`: Compiled frontend files (javascript, images, css, etc.) -- `routers`: Handling of server requests. As it uses other Gitea packages to serve the request, other packages (models, modules or services) must not depend on routers. - - `routers/api` Contains routers for `/api/v1` aims to handle RESTful API requests. - - `routers/install` Could only respond when system is in INSTALL mode (INSTALL_LOCK=false). - - `routers/private` will only be invoked by internal sub commands, especially `serv` and `hooks`. - - `routers/web` will handle HTTP requests from web browsers or Git SMART HTTP protocols. -- `services`: Support functions for common routing operations or command executions. Uses `models` and `modules` to handle the requests. -- `templates`: Golang templates for generating the html output. - -### Package Dependencies - -Since Golang doesn't support import cycles, we have to decide the package dependencies carefully. There are some levels between those packages. Below is the ideal package dependencies direction. - -`cmd` -> `routers` -> `services` -> `models` -> `modules` - -From left to right, left packages could depend on right packages, but right packages MUST not depend on left packages. The sub packages on the same level could depend on according this level's rules. - -**NOTICE** - -Why do we need database transactions outside of `models`? And how? -Some actions should allow for rollback when database record insertion/update/deletion failed. -So services must be allowed to create a database transaction. Here is some example, - -```go -// services/repository/repository.go -func CreateXXXX() error { - return db.WithTx(func(ctx context.Context) error { - // do something, if err is returned, it will rollback automatically - if err := issues.UpdateIssue(ctx, repoID); err != nil { - // ... - return err - } - // ... - return nil - }) -} -``` - -You should **not** use `db.GetEngine(ctx)` in `services` directly, but just write a function under `models/`. -If the function will be used in the transaction, just let `context.Context` as the function's first parameter. - -```go -// models/issues/issue.go -func UpdateIssue(ctx context.Context, repoID int64) error { - e := db.GetEngine(ctx) - - // ... -} -``` - -### Package Name - -For the top level package, use a plural as package name, i.e. `services`, `models`, for sub packages, use singular, -i.e. `services/user`, `models/repository`. - -### Import Alias - -Since there are some packages which use the same package name, it is possible that you find packages like `modules/user`, `models/user`, and `services/user`. When these packages are imported in one Go file, it's difficult to know which package we are using and if it's a variable name or an import name. So, we always recommend to use import aliases. To differ from package variables which are commonly in camelCase, just use **snake_case** for import aliases. -i.e. `import user_service "code.gitea.io/gitea/services/user"` - -### Implementing `io.Closer` - -If a type implements `io.Closer`, calling `Close` multiple times must not fail or `panic` but return an error or `nil`. - -### Important Gotchas - -- Never write `x.Update(exemplar)` without an explicit `WHERE` clause: - - This will cause all rows in the table to be updated with the non-zero values of the exemplar - including IDs. - - You should usually write `x.ID(id).Update(exemplar)`. -- If during a migration you are inserting into a table using `x.Insert(exemplar)` where the ID is preset: - - You will need to ``SET IDENTITY_INSERT `table` ON`` for the MSSQL variant (the migration will fail otherwise) - - However, you will also need to update the id sequence for postgres - the migration will silently pass here but later insertions will fail: - ``SELECT setval('table_name_id_seq', COALESCE((SELECT MAX(id)+1 FROM `table_name`), 1), false)`` - -### Future Tasks - -Currently, we are creating some refactors to do the following things: - -- Correct that codes which doesn't follow the rules. -- There are too many files in `models`, so we are moving some of them into a sub package `models/xxx`. -- Some `modules` sub packages should be moved to `services` because they depend on `models`. diff --git a/docs/content/contributing/guidelines-backend.zh-cn.md b/docs/content/contributing/guidelines-backend.zh-cn.md deleted file mode 100644 index 33129dc086ef..000000000000 --- a/docs/content/contributing/guidelines-backend.zh-cn.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -date: "2023-05-25T23:41:00+08:00" -title: "后端开发指南" -slug: "guidelines-backend" -sidebar_position: 20 -toc: false -draft: false -aliases: - - /zh-cn/guidelines-backend -menu: - sidebar: - parent: "contributing" - name: "后端开发指南" - sidebar_position: 20 - identifier: "guidelines-backend" ---- - -# 后端开发指南 - -## 背景 - -Gitea使用Golang作为后端编程语言。它使用了许多第三方包,并且自己也编写了一些包。 -例如,Gitea使用[Chi](https://github.com/go-chi/chi)作为基本的Web框架。[Xorm](https://xorm.io)是一个用于与数据库交互的ORM框架。 -因此,管理这些包非常重要。在开始编写后端代码之前,请参考以下准则。 - -## 包设计准则 - -### 包列表 - -为了保持易于理解的代码并避免循环依赖,拥有良好的代码结构是很重要的。Gitea后端分为以下几个部分: - -- `build`:帮助构建Gitea的脚本。 -- `cmd`:包含所有Gitea的实际子命令,包括web、doctor、serv、hooks、admin等。`web`将启动Web服务。`serv`和`hooks`将被Git或OpenSSH调用。其他子命令可以帮助维护Gitea。 -- `tests`:常用的测试函数 -- `tests/integration`:集成测试,用于测试后端回归。 -- `tests/e2e`:端到端测试,用于测试前端和后端的兼容性和视觉回归。 -- `models`:包含由xorm用于构建数据库表的数据结构。它还包含查询和更新数据库的函数。应避免与其他Gitea代码的依赖关系。在某些情况下,比如日志记录时可以例外。 - - `models/db`:基本的数据库操作。所有其他`models/xxx`包都应依赖于此包。`GetEngine`函数只能从models/中调用。 - - `models/fixtures`:单元测试和集成测试中使用的示例数据。一个`yml`文件表示一个将在测试开始时加载到数据库中的表。 - - `models/migrations`:存储不同版本之间的数据库迁移。修改数据库结构的PR**必须**包含一个迁移步骤。 -- `modules`:在Gitea中处理特定功能的不同模块。工作正在进行中:其中一些模块应该移到`services`中,特别是那些依赖于models的模块,因为它们依赖于数据库。 - - `modules/setting`:存储从ini文件中读取的所有系统配置,并在各处引用。但是在可能的情况下,应将其作为函数参数使用。 - - `modules/git`:用于与`Git`命令行或Gogit包交互的包。 -- `public`:编译后的前端文件(JavaScript、图像、CSS等) -- `routers`:处理服务器请求。由于它使用其他Gitea包来处理请求,因此其他包(models、modules或services)不能依赖于routers。 - - `routers/api`:包含`/api/v1`相关路由,用于处理RESTful API请求。 - - `routers/install`:只能在系统处于安装模式(INSTALL_LOCK=false)时响应。 - - `routers/private`:仅由内部子命令调用,特别是`serv`和`hooks`。 - - `routers/web`:处理来自Web浏览器或Git SMART HTTP协议的HTTP请求。 -- `services`:用于常见路由操作或命令执行的支持函数。使用`models`和`modules`来处理请求。 -- `templates`:用于生成HTML输出的Golang模板。 - -### 包依赖关系 - -由于Golang不支持导入循环,我们必须仔细决定包之间的依赖关系。这些包之间有一些级别。以下是理想的包依赖关系方向。 - -`cmd` -> `routers` -> `services` -> `models` -> `modules` - -从左到右,左侧的包可以依赖于右侧的包,但右侧的包不能依赖于左侧的包。在同一级别的子包中,可以根据该级别的规则进行依赖。 - -**注意事项** - -为什么我们需要在`models`之外使用数据库事务?以及如何使用? -某些操作在数据库记录插入/更新/删除失败时应该允许回滚。 -因此,服务必须能够创建数据库事务。以下是一些示例: - -```go -// services/repository/repository.go -func CreateXXXX() error { - return db.WithTx(func(ctx context.Context) error { - // do something, if err is returned, it will rollback automatically - if err := issues.UpdateIssue(ctx, repoID); err != nil { - // ... - return err - } - // ... - return nil - }) -} -``` - -在`services`中**不应该**直接使用`db.GetEngine(ctx)`,而是应该在`models/`下编写一个函数。 -如果该函数将在事务中使用,请将`context.Context`作为函数的第一个参数。 - -```go -// models/issues/issue.go -func UpdateIssue(ctx context.Context, repoID int64) error { - e := db.GetEngine(ctx) - - // ... -} -``` - -### 包名称 - -对于顶层包,请使用复数作为包名,例如`services`、`models`,对于子包,请使用单数,例如`services/user`、`models/repository`。 - -### 导入别名 - -由于有一些使用相同包名的包,例如`modules/user`、`models/user`和`services/user`,当这些包在一个Go文件中被导入时,很难知道我们使用的是哪个包以及它是变量名还是导入名。因此,我们始终建议使用导入别名。为了与常见的驼峰命名法的包变量区分开,建议使用**snake_case**作为导入别名的命名规则。 -例如:`import user_service "code.gitea.io/gitea/services/user"` - -### 重要注意事项 - -- 永远不要写成`x.Update(exemplar)`,而没有明确的`WHERE`子句: - - 这将导致表中的所有行都被使用exemplar的非零值进行更新,包括ID。 - - 通常应该写成`x.ID(id).Update(exemplar)`。 -- 如果在迁移过程中使用`x.Insert(exemplar)`向表中插入记录,而ID是预设的: - - 对于MSSQL变体,你将需要执行``SET IDENTITY_INSERT `table` ON``(否则迁移将失败) - - 对于PostgreSQL,你还需要更新ID序列,否则迁移将悄无声息地通过,但后续的插入将失败: - ``SELECT setval('table_name_id_seq', COALESCE((SELECT MAX(id)+1 FROM `table_name`), 1), false)`` - -### 未来的任务 - -目前,我们正在进行一些重构,以完成以下任务: - -- 纠正不符合规则的代码。 -- `models`中的文件太多了,所以我们正在将其中的一些移动到子包`models/xxx`中。 -- 由于它们依赖于`models`,因此应将某些`modules`子包移动到`services`中。 diff --git a/docs/content/contributing/guidelines-frontend.en-us.md b/docs/content/contributing/guidelines-frontend.en-us.md deleted file mode 100644 index 5539532c52e8..000000000000 --- a/docs/content/contributing/guidelines-frontend.en-us.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -date: "2021-10-13T16:00:00+02:00" -title: "Guidelines for Frontend Development" -slug: "guidelines-frontend" -sidebar_position: 30 -toc: false -draft: false -aliases: - - /en-us/guidelines-frontend -menu: - sidebar: - parent: "contributing" - name: "Guidelines for Frontend" - sidebar_position: 30 - identifier: "guidelines-frontend" ---- - -# Guidelines for Frontend Development - -## Background - -Gitea uses [Fomantic-UI](https://fomantic-ui.com/introduction/getting-started.html) (based on [jQuery](https://api.jquery.com)) and [Vue3](https://vuejs.org/) for its frontend. - -The HTML pages are rendered by [Go HTML Template](https://pkg.go.dev/html/template). - -The source files can be found in the following directories: - -* **CSS styles:** `web_src/css/` -* **JavaScript files:** `web_src/js/` -* **Vue components:** `web_src/js/components/` -* **Go HTML templates:** `templates/` - -## General Guidelines - -We recommend [Google HTML/CSS Style Guide](https://google.github.io/styleguide/htmlcssguide.html) and [Google JavaScript Style Guide](https://google.github.io/styleguide/jsguide.html) - -### Gitea specific guidelines - -1. Every feature (Fomantic-UI/jQuery module) should be put in separate files/directories. -2. HTML ids and classes should use kebab-case, it's preferred to contain 2-3 feature related keywords. -3. HTML ids and classes used in JavaScript should be unique for the whole project, and should contain 2-3 feature related keywords. We recommend to use the `js-` prefix for classes that are only used in JavaScript. -4. CSS styling for classes provided by frameworks should not be overwritten. Always use new class names with 2-3 feature related keywords to overwrite framework styles. Gitea's helper CSS classes in `helpers.less` could be helpful. -5. The backend can pass complex data to the frontend by using `ctx.PageData["myModuleData"] = map[]{}`, but do not expose whole models to the frontend to avoid leaking sensitive data. -6. Simple pages and SEO-related pages use Go HTML Template render to generate static Fomantic-UI HTML output. Complex pages can use Vue3. -7. Clarify variable types, prefer `elem.disabled = true` instead of `elem.setAttribute('disabled', 'anything')`, prefer `$el.prop('checked', var === 'yes')` instead of `$el.prop('checked', var)`. -8. Use semantic elements, prefer `