diff --git a/modules/util/truncate.go b/modules/util/truncate.go index 77b116eeff..f2edbdc673 100644 --- a/modules/util/truncate.go +++ b/modules/util/truncate.go @@ -41,6 +41,8 @@ func SplitStringAtByteN(input string, n int) (left, right string) { // SplitTrimSpace splits the string at given separator and trims leading and trailing space func SplitTrimSpace(input, sep string) []string { + // Trim initial leading & trailing space + input = strings.TrimSpace(input) // replace CRLF with LF input = strings.ReplaceAll(input, "\r\n", "\n") diff --git a/modules/validation/binding.go b/modules/validation/binding.go index cb0a5063e5..75190e31e1 100644 --- a/modules/validation/binding.go +++ b/modules/validation/binding.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/util" "gitea.com/go-chi/binding" "github.com/gobwas/glob" @@ -31,6 +32,7 @@ const ( // AddBindingRules adds additional binding rules func AddBindingRules() { addGitRefNameBindingRule() + addValidURLListBindingRule() addValidURLBindingRule() addValidSiteURLBindingRule() addGlobPatternRule() @@ -44,7 +46,7 @@ func addGitRefNameBindingRule() { // Git refname validation rule binding.AddRule(&binding.Rule{ IsMatch: func(rule string) bool { - return strings.HasPrefix(rule, "GitRefName") + return rule == "GitRefName" }, IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { str := fmt.Sprintf("%v", val) @@ -58,11 +60,38 @@ func addGitRefNameBindingRule() { }) } +func addValidURLListBindingRule() { + // URL validation rule + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "ValidUrlList" + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + if len(str) == 0 { + errs.Add([]string{name}, binding.ERR_URL, "Url") + return false, errs + } + + ok := true + urls := util.SplitTrimSpace(str, "\n") + for _, u := range urls { + if !IsValidURL(u) { + ok = false + errs.Add([]string{name}, binding.ERR_URL, u) + } + } + + return ok, errs + }, + }) +} + func addValidURLBindingRule() { // URL validation rule binding.AddRule(&binding.Rule{ IsMatch: func(rule string) bool { - return strings.HasPrefix(rule, "ValidUrl") + return rule == "ValidUrl" }, IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { str := fmt.Sprintf("%v", val) @@ -80,7 +109,7 @@ func addValidSiteURLBindingRule() { // URL validation rule binding.AddRule(&binding.Rule{ IsMatch: func(rule string) bool { - return strings.HasPrefix(rule, "ValidSiteUrl") + return rule == "ValidSiteUrl" }, IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { str := fmt.Sprintf("%v", val) @@ -171,7 +200,7 @@ func addUsernamePatternRule() { func addValidGroupTeamMapRule() { binding.AddRule(&binding.Rule{ IsMatch: func(rule string) bool { - return strings.HasPrefix(rule, "ValidGroupTeamMap") + return rule == "ValidGroupTeamMap" }, IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { _, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val)) diff --git a/modules/validation/binding_test.go b/modules/validation/binding_test.go index 01ff4e3435..28d0f57b5c 100644 --- a/modules/validation/binding_test.go +++ b/modules/validation/binding_test.go @@ -27,6 +27,7 @@ type ( TestForm struct { BranchName string `form:"BranchName" binding:"GitRefName"` URL string `form:"ValidUrl" binding:"ValidUrl"` + URLs string `form:"ValidUrls" binding:"ValidUrlList"` GlobPattern string `form:"GlobPattern" binding:"GlobPattern"` RegexPattern string `form:"RegexPattern" binding:"RegexPattern"` } diff --git a/modules/validation/validurllist_test.go b/modules/validation/validurllist_test.go new file mode 100644 index 0000000000..c6f862a962 --- /dev/null +++ b/modules/validation/validurllist_test.go @@ -0,0 +1,157 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "testing" + + "gitea.com/go-chi/binding" +) + +// This is a copy of all the URL tests cases, plus additional ones to +// account for multiple URLs +var urlListValidationTestCases = []validationTestCase{ + { + description: "Empty URL", + data: TestForm{ + URLs: "", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "URL without port", + data: TestForm{ + URLs: "http://test.lan/", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "URL with port", + data: TestForm{ + URLs: "http://test.lan:3000/", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "URL with IPv6 address without port", + data: TestForm{ + URLs: "http://[::1]/", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "URL with IPv6 address with port", + data: TestForm{ + URLs: "http://[::1]:3000/", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "Invalid URL", + data: TestForm{ + URLs: "http//test.lan/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URLs"}, + Classification: binding.ERR_URL, + Message: "http//test.lan/", + }, + }, + }, + { + description: "Invalid schema", + data: TestForm{ + URLs: "ftp://test.lan/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URLs"}, + Classification: binding.ERR_URL, + Message: "ftp://test.lan/", + }, + }, + }, + { + description: "Invalid port", + data: TestForm{ + URLs: "http://test.lan:3x4/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URLs"}, + Classification: binding.ERR_URL, + Message: "http://test.lan:3x4/", + }, + }, + }, + { + description: "Invalid port with IPv6 address", + data: TestForm{ + URLs: "http://[::1]:3x4/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URLs"}, + Classification: binding.ERR_URL, + Message: "http://[::1]:3x4/", + }, + }, + }, + { + description: "Multi URLs", + data: TestForm{ + URLs: "http://test.lan:3000/\nhttp://test.local/", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "Multi URLs with newline", + data: TestForm{ + URLs: "http://test.lan:3000/\nhttp://test.local/\n", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "List with invalid entry", + data: TestForm{ + URLs: "http://test.lan:3000/\nhttp://[::1]:3x4/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URLs"}, + Classification: binding.ERR_URL, + Message: "http://[::1]:3x4/", + }, + }, + }, + { + description: "List with two invalid entries", + data: TestForm{ + URLs: "ftp://test.lan:3000/\nhttp://[::1]:3x4/\n", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URLs"}, + Classification: binding.ERR_URL, + Message: "ftp://test.lan:3000/", + }, + binding.Error{ + FieldNames: []string{"URLs"}, + Classification: binding.ERR_URL, + Message: "http://[::1]:3x4/", + }, + }, + }, +} + +func Test_ValidURLListValidation(t *testing.T) { + AddBindingRules() + + for _, testCase := range urlListValidationTestCases { + t.Run(testCase.description, func(t *testing.T) { + performValidationTest(t, testCase) + }) + } +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 9945eb4949..ffce4b7e2f 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -765,6 +765,7 @@ uploaded_avatar_not_a_image = The uploaded file is not an image. uploaded_avatar_is_too_big = The uploaded file size (%d KiB) exceeds the maximum size (%d KiB). update_avatar_success = Your avatar has been updated. update_user_avatar_success = The user's avatar has been updated. +cropper_prompt = You can edit the image before saving. The edited image will be saved as PNG. change_password = Update Password old_password = Current Password diff --git a/package-lock.json b/package-lock.json index 989c2bd77f..54e387a107 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "chartjs-adapter-dayjs-4": "1.0.4", "chartjs-plugin-zoom": "2.0.1", "clippie": "4.1.3", + "cropperjs": "1.6.2", "css-loader": "7.1.2", "dayjs": "1.11.13", "dropzone": "6.0.0-beta.2", @@ -6876,6 +6877,12 @@ } } }, + "node_modules/cropperjs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz", + "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", diff --git a/package.json b/package.json index 03c3b79990..e596b444b6 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "chartjs-adapter-dayjs-4": "1.0.4", "chartjs-plugin-zoom": "2.0.1", "clippie": "4.1.3", + "cropperjs": "1.6.2", "css-loader": "7.1.2", "dayjs": "1.11.13", "dropzone": "6.0.0-beta.2", diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index f5fb056494..ad16b9fb4e 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -168,8 +168,8 @@ func List(ctx *context.Context) { actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() ctx.Data["ActionsConfig"] = actionsConfig - if len(workflowID) > 0 && ctx.Repo.IsAdmin() { - ctx.Data["AllowDisableOrEnableWorkflow"] = true + if len(workflowID) > 0 && ctx.Repo.CanWrite(unit.TypeActions) { + ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.IsAdmin() isWorkflowDisabled := actionsConfig.IsWorkflowDisabled(workflowID) ctx.Data["CurWorkflowDisabled"] = isWorkflowDisabled diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go index 6029d7bedb..e93e9e1954 100644 --- a/routers/web/user/setting/oauth2_common.go +++ b/routers/web/user/setting/oauth2_common.go @@ -47,7 +47,6 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) { return } - // TODO validate redirect URI app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{ Name: form.Name, RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), @@ -96,11 +95,25 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) if ctx.HasError() { + app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + if auth.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound("Application not found", err) + return + } + ctx.ServerError("GetOAuth2ApplicationByID", err) + return + } + if app.UID != oa.OwnerID { + ctx.NotFound("Application not found", nil) + return + } + ctx.Data["App"] = app + oa.renderEditPage(ctx) return } - // TODO validate redirect URI var err error if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{ ID: ctx.PathParamInt64("id"), diff --git a/routers/web/web.go b/routers/web/web.go index 51bb8d72e9..2ba85b0e59 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1406,7 +1406,7 @@ func registerRoutes(m *web.Router) { m.Get("", actions.List) m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) - m.Post("/run", reqRepoAdmin, actions.Run) + m.Post("/run", reqRepoActionsWriter, actions.Run) m.Group("/runs/{run}", func() { m.Combo(""). diff --git a/services/feed/action.go b/services/feed/action.go index 83daaa1438..a8820aeb77 100644 --- a/services/feed/action.go +++ b/services/feed/action.go @@ -390,6 +390,12 @@ func (a *actionNotifier) DeleteRef(ctx context.Context, doer *user_model.User, r } func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) { + // ignore pull sync message for pull requests refs + // TODO: it's better to have a UI to let users chose + if opts.RefFullName.IsPull() { + return + } + data, err := json.Marshal(commits) if err != nil { log.Error("json.Marshal: %v", err) diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 5b7a43642a..ed79936add 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -366,7 +366,7 @@ func (f *NewAccessTokenForm) GetScope() (auth_model.AccessTokenScope, error) { // EditOAuth2ApplicationForm form for editing oauth2 applications type EditOAuth2ApplicationForm struct { Name string `binding:"Required;MaxSize(255)" form:"application_name"` - RedirectURIs string `binding:"Required" form:"redirect_uris"` + RedirectURIs string `binding:"Required;ValidUrlList" form:"redirect_uris"` ConfidentialClient bool `form:"confidential_client"` SkipSecondaryAuthorization bool `form:"skip_secondary_authorization"` } diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl index 9c7e2de218..f879587c71 100644 --- a/templates/user/settings/profile.tmpl +++ b/templates/user/settings/profile.tmpl @@ -127,6 +127,11 @@ +
+
{{ctx.Locale.Tr "settings.cropper_prompt"}}
+
+
+
diff --git a/tests/integration/user_settings_test.go b/tests/integration/user_settings_test.go index 2103c92d58..d8402eb25f 100644 --- a/tests/integration/user_settings_test.go +++ b/tests/integration/user_settings_test.go @@ -10,6 +10,8 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" ) // Validate that each navbar setting is correct. This checks that the @@ -51,8 +53,10 @@ func WithDisabledFeatures(t *testing.T, features ...string) { } func TestUserSettingsAccount(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("all features enabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() session := loginUser(t, "user2") req := NewRequest(t, "GET", "/user/settings/account") @@ -68,7 +72,7 @@ func TestUserSettingsAccount(t *testing.T) { }) t.Run("credentials disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageCredentials) @@ -85,7 +89,7 @@ func TestUserSettingsAccount(t *testing.T) { }) t.Run("deletion disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureDeletion) @@ -102,7 +106,7 @@ func TestUserSettingsAccount(t *testing.T) { }) t.Run("deletion, credentials and email notifications are disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() mail := setting.Service.EnableNotifyMail setting.Service.EnableNotifyMail = false @@ -119,8 +123,10 @@ func TestUserSettingsAccount(t *testing.T) { } func TestUserSettingsUpdatePassword(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("enabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() session := loginUser(t, "user2") @@ -138,7 +144,7 @@ func TestUserSettingsUpdatePassword(t *testing.T) { }) t.Run("credentials disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageCredentials) @@ -156,8 +162,10 @@ func TestUserSettingsUpdatePassword(t *testing.T) { } func TestUserSettingsUpdateEmail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("credentials disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageCredentials) @@ -175,8 +183,10 @@ func TestUserSettingsUpdateEmail(t *testing.T) { } func TestUserSettingsDeleteEmail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("credentials disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageCredentials) @@ -194,8 +204,10 @@ func TestUserSettingsDeleteEmail(t *testing.T) { } func TestUserSettingsDelete(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("deletion disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureDeletion) @@ -224,9 +236,10 @@ func TestUserSettingsAppearance(t *testing.T) { } func TestUserSettingsSecurity(t *testing.T) { - t.Run("credentials disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrepareTestEnv(t)() + t.Run("credentials disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageCredentials) session := loginUser(t, "user2") @@ -240,8 +253,7 @@ func TestUserSettingsSecurity(t *testing.T) { }) t.Run("mfa disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() - + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageMFA) session := loginUser(t, "user2") @@ -255,8 +267,7 @@ func TestUserSettingsSecurity(t *testing.T) { }) t.Run("credentials and mfa disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() - + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageCredentials, setting.UserFeatureManageMFA) session := loginUser(t, "user2") @@ -268,17 +279,75 @@ func TestUserSettingsSecurity(t *testing.T) { func TestUserSettingsApplications(t *testing.T) { defer tests.PrepareTestEnv(t)() - session := loginUser(t, "user2") - req := NewRequest(t, "GET", "/user/settings/applications") - resp := session.MakeRequest(t, req, http.StatusOK) - doc := NewHTMLParser(t, resp.Body) + t.Run("Applications", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() - assertNavbar(t, doc) + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/settings/applications") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + }) + + t.Run("OAuth2", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user2") + + t.Run("OAuth2ApplicationShow", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user/settings/applications/oauth2/2") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + assertNavbar(t, doc) + }) + + t.Run("OAuthApplicationsEdit", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user/settings/applications/oauth2/2") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + t.Run("Invalid URL", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user/settings/applications/oauth2/2", map[string]string{ + "_csrf": doc.GetCSRF(), + "application_name": "Test native app", + "redirect_uris": "ftp://127.0.0.1", + "confidential_client": "false", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + msg := doc.Find(".flash-error p").Text() + assert.Equal(t, `form.RedirectURIs"ftp://127.0.0.1" is not a valid URL.`, msg) + }) + + t.Run("OK", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user/settings/applications/oauth2/2", map[string]string{ + "_csrf": doc.GetCSRF(), + "application_name": "Test native app", + "redirect_uris": "http://127.0.0.1", + "confidential_client": "false", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + }) + }) + }) } func TestUserSettingsKeys(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("all enabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() session := loginUser(t, "user2") req := NewRequest(t, "GET", "/user/settings/keys") @@ -292,7 +361,7 @@ func TestUserSettingsKeys(t *testing.T) { }) t.Run("ssh keys disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageSSHKeys) @@ -308,7 +377,7 @@ func TestUserSettingsKeys(t *testing.T) { }) t.Run("gpg keys disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageGPGKeys) @@ -324,7 +393,7 @@ func TestUserSettingsKeys(t *testing.T) { }) t.Run("ssh & gpg keys disabled", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() + defer tests.PrintCurrentTest(t)() WithDisabledFeatures(t, setting.UserFeatureManageSSHKeys, setting.UserFeatureManageGPGKeys) diff --git a/web_src/css/features/cropper.css b/web_src/css/features/cropper.css new file mode 100644 index 0000000000..ed7171e770 --- /dev/null +++ b/web_src/css/features/cropper.css @@ -0,0 +1,6 @@ +@import "cropperjs/dist/cropper.css"; + +.page-content.user.profile .cropper-panel .cropper-wrapper { + max-width: 400px; + max-height: 400px; +} diff --git a/web_src/css/index.css b/web_src/css/index.css index 817f6997da..174a4a9cbc 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -40,6 +40,7 @@ @import "./features/codeeditor.css"; @import "./features/projects.css"; @import "./features/tribute.css"; +@import "./features/cropper.css"; @import "./features/console.css"; @import "./markup/content.css"; diff --git a/web_src/js/features/comp/Cropper.ts b/web_src/js/features/comp/Cropper.ts new file mode 100644 index 0000000000..3961b79b49 --- /dev/null +++ b/web_src/js/features/comp/Cropper.ts @@ -0,0 +1,40 @@ +import {showElem} from '../../utils/dom.ts'; + +type CropperOpts = { + container: HTMLElement, + imageSource: HTMLImageElement, + fileInput: HTMLInputElement, +} + +export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) { + const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs'); + let currentFileName = ''; + let currentFileLastModified = 0; + const cropper = new Cropper(imageSource, { + aspectRatio: 1, + viewMode: 2, + autoCrop: false, + crop() { + const canvas = cropper.getCroppedCanvas(); + canvas.toBlob((blob) => { + const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png'); + const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified}); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(croppedFile); + fileInput.files = dataTransfer.files; + }); + }, + }); + + fileInput.addEventListener('input', (e: Event & {target: HTMLInputElement}) => { + const files = e.target.files; + if (files?.length > 0) { + currentFileName = files[0].name; + currentFileLastModified = files[0].lastModified; + const fileURL = URL.createObjectURL(files[0]); + imageSource.src = fileURL; + cropper.replace(fileURL); + showElem(container); + } + }); +} diff --git a/web_src/js/features/repo-settings-branches.test.ts b/web_src/js/features/repo-settings-branches.test.ts index 023039334f..c4609999be 100644 --- a/web_src/js/features/repo-settings-branches.test.ts +++ b/web_src/js/features/repo-settings-branches.test.ts @@ -1,5 +1,5 @@ import {beforeEach, describe, expect, test, vi} from 'vitest'; -import {initRepoBranchesSettings} from './repo-settings-branches.ts'; +import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts'; import {POST} from '../modules/fetch.ts'; import {createSortable} from '../modules/sortable.ts'; @@ -31,7 +31,7 @@ describe('Repository Branch Settings', () => { }); test('should initialize sortable for protected branches list', () => { - initRepoBranchesSettings(); + initRepoSettingsBranchesDrag(); expect(createSortable).toHaveBeenCalledWith( document.querySelector('#protected-branches-list'), @@ -45,7 +45,7 @@ describe('Repository Branch Settings', () => { test('should not initialize if protected branches list is not present', () => { document.body.innerHTML = ''; - initRepoBranchesSettings(); + initRepoSettingsBranchesDrag(); expect(createSortable).not.toHaveBeenCalled(); }); @@ -59,7 +59,7 @@ describe('Repository Branch Settings', () => { return {destroy: vi.fn()}; }); - initRepoBranchesSettings(); + initRepoSettingsBranchesDrag(); expect(POST).toHaveBeenCalledWith( 'some/repo/branches/priority', diff --git a/web_src/js/features/repo-settings-branches.ts b/web_src/js/features/repo-settings-branches.ts index 43b98f79b3..40cdf9f981 100644 --- a/web_src/js/features/repo-settings-branches.ts +++ b/web_src/js/features/repo-settings-branches.ts @@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts'; import {showErrorToast} from '../modules/toast.ts'; import {queryElemChildren} from '../utils/dom.ts'; -export function initRepoBranchesSettings() { +export function initRepoSettingsBranchesDrag() { const protectedBranchesList = document.querySelector('#protected-branches-list'); if (!protectedBranchesList) return; diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts index 5a009cfea4..9ea546f76d 100644 --- a/web_src/js/features/repo-settings.ts +++ b/web_src/js/features/repo-settings.ts @@ -3,7 +3,7 @@ import {minimatch} from 'minimatch'; import {createMonaco} from './codeeditor.ts'; import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts'; import {POST} from '../modules/fetch.ts'; -import {initRepoBranchesSettings} from './repo-settings-branches.ts'; +import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts'; const {appSubUrl, csrfToken} = window.config; @@ -155,5 +155,5 @@ export function initRepoSettings() { initRepoSettingsCollaboration(); initRepoSettingsSearchTeamBox(); initRepoSettingsGitHook(); - initRepoBranchesSettings(); + initRepoSettingsBranchesDrag(); } diff --git a/web_src/js/features/user-settings.ts b/web_src/js/features/user-settings.ts index 41939c0f52..c097df7b6c 100644 --- a/web_src/js/features/user-settings.ts +++ b/web_src/js/features/user-settings.ts @@ -1,7 +1,17 @@ import {hideElem, showElem} from '../utils/dom.ts'; +import {initCompCropper} from './comp/Cropper.ts'; + +function initUserSettingsAvatarCropper() { + const fileInput = document.querySelector('#new-avatar'); + const container = document.querySelector('.user.settings.profile .cropper-panel'); + const imageSource = container.querySelector('.cropper-source'); + initCompCropper({container, fileInput, imageSource}); +} export function initUserSettings() { - if (!document.querySelectorAll('.user.settings.profile').length) return; + if (!document.querySelector('.user.settings.profile')) return; + + initUserSettingsAvatarCropper(); const usernameInput = document.querySelector('#username'); if (!usernameInput) return; diff --git a/web_src/js/modules/sortable.ts b/web_src/js/modules/sortable.ts index c31135357c..b318386d08 100644 --- a/web_src/js/modules/sortable.ts +++ b/web_src/js/modules/sortable.ts @@ -1,6 +1,6 @@ import type {SortableOptions, SortableEvent} from 'sortablejs'; -export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}) { +export async function createSortable(el: Element, opts: {handle?: string} & SortableOptions = {}) { // @ts-expect-error: wrong type derived by typescript const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs'); diff --git a/webpack.config.js b/webpack.config.js index 838fb51f43..276c758e2c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -17,7 +17,7 @@ import tailwindcssNesting from 'tailwindcss/nesting/index.js'; import postcssNesting from 'postcss-nesting'; const {EsbuildPlugin} = EsBuildLoader; -const {SourceMapDevToolPlugin, DefinePlugin} = webpack; +const {SourceMapDevToolPlugin, DefinePlugin, EnvironmentPlugin} = webpack; const formatLicenseText = (licenseText) => wrapAnsi(licenseText || '', 80).trim(); const glob = (pattern) => fastGlob.sync(pattern, { @@ -213,6 +213,10 @@ export default { __VUE_PROD_DEVTOOLS__: false, // do not enable devtools support in production __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, // https://github.com/vuejs/vue-cli/pull/7443 }), + // all environment variables used in bundled js via process.env must be declared here + new EnvironmentPlugin({ + TEST: 'false', + }), new VueLoaderPlugin(), new MiniCssExtractPlugin({ filename: 'css/[name].css',