-

{{.locale.Tr "notification.notifications"}}

+

+ {{svg "octicon-bell" 28 "gt-mr-3 gt-mt-1"}} + {{.locale.Tr "notification.notifications"}} +

{{if eq (len .Notifications) 0}} - {{if eq .Status 1}} - {{.locale.Tr "notification.no_unread"}} - {{else}} - {{.locale.Tr "notification.no_read"}} - {{end}} +
+ {{svg "octicon-inbox" 56 "gt-mb-4"}} + {{if eq .Status 1}} + {{.locale.Tr "notification.no_unread"}} + {{else}} + {{.locale.Tr "notification.no_read"}} + {{end}} +
{{else}} From bc719f549ee67a141302fca545c44262b9fed332 Mon Sep 17 00:00:00 2001 From: yp05327 <576951401@qq.com> Date: Fri, 12 May 2023 15:38:59 +0900 Subject: [PATCH 03/55] Update pin and add pin-slash (#24669) Continue #23531 Thanks for the update in https://github.com/primer/octicons/issues/940, @CameronFoxly ![image](https://github.com/go-gitea/gitea/assets/18380374/bc512b20-b656-4bd7-8e70-3a2b7eca9d65) ![image](https://github.com/go-gitea/gitea/assets/18380374/a3863267-6a7a-48d5-8157-9f361fec3fed) --- package-lock.json | 8 ++++---- package.json | 2 +- public/img/svg/octicon-devices.svg | 1 + public/img/svg/octicon-move-to-top.svg | 2 +- public/img/svg/octicon-pin-slash.svg | 1 + public/img/svg/octicon-pin.svg | 2 +- templates/projects/view.tmpl | 2 +- templates/repo/projects/view.tmpl | 2 +- 8 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 public/img/svg/octicon-devices.svg create mode 100644 public/img/svg/octicon-pin-slash.svg diff --git a/package-lock.json b/package-lock.json index 93c5775e00..73468344d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@github/relative-time-element": "4.3.0", "@github/text-expander-element": "2.3.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", - "@primer/octicons": "18.3.0", + "@primer/octicons": "19.1.0", "@vue/compiler-sfc": "3.2.47", "@webcomponents/custom-elements": "1.6.0", "add-asset-webpack-plugin": "2.0.1", @@ -1174,9 +1174,9 @@ } }, "node_modules/@primer/octicons": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@primer/octicons/-/octicons-18.3.0.tgz", - "integrity": "sha512-vyUej5rKjXfqHAmYSo2k3pA9IsL5KJaYOBVXvc188FwBfUweA+WVP3qwWyZTSQnp9LK6yOajGyr0SCMIa9ombw==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@primer/octicons/-/octicons-19.1.0.tgz", + "integrity": "sha512-5o90F89gNPnAk1qfzl3hb/TcsUjk5g0WFI+fBRHLkBKzB3uc9EvxTpgzjXhhjyriOkrBOjFo58D0sjtwttaQww==", "dependencies": { "object-assign": "^4.1.1" } diff --git a/package.json b/package.json index 9d9c0923ef..fe637e9389 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@github/relative-time-element": "4.3.0", "@github/text-expander-element": "2.3.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", - "@primer/octicons": "18.3.0", + "@primer/octicons": "19.1.0", "@vue/compiler-sfc": "3.2.47", "@webcomponents/custom-elements": "1.6.0", "add-asset-webpack-plugin": "2.0.1", diff --git a/public/img/svg/octicon-devices.svg b/public/img/svg/octicon-devices.svg new file mode 100644 index 0000000000..c351640dea --- /dev/null +++ b/public/img/svg/octicon-devices.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/svg/octicon-move-to-top.svg b/public/img/svg/octicon-move-to-top.svg index 78498db364..f0726dd988 100644 --- a/public/img/svg/octicon-move-to-top.svg +++ b/public/img/svg/octicon-move-to-top.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/img/svg/octicon-pin-slash.svg b/public/img/svg/octicon-pin-slash.svg new file mode 100644 index 0000000000..4ba1d4a5e2 --- /dev/null +++ b/public/img/svg/octicon-pin-slash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/svg/octicon-pin.svg b/public/img/svg/octicon-pin.svg index f41a4a3580..e24d9cc817 100644 --- a/public/img/svg/octicon-pin.svg +++ b/public/img/svg/octicon-pin.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index 3274463435..014a748317 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -109,7 +109,7 @@ data-modal-default-project-board-header="{{$.locale.Tr "repo.projects.column.unset_default"}}" data-modal-default-project-board-content="{{$.locale.Tr "repo.projects.column.unset_default_desc"}}" data-url="{{$.Link}}/{{.ID}}/unsetdefault"> - {{svg "octicon-pin"}} + {{svg "octicon-pin-slash"}} {{$.locale.Tr "repo.projects.column.unset_default"}} {{end}} diff --git a/templates/repo/projects/view.tmpl b/templates/repo/projects/view.tmpl index 4c4f82c8e7..73548701d4 100644 --- a/templates/repo/projects/view.tmpl +++ b/templates/repo/projects/view.tmpl @@ -115,7 +115,7 @@ data-modal-default-project-board-content="{{$.locale.Tr "repo.projects.column.unset_default_desc"}}" data-type="unset_default" data-url="{{$.Link}}/{{.ID}}/unsetdefault"> - {{svg "octicon-pin"}} + {{svg "octicon-pin-slash"}} {{$.locale.Tr "repo.projects.column.unset_default"}} {{end}} From 4aec1f87a4affe606e96e27c2e8660092aac3afb Mon Sep 17 00:00:00 2001 From: yp05327 <576951401@qq.com> Date: Fri, 12 May 2023 17:00:17 +0900 Subject: [PATCH 04/55] Remove highlight in repo list (#24675) Before: ![image](https://github.com/go-gitea/gitea/assets/18380374/aa5e9f1f-48ff-4e9f-a693-23a34e390760) After: ![image](https://github.com/go-gitea/gitea/assets/18380374/a4b58a6c-4ea3-4ded-80df-2e650d39a1d3) private or internal repos have `lock` icon, no need to add highlights to them. --- web_src/css/dashboard.css | 4 ---- web_src/js/components/DashboardRepoList.vue | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/web_src/css/dashboard.css b/web_src/css/dashboard.css index 4512367dc7..6048c0cdad 100644 --- a/web_src/css/dashboard.css +++ b/web_src/css/dashboard.css @@ -171,10 +171,6 @@ border-bottom: 1px solid var(--color-secondary); } -.feeds .list ul li.private { - background-color: var(--color-box-body-highlight); -} - .feeds .list ul li .repo-list-link { padding: 6px 1em; display: block; diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue index 5cedbcb000..161fca9414 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -70,7 +70,7 @@
- + From 4b808133414e39348f56d691883426947bf2d322 Mon Sep 17 00:00:00 2001 From: rune Date: Fri, 12 May 2023 17:44:37 +0800 Subject: [PATCH 06/55] Support SSH for go get (#24664) fix #12192 Support SSH for go get --------- Co-authored-by: Lunny Xiao Co-authored-by: Giteabot Co-authored-by: mfk Co-authored-by: silverwind --- custom/conf/app.example.ini | 3 ++ .../config-cheat-sheet.en-us.md | 2 ++ models/repo/repo.go | 32 +++++++++++-------- modules/context/repo.go | 9 +++++- modules/setting/repository.go | 2 ++ routers/web/goget.go | 9 +++++- tests/integration/goget_test.go | 26 +++++++++++++++ 7 files changed, 68 insertions(+), 15 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 3ceb53dcd0..e279b67ded 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -940,6 +940,9 @@ ROUTER = console ;; Force ssh:// clone url instead of scp-style uri when default SSH port is used ;USE_COMPAT_SSH_URI = false ;; +;; Value for the "go get" request returns the repository url as https or ssh, default is https +;GO_GET_CLONE_URL_PROTOCOL = https +;; ;; Close issues as long as a commit on any branch marks it as fixed ;; Comma separated list of globally disabled repo units. Allowed values: repo.issues, repo.ext_issues, repo.pulls, repo.wiki, repo.ext_wiki, repo.projects, repo.packages, repo.actions. ;DISABLED_REPO_UNITS = diff --git a/docs/content/doc/administration/config-cheat-sheet.en-us.md b/docs/content/doc/administration/config-cheat-sheet.en-us.md index c1befed489..470e299a18 100644 --- a/docs/content/doc/administration/config-cheat-sheet.en-us.md +++ b/docs/content/doc/administration/config-cheat-sheet.en-us.md @@ -95,6 +95,8 @@ In addition there is _`StaticRootPath`_ which can be set as a built-in at build HTTP protocol. - `USE_COMPAT_SSH_URI`: **false**: Force ssh:// clone url instead of scp-style uri when default SSH port is used. +- `GO_GET_CLONE_URL_PROTOCOL`: **https**: Value for the "go get" request returns the repository url as https or ssh + default is https. - `ACCESS_CONTROL_ALLOW_ORIGIN`: **\**: Value for Access-Control-Allow-Origin header, default is not to present. **WARNING**: This maybe harmful to you website if you do not give it a right value. diff --git a/models/repo/repo.go b/models/repo/repo.go index 2e8c28cbb3..7cbd5867b7 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -547,16 +547,9 @@ func ComposeHTTPSCloneURL(owner, repo string) string { return fmt.Sprintf("%s%s/%s.git", setting.AppURL, url.PathEscape(owner), url.PathEscape(repo)) } -func (repo *Repository) cloneLink(isWiki bool) *CloneLink { - repoName := repo.Name - if isWiki { - repoName += ".wiki" - } - +func ComposeSSHCloneURL(ownerName, repoName string) string { sshUser := setting.SSH.User - cl := new(CloneLink) - // if we have a ipv6 literal we need to put brackets around it // for the git cloning to work. sshDomain := setting.SSH.Domain @@ -566,12 +559,25 @@ func (repo *Repository) cloneLink(isWiki bool) *CloneLink { } if setting.SSH.Port != 22 { - cl.SSH = fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, net.JoinHostPort(setting.SSH.Domain, strconv.Itoa(setting.SSH.Port)), url.PathEscape(repo.OwnerName), url.PathEscape(repoName)) - } else if setting.Repository.UseCompatSSHURI { - cl.SSH = fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshDomain, url.PathEscape(repo.OwnerName), url.PathEscape(repoName)) - } else { - cl.SSH = fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshDomain, url.PathEscape(repo.OwnerName), url.PathEscape(repoName)) + return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, + net.JoinHostPort(setting.SSH.Domain, strconv.Itoa(setting.SSH.Port)), + url.PathEscape(ownerName), + url.PathEscape(repoName)) } + if setting.Repository.UseCompatSSHURI { + return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshDomain, url.PathEscape(ownerName), url.PathEscape(repoName)) + } + return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshDomain, url.PathEscape(ownerName), url.PathEscape(repoName)) +} + +func (repo *Repository) cloneLink(isWiki bool) *CloneLink { + repoName := repo.Name + if isWiki { + repoName += ".wiki" + } + + cl := new(CloneLink) + cl.SSH = ComposeSSHCloneURL(repo.OwnerName, repoName) cl.HTTPS = ComposeHTTPSCloneURL(repo.OwnerName, repoName) return cl } diff --git a/modules/context/repo.go b/modules/context/repo.go index 84e07ab422..b20ea26e4e 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -319,7 +319,14 @@ func EarlyResponseForGoGetMeta(ctx *Context) { ctx.PlainText(http.StatusBadRequest, "invalid repository path") return } - goImportContent := fmt.Sprintf("%s git %s", ComposeGoGetImport(username, reponame), repo_model.ComposeHTTPSCloneURL(username, reponame)) + + var cloneURL string + if setting.Repository.GoGetCloneURLProtocol == "ssh" { + cloneURL = repo_model.ComposeSSHCloneURL(username, reponame) + } else { + cloneURL = repo_model.ComposeHTTPSCloneURL(username, reponame) + } + goImportContent := fmt.Sprintf("%s git %s", ComposeGoGetImport(username, reponame), cloneURL) htmlMeta := fmt.Sprintf(``, html.EscapeString(goImportContent)) ctx.PlainText(http.StatusOK, htmlMeta) } diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 56e7e6f4ac..153307a0b6 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -36,6 +36,7 @@ var ( DisableHTTPGit bool AccessControlAllowOrigin string UseCompatSSHURI bool + GoGetCloneURLProtocol string DefaultCloseIssuesViaCommitsInAnyBranch bool EnablePushCreateUser bool EnablePushCreateOrg bool @@ -273,6 +274,7 @@ func loadRepositoryFrom(rootCfg ConfigProvider) { sec := rootCfg.Section("repository") Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool() Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool() + Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").MustString("https") Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1) Repository.DefaultBranch = sec.Key("DEFAULT_BRANCH").MustString(Repository.DefaultBranch) RepoRootPath = sec.Key("ROOT").MustString(path.Join(AppDataPath, "gitea-repositories")) diff --git a/routers/web/goget.go b/routers/web/goget.go index fb8afae999..c5b8b6cbc0 100644 --- a/routers/web/goget.go +++ b/routers/web/goget.go @@ -66,7 +66,14 @@ func goGet(ctx *context.Context) { } goGetImport := context.ComposeGoGetImport(ownerName, trimmedRepoName) - goImportContent := fmt.Sprintf("%s git %s", goGetImport, repo_model.ComposeHTTPSCloneURL(ownerName, repoName) /*CloneLink*/) + + var cloneURL string + if setting.Repository.GoGetCloneURLProtocol == "ssh" { + cloneURL = repo_model.ComposeSSHCloneURL(ownerName, repoName) + } else { + cloneURL = repo_model.ComposeHTTPSCloneURL(ownerName, repoName) + } + goImportContent := fmt.Sprintf("%s git %s", goGetImport, cloneURL /*CloneLink*/) goSourceContent := fmt.Sprintf("%s _ %s %s", goGetImport, prefix+"{/dir}" /*GoDocDirectory*/, prefix+"{/dir}/{file}#L{line}" /*GoDocFile*/) goGetCli := fmt.Sprintf("go get %s%s", insecure, goGetImport) diff --git a/tests/integration/goget_test.go b/tests/integration/goget_test.go index fab3911464..854f8d7a2d 100644 --- a/tests/integration/goget_test.go +++ b/tests/integration/goget_test.go @@ -33,3 +33,29 @@ func TestGoGet(t *testing.T) { assert.Equal(t, expected, resp.Body.String()) } + +func TestGoGetForSSH(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + old := setting.Repository.GoGetCloneURLProtocol + defer func() { + setting.Repository.GoGetCloneURLProtocol = old + }() + setting.Repository.GoGetCloneURLProtocol = "ssh" + + req := NewRequest(t, "GET", "/blah/glah/plah?go-get=1") + resp := MakeRequest(t, req, http.StatusOK) + + expected := fmt.Sprintf(` + + + + + + + go get --insecure %[1]s:%[2]s/blah/glah + +`, setting.Domain, setting.HTTPPort, setting.AppURL, setting.SSH.Domain, setting.SSH.Port) + + assert.Equal(t, expected, resp.Body.String()) +} From a96c73f9790772d6fa34f462dfd5a87f9a4b0e21 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 12 May 2023 12:23:53 +0200 Subject: [PATCH 07/55] Remove svg.svg class, restore .rss-icon (#24667) Fix regression from https://github.com/go-gitea/gitea/pull/24476 where the `svg.svg` class misaligns SVG icons across the site and streched buttons unintentionally in vertical height. Before (button 30.3px): Screenshot 2023-05-11 at 22 09 42 After (button 30px): Screenshot 2023-05-11 at 22 09 59 [vertical-align: middle](https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align) is not suitable to align icons to text because > Aligns the middle of the element with the baseline plus half the x-height of the parent. Example of `vertical-align: middle` from MDN: Screenshot 2023-05-11 at 22 29 28 So I think the [existing](https://github.com/go-gitea/gitea/blob/365bb77a5497d492836bf823cf780c823db27e8c/web_src/css/svg.css#L3) `vertical-align: text-top` is generally still the best bet: Screenshot 2023-05-11 at 22 34 24 Co-authored-by: Giteabot --- templates/org/home.tmpl | 2 +- templates/repo/header.tmpl | 2 +- templates/repo/release_tag_header.tmpl | 2 +- web_src/css/base.css | 11 +++++------ web_src/js/components/RepoBranchTagSelector.vue | 2 +- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 27f0df0b38..97047c71cc 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -10,7 +10,7 @@ {{if .Org.Visibility.IsPrivate}}{{.locale.Tr "org.settings.visibility.private_shortname"}}{{end}} {{if .EnableFeed}} - {{svg "octicon-rss" 24}} + {{svg "octicon-rss" 24}} {{end}} {{if $.RenderedDescription}}
{{$.RenderedDescription|Str2html}}
{{end}} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index b2fd0710af..03f5a2e78f 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -36,7 +36,7 @@ {{end}} {{if $.EnableFeed}} - {{svg "octicon-rss" 18}} + {{svg "octicon-rss" 18}} {{end}} {{if $.IsPullMirror}} diff --git a/templates/repo/release_tag_header.tmpl b/templates/repo/release_tag_header.tmpl index 6d022eebcc..d8f60a2c6c 100644 --- a/templates/repo/release_tag_header.tmpl +++ b/templates/repo/release_tag_header.tmpl @@ -11,7 +11,7 @@ {{end}} {{if .EnableFeed}} - {{svg "octicon-rss" 18}} + {{svg "octicon-rss" 18}} {{end}} {{if and (not .PageIsTagList) .CanCreateRelease}} diff --git a/web_src/css/base.css b/web_src/css/base.css index aa86d140fe..3663511fef 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -364,12 +364,6 @@ a.label, text-decoration: none !important; } -/* for most cases, we only use our svg icon as inline icon, so we need to make them inline-block and vertical-align: middle */ -svg.svg { - display: inline-block; - vertical-align: middle; -} - .ui.red.labels .label, .ui.ui.ui.red.label, .ui.red.button, @@ -2595,6 +2589,11 @@ a.ui.basic.label:hover { line-height: .67em; } +.rss-icon { + display: inline-flex; + color: var(--color-text-light-1); +} + table th[data-sortt-asc]:hover, table th[data-sortt-desc]:hover { background: rgba(0, 0, 0, 0.1) !important; diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue index f02918da9a..6be09251bc 100644 --- a/web_src/js/components/RepoBranchTagSelector.vue +++ b/web_src/js/components/RepoBranchTagSelector.vue @@ -39,7 +39,7 @@
{{.ID}}

{{.Name}}

{{if .Version}}{{.Version}}{{else}}{{$.locale.Tr "unknown"}}{{end}}{{.OwnType}}{{.BelongsToOwnerType.LocaleString $.locale}} {{range .AllLabels}}{{.}}{{end}}
+ + + + + + + + + + + + + + +
{{.locale.Tr "packages.alpine.repository.branches"}}
{{StringUtils.Join .Branches ", "}}
{{.locale.Tr "packages.alpine.repository.repositories"}}
{{StringUtils.Join .Repositories ", "}}
{{.locale.Tr "packages.alpine.repository.architectures"}}
{{StringUtils.Join .Architectures ", "}}
+
+ + {{if .PackageDescriptor.Metadata.Description}} +

{{.locale.Tr "packages.about"}}

+
+ {{.PackageDescriptor.Metadata.Description}} +
+ {{end}} +{{end}} diff --git a/templates/package/metadata/alpine.tmpl b/templates/package/metadata/alpine.tmpl new file mode 100644 index 0000000000..9011bfce10 --- /dev/null +++ b/templates/package/metadata/alpine.tmpl @@ -0,0 +1,5 @@ +{{if eq .PackageDescriptor.Package.Type "alpine"}} + {{if .PackageDescriptor.Metadata.Maintainer}}
{{svg "octicon-person" 16 "mr-3"}} {{.PackageDescriptor.Metadata.Maintainer}}
{{end}} + {{if .PackageDescriptor.Metadata.ProjectURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.details.project_site"}}
{{end}} + {{if .PackageDescriptor.Metadata.License}}
{{svg "octicon-law" 16 "gt-mr-3"}} {{.PackageDescriptor.Metadata.License}}
{{end}} +{{end}} diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl index 36637772cd..3c42c3adfd 100644 --- a/templates/package/view.tmpl +++ b/templates/package/view.tmpl @@ -19,6 +19,7 @@
+ {{template "package/content/alpine" .}} {{template "package/content/cargo" .}} {{template "package/content/chef" .}} {{template "package/content/composer" .}} @@ -48,6 +49,7 @@ {{end}}
{{svg "octicon-calendar" 16 "gt-mr-3"}} {{TimeSinceUnix .PackageDescriptor.Version.CreatedUnix $.locale}}
{{svg "octicon-download" 16 "gt-mr-3"}} {{.PackageDescriptor.Version.DownloadCount}}
+ {{template "package/metadata/alpine" .}} {{template "package/metadata/cargo" .}} {{template "package/metadata/chef" .}} {{template "package/metadata/composer" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 35cbc71c8b..01055cb00d 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2409,6 +2409,7 @@ }, { "enum": [ + "alpine", "cargo", "chef", "composer", diff --git a/tests/integration/api_packages_alpine_test.go b/tests/integration/api_packages_alpine_test.go new file mode 100644 index 0000000000..473dcc0424 --- /dev/null +++ b/tests/integration/api_packages_alpine_test.go @@ -0,0 +1,229 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "encoding/base64" + "fmt" + "io" + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + alpine_module "code.gitea.io/gitea/modules/packages/alpine" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestPackageAlpine(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "gitea-test" + packageVersion := "1.4.1-r3" + + base64AlpinePackageContent := `H4sIAAAAAAACA9ML9nT30wsKdtTLzjNJzjYuckjPLElN1DUzMUxMNTa11CsqTtQrKE1ioAAYAIGZ +iQmYBgJ02hDENjQxMTAzMzQ1MTVjMDA0MTQ1ZlAwYKADKC0uSSxSUGAYoWDm4sZZtypv75+q2fVT +POD1bKkFB22ms+g1z+H4dk7AhC3HwUSj9EbT0Rk3Dn55dHxy/K7Q+Nl/i+L7Z036ypcRvvpZuMiN +s7wbZL/klqRGGshv9Gi0qHTgTZfw3HytnJdx9c3NTRp/PHn+Z50uq2pjkilzjtpfd+uzQMw1M7cY +i9RXJasnT2M+vDXCesLK7MilJt8sGplj4xUlLMUun9SzY+phFpxWxRXa06AseV9WvzH3jtGGoL5A +vQkea+VKPj5R+Cb461tIk97qpa9nJYsJujTNl2B/J1P52H/D2rPr/j19uU8p7cMSq5tmXk51ReXl +F/Yddr9XsMpEwFKlXSPo3QSGwnCOG8y2uadjm6ui998WYXNYubjg78N3a7bnXjhrl5fB8voI++LI +1FP5W44e2xf4Ou2wrtyic1Onz7MzMV5ksuno2V/LVG4eN/15X/n2/2vJ2VV+T68aT327dOrhd6e6 +q5Y0V82Y83tdqkFa8TW2BvGCZ0ds/iibHVpzKuPcuSULO63/bNmfrnhjWqXzhMSXTb5Cv4vPaxSL +8LFMdqmxbN7+Y+Yi0ZyZhz4UxexLuHHFd1VFvk+kwvniq3P+f9rh52InWnL8Lpvedcecoh1GFSc5 +xZ9VBGex2V269HZfwxSVCvP35wQfi2xKX+lYMXtF48n1R65O2PLWpm69RdESMa79dlrTGazsZacu +MbMLeSSScPORZde76/MBV6SFJAAEAAAfiwgAAAAAAAID7VRLaxsxEN6zfoUgZ++OVq+1aUIhUDeY +pKa49FhmJdkW3ofRysXpr69220t9SCk0gZJ+IGaY56eBmbxY4/m9Q+vCUOTr1fLu4d2H7O8CEpQQ +k0y4lAClypgQoBSTQqoMGBMgMnrOXgCnIWJIVLLXCcaoib5110CSij/V7D9eCZ5p5f9o/5VkF/tf +MqUzCi+5/6Hv41Nxv/Nffu4fwRVdus4FjM7S+pFiffKNpTxnkMMsALmin5PnHgMtS8rkgvGFBPpp +c0tLKDk5HnYdto5e052PDmfRDXE0fnUh2VgucjYLU5h1g0mm5RhGNymMrtEccOfIKTTJsY/xOCyK +YqqT+74gExWbmI2VlJ6LeQUcyPFH2lh/9SBuV/wjfXPohDnw8HZKviGD/zYmCZgrgsHsk36u1Bcl +SB/8zne/0jV92/qYbKRF38X0niiemN2QxhvXDWOL+7tNGhGeYt+m22mwaR6pddGZNM8FSeRxj8PY +X7PaqdqAVlqWXHKnmQGmK43VlqNlILRilbBSMI2jV5Vbu5XGSVsDyGc7yd8B/gK2qgAIAAAfiwgA +AAAAAAID7dNNSgMxGAbg7MSCOxcu5wJOv0x+OlkU7K5QoYXqVsxMMihlKMwP1Fu48QQewCN4DfEQ +egUz4sYuFKEtFN9n870hWSSQN+7P7GrsrfNV3Y9dW5Z3bNMo0FJ+zmB9EhcJ41KS1lxJpRnxbsWi +FduBtm5sFa7C/ifOo7y5Lf2QeiHar6jTaDSbnF5Mp+fzOL/x+aJuy3g+HvGhs8JY4b3yOpMZOZEo +lRW+MEoTTw3ZwqU0INNjsAe2VPk/9b/L3/s/kIKzqOtk+IbJGTtmr+bx7WoxOUoun98frk/un14O +Djfa/2q5bH4699v++uMAAAAAAAAAAAAAAAAAAAAAAHbgA/eXQh8AKAAA` + content, err := base64.StdEncoding.DecodeString(base64AlpinePackageContent) + assert.NoError(t, err) + + branches := []string{"v3.16", "v3.17"} + repositories := []string{"main", "testing"} + + rootURL := fmt.Sprintf("/api/packages/%s/alpine", user.Name) + + t.Run("RepositoryKey", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", rootURL+"/key") + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "application/x-pem-file", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Body.String(), "-----BEGIN PUBLIC KEY-----") + }) + + for _, branch := range branches { + for _, repository := range repositories { + t.Run(fmt.Sprintf("[Branch:%s,Repository:%s]", branch, repository), func(t *testing.T) { + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := fmt.Sprintf("%s/%s/%s", rootURL, branch, repository) + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)) + AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeAlpine) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + assert.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &alpine_module.VersionMetadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + assert.NoError(t, err) + assert.NotEmpty(t, pfs) + assert.Condition(t, func() bool { + seen := false + expectedFilename := fmt.Sprintf("%s-%s.apk", packageName, packageVersion) + expectedCompositeKey := fmt.Sprintf("%s|%s|x86_64", branch, repository) + for _, pf := range pfs { + if pf.Name == expectedFilename && pf.CompositeKey == expectedCompositeKey { + if seen { + return false + } + seen = true + + assert.True(t, pf.IsLead) + + pfps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID) + assert.NoError(t, err) + + for _, pfp := range pfps { + switch pfp.Name { + case alpine_module.PropertyBranch: + assert.Equal(t, branch, pfp.Value) + case alpine_module.PropertyRepository: + assert.Equal(t, repository, pfp.Value) + case alpine_module.PropertyArchitecture: + assert.Equal(t, "x86_64", pfp.Value) + } + } + } + } + return seen + }) + }) + + t.Run("Index", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + url := fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository) + + req := NewRequest(t, "GET", url) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Condition(t, func() bool { + br := bufio.NewReader(resp.Body) + + gzr, err := gzip.NewReader(br) + assert.NoError(t, err) + + for { + gzr.Multistream(false) + + tr := tar.NewReader(gzr) + for { + hd, err := tr.Next() + if err == io.EOF { + break + } + assert.NoError(t, err) + + if hd.Name == "APKINDEX" { + buf, err := io.ReadAll(tr) + assert.NoError(t, err) + + s := string(buf) + + assert.Contains(t, s, "C:Q1/se1PjO94hYXbfpNR1/61hVORIc=\n") + assert.Contains(t, s, "P:"+packageName+"\n") + assert.Contains(t, s, "V:"+packageVersion+"\n") + assert.Contains(t, s, "A:x86_64\n") + assert.Contains(t, s, "T:Gitea Test Package\n") + assert.Contains(t, s, "U:https://gitea.io/\n") + assert.Contains(t, s, "L:MIT\n") + assert.Contains(t, s, "S:1353\n") + assert.Contains(t, s, "I:4096\n") + assert.Contains(t, s, "o:gitea-test\n") + assert.Contains(t, s, "m:KN4CK3R \n") + assert.Contains(t, s, "t:1679498030\n") + + return true + } + } + + err = gzr.Reset(br) + if err == io.EOF { + break + } + assert.NoError(t, err) + } + + return false + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)) + MakeRequest(t, req, http.StatusOK) + }) + }) + } + } + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for _, branch := range branches { + for _, repository := range repositories { + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)) + AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusNoContent) + + // Deleting the last file of an architecture should remove that index + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository)) + MakeRequest(t, req, http.StatusNotFound) + } + } + }) +} diff --git a/web_src/svg/gitea-alpine.svg b/web_src/svg/gitea-alpine.svg new file mode 100644 index 0000000000..a297d95ec5 --- /dev/null +++ b/web_src/svg/gitea-alpine.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file From a4d6f83b5be6b95f288a5ca02eb1f46d8ac0f992 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 12 May 2023 21:15:49 +0200 Subject: [PATCH 15/55] Add `eslint-plugin-wc` (#24689) Add [eslint-plugin-wc](https://github.com/43081j/eslint-plugin-wc), another small set of eslint rules covering web components. --- .eslintrc.yaml | 10 ++++++++++ package-lock.json | 29 +++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 40 insertions(+) diff --git a/.eslintrc.yaml b/.eslintrc.yaml index c4e0b9de8c..5fa6d1e682 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -16,6 +16,7 @@ plugins: - eslint-plugin-sonarjs - eslint-plugin-custom-elements - eslint-plugin-regexp + - eslint-plugin-wc env: es2022: true @@ -717,6 +718,15 @@ rules: use-isnan: [2] valid-typeof: [2, {requireStringLiterals: true}] vars-on-top: [0] + wc/attach-shadow-constructor: [2] + wc/guard-super-call: [2] + wc/no-closed-shadow-root: [2] + wc/no-constructor-attributes: [2] + wc/no-constructor-params: [2] + wc/no-invalid-element-name: [0] # covered by custom-elements/valid-tag-name + wc/no-self-class: [2] + wc/no-typos: [2] + wc/require-listener-teardown: [2] wrap-iife: [2, inside] wrap-regex: [0] yield-star-spacing: [2, after] diff --git a/package-lock.json b/package-lock.json index 36539779b8..72e9cdab9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "eslint-plugin-sonarjs": "0.19.0", "eslint-plugin-unicorn": "47.0.0", "eslint-plugin-vue": "9.12.0", + "eslint-plugin-wc": "1.5.0", "jsdom": "22.0.0", "markdownlint-cli": "0.34.0", "stylelint": "15.6.1", @@ -4683,6 +4684,19 @@ "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/eslint-plugin-wc": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-wc/-/eslint-plugin-wc-1.5.0.tgz", + "integrity": "sha512-KFSfiHDol/LeV7U6IX8GdgpGf/s3wG8FTG120Rml/hGNB/DkCuGYQhlf0VgdBdf7gweem8Nlsh5o64HNdj+qPA==", + "dev": true, + "dependencies": { + "is-valid-element-name": "^1.0.0", + "js-levenshtein-esm": "^1.2.0" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, "node_modules/eslint-scope": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", @@ -5994,6 +6008,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-valid-element-name": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-valid-element-name/-/is-valid-element-name-1.0.0.tgz", + "integrity": "sha512-GZITEJY2LkSjQfaIPBha7eyZv+ge0PhBR7KITeCCWvy7VBQrCUdFkvpI+HrAPQjVtVjy1LvlEkqQTHckoszruw==", + "dev": true, + "dependencies": { + "is-potential-custom-element-name": "^1.0.0" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -6086,6 +6109,12 @@ "node": ">=0.8.0" } }, + "node_modules/js-levenshtein-esm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/js-levenshtein-esm/-/js-levenshtein-esm-1.2.0.tgz", + "integrity": "sha512-fzreKVq1eD7eGcQr7MtRpQH94f8gIfhdrc7yeih38xh684TNMK9v5aAu2wxfIRMk/GpAJRrzcirMAPIaSDaByQ==", + "dev": true + }, "node_modules/js-sdsl": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", diff --git a/package.json b/package.json index 9054404fbf..e0e0644024 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "eslint-plugin-sonarjs": "0.19.0", "eslint-plugin-unicorn": "47.0.0", "eslint-plugin-vue": "9.12.0", + "eslint-plugin-wc": "1.5.0", "jsdom": "22.0.0", "markdownlint-cli": "0.34.0", "stylelint": "15.6.1", From f74501609246646a3ab62b37a1e7469d6fea63b7 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 12 May 2023 22:38:17 +0200 Subject: [PATCH 16/55] Add `eslint-plugin-eslint-comments` (#24690) Add [eslint-plugin-eslint-comments](https://github.com/mysticatea/eslint-plugin-eslint-comments) which prevents some abuse related to eslint comments. --- .eslintrc.yaml | 10 ++++++++++ package-lock.json | 29 +++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 40 insertions(+) diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 5fa6d1e682..d47d8c0349 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -17,6 +17,7 @@ plugins: - eslint-plugin-custom-elements - eslint-plugin-regexp - eslint-plugin-wc + - eslint-plugin-eslint-comments env: es2022: true @@ -86,6 +87,15 @@ rules: dot-notation: [0] eol-last: [2] eqeqeq: [2] + eslint-comments/disable-enable-pair: [2] + eslint-comments/no-aggregating-enable: [2] + eslint-comments/no-duplicate-disable: [2] + eslint-comments/no-restricted-disable: [0] + eslint-comments/no-unlimited-disable: [2] + eslint-comments/no-unused-disable: [2] + eslint-comments/no-unused-enable: [2] + eslint-comments/no-use: [0] + eslint-comments/require-description: [0] for-direction: [2] func-call-spacing: [2, never] func-name-matching: [2] diff --git a/package-lock.json b/package-lock.json index 72e9cdab9f..4754a8a826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "@vitejs/plugin-vue": "4.2.3", "eslint": "8.40.0", "eslint-plugin-custom-elements": "0.0.8", + "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-jquery": "1.5.1", "eslint-plugin-no-jquery": "2.7.0", @@ -4519,6 +4520,34 @@ "eslint": ">=4.19.0" } }, + "node_modules/eslint-plugin-eslint-comments": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.2.0.tgz", + "integrity": "sha512-0jkOl0hfojIHHmEHgmNdqv4fmh7300NdpA9FFpF7zaoLvB/QeXOGNLIo86oAveJFrfB1p05kC8hpEMHM8DwWVQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5", + "ignore": "^5.0.5" + }, + "engines": { + "node": ">=6.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-eslint-comments/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/eslint-plugin-import": { "version": "2.27.5", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", diff --git a/package.json b/package.json index e0e0644024..21c39aae9c 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@vitejs/plugin-vue": "4.2.3", "eslint": "8.40.0", "eslint-plugin-custom-elements": "0.0.8", + "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-jquery": "1.5.1", "eslint-plugin-no-jquery": "2.7.0", From a94a8d0ab1f6c9d63ae607dfec866c47ae3e4b5c Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 13 May 2023 22:04:57 +0800 Subject: [PATCH 17/55] Use standard HTTP library to serve files (#24693) `http.ServeFile/ServeContent` handles `If-xxx`, `Content-Length`, `Range` and `Etag` correctly After this PR, storage files (eg: avatar) could be responded with correct Content-Length. --- modules/context/context_test.go | 4 +- modules/httpcache/httpcache.go | 37 ++----------------- modules/httpcache/httpcache_test.go | 57 ----------------------------- modules/httplib/mock.go | 35 ------------------ modules/httplib/serve_test.go | 13 ++++--- modules/public/public.go | 8 +--- routers/web/base.go | 16 +++----- routers/web/misc/misc.go | 9 ++--- 8 files changed, 22 insertions(+), 157 deletions(-) delete mode 100644 modules/httplib/mock.go diff --git a/modules/context/context_test.go b/modules/context/context_test.go index a6facc9788..033ce2ef0a 100644 --- a/modules/context/context_test.go +++ b/modules/context/context_test.go @@ -5,16 +5,16 @@ package context import ( "net/http" + "net/http/httptest" "testing" - "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" ) func TestRemoveSessionCookieHeader(t *testing.T) { - w := httplib.NewMockResponseWriter() + w := httptest.NewRecorder() w.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "foo"}).String()) w.Header().Add("Set-Cookie", (&http.Cookie{Name: "other", Value: "bar"}).String()) assert.Len(t, w.Header().Values("Set-Cookie"), 2) diff --git a/modules/httpcache/httpcache.go b/modules/httpcache/httpcache.go index 46e0152ef4..001ac06415 100644 --- a/modules/httpcache/httpcache.go +++ b/modules/httpcache/httpcache.go @@ -4,10 +4,8 @@ package httpcache import ( - "encoding/base64" - "fmt" + "io" "net/http" - "os" "strconv" "strings" "time" @@ -37,38 +35,9 @@ func SetCacheControlInHeader(h http.Header, maxAge time.Duration, additionalDire h.Set("Cache-Control", strings.Join(append(directives, additionalDirectives...), ", ")) } -// generateETag generates an ETag based on size, filename and file modification time -func generateETag(fi os.FileInfo) string { - etag := fmt.Sprint(fi.Size()) + fi.Name() + fi.ModTime().UTC().Format(http.TimeFormat) - return `"` + base64.StdEncoding.EncodeToString([]byte(etag)) + `"` -} - -// HandleTimeCache handles time-based caching for a HTTP request -func HandleTimeCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) { - return HandleGenericTimeCache(req, w, fi.ModTime()) -} - -// HandleGenericTimeCache handles time-based caching for a HTTP request -func HandleGenericTimeCache(req *http.Request, w http.ResponseWriter, lastModified time.Time) (handled bool) { +func ServeContentWithCacheControl(w http.ResponseWriter, req *http.Request, name string, modTime time.Time, content io.ReadSeeker) { SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) - - ifModifiedSince := req.Header.Get("If-Modified-Since") - if ifModifiedSince != "" { - t, err := time.Parse(http.TimeFormat, ifModifiedSince) - if err == nil && lastModified.Unix() <= t.Unix() { - w.WriteHeader(http.StatusNotModified) - return true - } - } - - w.Header().Set("Last-Modified", lastModified.Format(http.TimeFormat)) - return false -} - -// HandleFileETagCache handles ETag-based caching for a HTTP request -func HandleFileETagCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) { - etag := generateETag(fi) - return HandleGenericETagCache(req, w, etag) + http.ServeContent(w, req, name, modTime, content) } // HandleGenericETagCache handles ETag-based caching for a HTTP request. diff --git a/modules/httpcache/httpcache_test.go b/modules/httpcache/httpcache_test.go index 624d2f4d4b..d81f06097c 100644 --- a/modules/httpcache/httpcache_test.go +++ b/modules/httpcache/httpcache_test.go @@ -6,23 +6,12 @@ package httpcache import ( "net/http" "net/http/httptest" - "os" "strings" "testing" - "time" "github.com/stretchr/testify/assert" ) -type mockFileInfo struct{} - -func (m mockFileInfo) Name() string { return "gitea.test" } -func (m mockFileInfo) Size() int64 { return int64(10) } -func (m mockFileInfo) Mode() os.FileMode { return os.ModePerm } -func (m mockFileInfo) ModTime() time.Time { return time.Time{} } -func (m mockFileInfo) IsDir() bool { return false } -func (m mockFileInfo) Sys() interface{} { return nil } - func countFormalHeaders(h http.Header) (c int) { for k := range h { // ignore our headers for internal usage @@ -34,52 +23,6 @@ func countFormalHeaders(h http.Header) (c int) { return c } -func TestHandleFileETagCache(t *testing.T) { - fi := mockFileInfo{} - etag := `"MTBnaXRlYS50ZXN0TW9uLCAwMSBKYW4gMDAwMSAwMDowMDowMCBHTVQ="` - - t.Run("No_If-None-Match", func(t *testing.T) { - req := &http.Request{Header: make(http.Header)} - w := httptest.NewRecorder() - - handled := HandleFileETagCache(req, w, fi) - - assert.False(t, handled) - assert.Equal(t, 2, countFormalHeaders(w.Header())) - assert.Contains(t, w.Header(), "Cache-Control") - assert.Contains(t, w.Header(), "Etag") - assert.Equal(t, etag, w.Header().Get("Etag")) - }) - t.Run("Wrong_If-None-Match", func(t *testing.T) { - req := &http.Request{Header: make(http.Header)} - w := httptest.NewRecorder() - - req.Header.Set("If-None-Match", `"wrong etag"`) - - handled := HandleFileETagCache(req, w, fi) - - assert.False(t, handled) - assert.Equal(t, 2, countFormalHeaders(w.Header())) - assert.Contains(t, w.Header(), "Cache-Control") - assert.Contains(t, w.Header(), "Etag") - assert.Equal(t, etag, w.Header().Get("Etag")) - }) - t.Run("Correct_If-None-Match", func(t *testing.T) { - req := &http.Request{Header: make(http.Header)} - w := httptest.NewRecorder() - - req.Header.Set("If-None-Match", etag) - - handled := HandleFileETagCache(req, w, fi) - - assert.True(t, handled) - assert.Equal(t, 1, countFormalHeaders(w.Header())) - assert.Contains(t, w.Header(), "Etag") - assert.Equal(t, etag, w.Header().Get("Etag")) - assert.Equal(t, http.StatusNotModified, w.Code) - }) -} - func TestHandleGenericETagCache(t *testing.T) { etag := `"test"` diff --git a/modules/httplib/mock.go b/modules/httplib/mock.go deleted file mode 100644 index 7d284e86fb..0000000000 --- a/modules/httplib/mock.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package httplib - -import ( - "bytes" - "net/http" -) - -type MockResponseWriter struct { - header http.Header - - StatusCode int - BodyBuffer bytes.Buffer -} - -func (m *MockResponseWriter) Header() http.Header { - return m.header -} - -func (m *MockResponseWriter) Write(bytes []byte) (int, error) { - if m.StatusCode == 0 { - m.StatusCode = http.StatusOK - } - return m.BodyBuffer.Write(bytes) -} - -func (m *MockResponseWriter) WriteHeader(statusCode int) { - m.StatusCode = statusCode -} - -func NewMockResponseWriter() *MockResponseWriter { - return &MockResponseWriter{header: http.Header{}} -} diff --git a/modules/httplib/serve_test.go b/modules/httplib/serve_test.go index 0768f1c713..fed4611d21 100644 --- a/modules/httplib/serve_test.go +++ b/modules/httplib/serve_test.go @@ -6,6 +6,7 @@ package httplib import ( "fmt" "net/http" + "net/http/httptest" "net/url" "os" "strings" @@ -25,12 +26,12 @@ func TestServeContentByReader(t *testing.T) { r.Header.Set("Range", fmt.Sprintf("bytes=%s", rangeStr)) } reader := strings.NewReader(data) - w := NewMockResponseWriter() + w := httptest.NewRecorder() ServeContentByReader(r, w, "test", int64(len(data)), reader) - assert.Equal(t, expectedStatusCode, w.StatusCode) + assert.Equal(t, expectedStatusCode, w.Code) if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK { assert.Equal(t, fmt.Sprint(len(expectedContent)), w.Header().Get("Content-Length")) - assert.Equal(t, expectedContent, w.BodyBuffer.String()) + assert.Equal(t, expectedContent, w.Body.String()) } } @@ -76,12 +77,12 @@ func TestServeContentByReadSeeker(t *testing.T) { } defer seekReader.Close() - w := NewMockResponseWriter() + w := httptest.NewRecorder() ServeContentByReadSeeker(r, w, "test", time.Time{}, seekReader) - assert.Equal(t, expectedStatusCode, w.StatusCode) + assert.Equal(t, expectedStatusCode, w.Code) if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK { assert.Equal(t, fmt.Sprint(len(expectedContent)), w.Header().Get("Content-Length")) - assert.Equal(t, expectedContent, w.BodyBuffer.String()) + assert.Equal(t, expectedContent, w.Body.String()) } } diff --git a/modules/public/public.go b/modules/public/public.go index 0c0e6dc1cc..ed38d85cfa 100644 --- a/modules/public/public.go +++ b/modules/public/public.go @@ -97,10 +97,6 @@ func handleRequest(w http.ResponseWriter, req *http.Request, fs http.FileSystem, return true } - if httpcache.HandleFileETagCache(req, w, fi) { - return true - } - serveContent(w, req, fi, fi.ModTime(), f) return true } @@ -124,11 +120,11 @@ func serveContent(w http.ResponseWriter, req *http.Request, fi os.FileInfo, modt w.Header().Set("Content-Type", "application/octet-stream") } w.Header().Set("Content-Encoding", "gzip") - http.ServeContent(w, req, fi.Name(), modtime, rdGzip) + httpcache.ServeContentWithCacheControl(w, req, fi.Name(), modtime, rdGzip) return } } - http.ServeContent(w, req, fi.Name(), modtime, content) + httpcache.ServeContentWithCacheControl(w, req, fi.Name(), modtime, content) return } diff --git a/routers/web/base.go b/routers/web/base.go index b9b5958389..1f6c4fbfc5 100644 --- a/routers/web/base.go +++ b/routers/web/base.go @@ -6,7 +6,6 @@ package web import ( "errors" "fmt" - "io" "net/http" "os" "path" @@ -76,12 +75,6 @@ func storageHandler(storageSetting setting.Storage, prefix string, objStore stor } fi, err := objStore.Stat(rPath) - if err == nil && httpcache.HandleTimeCache(req, w, fi) { - return - } - - // If we have matched and access to release or issue - fr, err := objStore.Open(rPath) if err != nil { if os.IsNotExist(err) || errors.Is(err, os.ErrNotExist) { log.Warn("Unable to find %s %s", prefix, rPath) @@ -92,14 +85,15 @@ func storageHandler(storageSetting setting.Storage, prefix string, objStore stor http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError) return } - defer fr.Close() - _, err = io.Copy(w, fr) + fr, err := objStore.Open(rPath) if err != nil { - log.Error("Error whilst rendering %s %s. Error: %v", prefix, rPath, err) - http.Error(w, fmt.Sprintf("Error whilst rendering %s %s", prefix, rPath), http.StatusInternalServerError) + log.Error("Error whilst opening %s %s. Error: %v", prefix, rPath, err) + http.Error(w, fmt.Sprintf("Error whilst opening %s %s", prefix, rPath), http.StatusInternalServerError) return } + defer fr.Close() + httpcache.ServeContentWithCacheControl(w, req, path.Base(rPath), fi.ModTime(), fr) }) } } diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go index 582179990a..6ed3b5c3ad 100644 --- a/routers/web/misc/misc.go +++ b/routers/web/misc/misc.go @@ -5,13 +5,13 @@ package misc import ( "net/http" - "os" "path" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) func SSHInfo(rw http.ResponseWriter, req *http.Request) { @@ -34,11 +34,8 @@ func DummyOK(w http.ResponseWriter, req *http.Request) { } func RobotsTxt(w http.ResponseWriter, req *http.Request) { - filePath := path.Join(setting.CustomPath, "robots.txt") - fi, err := os.Stat(filePath) - if err == nil && httpcache.HandleTimeCache(req, w, fi) { - return - } + filePath := util.FilePathJoinAbs(setting.CustomPath, "robots.txt") + httpcache.SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) http.ServeFile(w, req, filePath) } From 542adf50d5007ec85a54843c33c716b68371f341 Mon Sep 17 00:00:00 2001 From: Yarden Shoham Date: Sat, 13 May 2023 17:47:58 +0300 Subject: [PATCH 18/55] Fix `organization` field being `null` in `GET /api/v1/teams/{id}` (#24694) Enabled the organization loading flag. - Fixes #20399 # Before ```json { ... "description": "", "organization": null, "includes_all_repositories": true, "permission": "owner", ... } ``` # After ```json { ... "description": "", "organization": { "id": 2, "name": "bigorg", "full_name": "", "avatar_url": "https://3000-yardenshoham-gitea-3gfrlc9gn4h.ws-us96b.gitpod.io/avatars/e2649b0c016d9102664a7d4349503eb9", "description": "", "website": "", "location": "", "visibility": "public", "repo_admin_change_team_access": true, "username": "bigorg" }, "includes_all_repositories": true, "permission": "owner", ... } ``` Signed-off-by: Yarden Shoham Co-authored-by: Giteabot --- routers/api/v1/org/team.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index 024fee3469..8b7d7b1951 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -126,7 +126,7 @@ func GetTeam(ctx *context.APIContext) { // "200": // "$ref": "#/responses/Team" - apiTeam, err := convert.ToTeam(ctx, ctx.Org.Team) + apiTeam, err := convert.ToTeam(ctx, ctx.Org.Team, true) if err != nil { ctx.InternalServerError(err) return From 9f1d377b87771ce2ef76caeef0aa649e768a24d7 Mon Sep 17 00:00:00 2001 From: Yarden Shoham Date: Sat, 13 May 2023 18:22:31 +0300 Subject: [PATCH 19/55] Fix docs documenting invalid `@every` for `OLDER_THAN` cron settings (#24695) Looks like a copy-paste leftover - Fixes #20868 Co-authored-by: Giteabot --- docs/content/doc/administration/config-cheat-sheet.en-us.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/doc/administration/config-cheat-sheet.en-us.md b/docs/content/doc/administration/config-cheat-sheet.en-us.md index 845840eb2d..af6b3a2edd 100644 --- a/docs/content/doc/administration/config-cheat-sheet.en-us.md +++ b/docs/content/doc/administration/config-cheat-sheet.en-us.md @@ -1018,7 +1018,7 @@ Default templates for project boards: - `RUN_AT_START`: **false**: Run tasks at start up time (if ENABLED). - `NOTICE_ON_SUCCESS`: **false**: Set to true to switch on success notices. - `SCHEDULE`: **@every 168h**: Cron syntax to set how often to check. -- `OLDER_THAN`: **@every 8760h**: any action older than this expression will be deleted from database, suggest using `8760h` (1 year) because that's the max length of heatmap. +- `OLDER_THAN`: **8760h**: any action older than this expression will be deleted from database, suggest using `8760h` (1 year) because that's the max length of heatmap. #### Cron - Check for new Gitea versions (`cron.update_checker`) @@ -1034,7 +1034,7 @@ Default templates for project boards: - `RUN_AT_START`: **false**: Run tasks at start up time (if ENABLED). - `NO_SUCCESS_NOTICE`: **false**: Set to true to switch off success notices. - `SCHEDULE`: **@every 168h**: Cron syntax to set how often to check. -- `OLDER_THAN`: **@every 8760h**: any system notice older than this expression will be deleted from database. +- `OLDER_THAN`: **8760h**: any system notice older than this expression will be deleted from database. #### Cron - Garbage collect LFS pointers in repositories (`cron.gc_lfs`) From 82224c54e0488738dbd3b7eccf56ab08b6790627 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 14 May 2023 02:59:11 +0800 Subject: [PATCH 20/55] Improve avatar uploading / resizing / compressing, remove Fomantic card module (#24653) Fixes: #8972 Fixes: #24263 And I think it also (partially) fix #24263 (no need to convert) , because users could upload any supported image format if it isn't larger than AVATAR_MAX_ORIGIN_SIZE The main idea: * if the uploaded file size is not larger than AVATAR_MAX_ORIGIN_SIZE, use the origin * if the resized size is larger than the origin, use the origin Screenshots: JPG:
![image](https://github.com/go-gitea/gitea/assets/2114189/70e98bb0-ecb9-4c4e-a89f-4a37d4e37f8e)
APNG:
![image](https://github.com/go-gitea/gitea/assets/2114189/9055135b-5e2d-4152-bd72-596fcb7c6671) ![image](https://github.com/go-gitea/gitea/assets/2114189/50364caf-f7f6-4241-a289-e485fe4cd582)
WebP (animated)
![image](https://github.com/go-gitea/gitea/assets/2114189/f642eb85-498a-49a5-86bf-0a7b04089ae0)
The only exception: if a WebP image is larger than MaxOriginSize and it is animated, then current `webp` package can't decode it, so only in this case it isn't supported. IMO no need to support such case: why a user would upload a 1MB animated webp as avatar? crazy ..... --------- Co-authored-by: silverwind --- custom/conf/app.example.ini | 7 +- .../config-cheat-sheet.en-us.md | 7 +- .../config-cheat-sheet.zh-cn.md | 4 +- modules/avatar/avatar.go | 70 +- modules/avatar/avatar_test.go | 95 +- modules/avatar/testdata/animated.webp | Bin 0 -> 4934 bytes modules/repository/commits_test.go | 7 +- modules/setting/picture.go | 17 +- services/repository/avatar.go | 7 +- services/user/user.go | 7 +- templates/user/profile.tmpl | 5 +- web_src/css/base.css | 64 +- web_src/css/index.css | 1 + web_src/css/modules/card.css | 134 ++ web_src/css/user.css | 5 +- web_src/fomantic/build/semantic.css | 1378 ----------------- web_src/fomantic/semantic.json | 1 - 17 files changed, 304 insertions(+), 1505 deletions(-) create mode 100644 modules/avatar/testdata/animated.webp create mode 100644 web_src/css/modules/card.css diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 6e89c42c64..fc0c502194 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1773,16 +1773,19 @@ ROUTER = console ;; Max Width and Height of uploaded avatars. ;; This is to limit the amount of RAM used when resizing the image. ;AVATAR_MAX_WIDTH = 4096 -;AVATAR_MAX_HEIGHT = 3072 +;AVATAR_MAX_HEIGHT = 4096 ;; ;; The multiplication factor for rendered avatar images. ;; Larger values result in finer rendering on HiDPI devices. -;AVATAR_RENDERED_SIZE_FACTOR = 3 +;AVATAR_RENDERED_SIZE_FACTOR = 2 ;; ;; Maximum allowed file size for uploaded avatars. ;; This is to limit the amount of RAM used when resizing the image. ;AVATAR_MAX_FILE_SIZE = 1048576 ;; +;; If the uploaded file is not larger than this byte size, the image will be used as is, without resizing/converting. +;AVATAR_MAX_ORIGIN_SIZE = 262144 +;; ;; Chinese users can choose "duoshuo" ;; or a custom avatar source, like: http://cn.gravatar.com/avatar/ ;GRAVATAR_SOURCE = gravatar diff --git a/docs/content/doc/administration/config-cheat-sheet.en-us.md b/docs/content/doc/administration/config-cheat-sheet.en-us.md index af6b3a2edd..82665d7d2c 100644 --- a/docs/content/doc/administration/config-cheat-sheet.en-us.md +++ b/docs/content/doc/administration/config-cheat-sheet.en-us.md @@ -792,9 +792,10 @@ and - `AVATAR_STORAGE_TYPE`: **default**: Storage type defined in `[storage.xxx]`. Default is `default` which will read `[storage]` if no section `[storage]` will be a type `local`. - `AVATAR_UPLOAD_PATH`: **data/avatars**: Path to store user avatar image files. - `AVATAR_MAX_WIDTH`: **4096**: Maximum avatar image width in pixels. -- `AVATAR_MAX_HEIGHT`: **3072**: Maximum avatar image height in pixels. -- `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): Maximum avatar image file size in bytes. -- `AVATAR_RENDERED_SIZE_FACTOR`: **3**: The multiplication factor for rendered avatar images. Larger values result in finer rendering on HiDPI devices. +- `AVATAR_MAX_HEIGHT`: **4096**: Maximum avatar image height in pixels. +- `AVATAR_MAX_FILE_SIZE`: **1048576** (1MiB): Maximum avatar image file size in bytes. +- `AVATAR_MAX_ORIGIN_SIZE`: **262144** (256KiB): If the uploaded file is not larger than this byte size, the image will be used as is, without resizing/converting. +- `AVATAR_RENDERED_SIZE_FACTOR`: **2**: The multiplication factor for rendered avatar images. Larger values result in finer rendering on HiDPI devices. - `REPOSITORY_AVATAR_STORAGE_TYPE`: **default**: Storage type defined in `[storage.xxx]`. Default is `default` which will read `[storage]` if no section `[storage]` will be a type `local`. - `REPOSITORY_AVATAR_UPLOAD_PATH`: **data/repo-avatars**: Path to store repository avatar image files. diff --git a/docs/content/doc/administration/config-cheat-sheet.zh-cn.md b/docs/content/doc/administration/config-cheat-sheet.zh-cn.md index 41eed612ac..c672b61598 100644 --- a/docs/content/doc/administration/config-cheat-sheet.zh-cn.md +++ b/docs/content/doc/administration/config-cheat-sheet.zh-cn.md @@ -214,8 +214,8 @@ menu: - `AVATAR_STORAGE_TYPE`: **local**: 头像存储类型,可以为 `local` 或 `minio`,分别支持本地文件系统和 minio 兼容的API。 - `AVATAR_UPLOAD_PATH`: **data/avatars**: 存储头像的文件系统路径。 - `AVATAR_MAX_WIDTH`: **4096**: 头像最大宽度,单位像素。 -- `AVATAR_MAX_HEIGHT`: **3072**: 头像最大高度,单位像素。 -- `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): 头像最大大小。 +- `AVATAR_MAX_HEIGHT`: **4096**: 头像最大高度,单位像素。 +- `AVATAR_MAX_FILE_SIZE`: **1048576** (1MiB): 头像最大大小。 - `REPOSITORY_AVATAR_STORAGE_TYPE`: **local**: 仓库头像存储类型,可以为 `local` 或 `minio`,分别支持本地文件系统和 minio 兼容的API。 - `REPOSITORY_AVATAR_UPLOAD_PATH`: **data/repo-avatars**: 存储仓库头像的路径。 diff --git a/modules/avatar/avatar.go b/modules/avatar/avatar.go index c166f14404..10de85b74e 100644 --- a/modules/avatar/avatar.go +++ b/modules/avatar/avatar.go @@ -5,13 +5,14 @@ package avatar import ( "bytes" + "errors" "fmt" "image" "image/color" + "image/png" _ "image/gif" // for processing gif images _ "image/jpeg" // for processing jpeg images - _ "image/png" // for processing png images "code.gitea.io/gitea/modules/avatar/identicon" "code.gitea.io/gitea/modules/setting" @@ -22,8 +23,11 @@ import ( _ "golang.org/x/image/webp" // for processing webp images ) -// AvatarSize returns avatar's size -const AvatarSize = 290 +// DefaultAvatarSize is the target CSS pixel size for avatar generation. It is +// multiplied by setting.Avatar.RenderedSizeFactor and the resulting size is the +// usual size of avatar image saved on server, unless the original file is smaller +// than the size after resizing. +const DefaultAvatarSize = 256 // RandomImageSize generates and returns a random avatar image unique to input data // in custom size (height and width). @@ -39,28 +43,44 @@ func RandomImageSize(size int, data []byte) (image.Image, error) { // RandomImage generates and returns a random avatar image unique to input data // in default size (height and width). func RandomImage(data []byte) (image.Image, error) { - return RandomImageSize(AvatarSize, data) + return RandomImageSize(DefaultAvatarSize*setting.Avatar.RenderedSizeFactor, data) } -// Prepare accepts a byte slice as input, validates it contains an image of an -// acceptable format, and crops and resizes it appropriately. -func Prepare(data []byte) (*image.Image, error) { - imgCfg, _, err := image.DecodeConfig(bytes.NewReader(data)) +// processAvatarImage process the avatar image data, crop and resize it if necessary. +// the returned data could be the original image if no processing is needed. +func processAvatarImage(data []byte, maxOriginSize int64) ([]byte, error) { + imgCfg, imgType, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { - return nil, fmt.Errorf("DecodeConfig: %w", err) + return nil, fmt.Errorf("image.DecodeConfig: %w", err) } + + // for safety, only accept known types explicitly + if imgType != "png" && imgType != "jpeg" && imgType != "gif" && imgType != "webp" { + return nil, errors.New("unsupported avatar image type") + } + + // do not process image which is too large, it would consume too much memory if imgCfg.Width > setting.Avatar.MaxWidth { - return nil, fmt.Errorf("Image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth) + return nil, fmt.Errorf("image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth) } if imgCfg.Height > setting.Avatar.MaxHeight { - return nil, fmt.Errorf("Image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight) + return nil, fmt.Errorf("image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight) + } + + // If the origin is small enough, just use it, then APNG could be supported, + // otherwise, if the image is processed later, APNG loses animation. + // And one more thing, webp is not fully supported, for animated webp, image.DecodeConfig works but Decode fails. + // So for animated webp, if the uploaded file is smaller than maxOriginSize, it will be used, if it's larger, there will be an error. + if len(data) < int(maxOriginSize) { + return data, nil } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { - return nil, fmt.Errorf("Decode: %w", err) + return nil, fmt.Errorf("image.Decode: %w", err) } + // try to crop and resize the origin image if necessary if imgCfg.Width != imgCfg.Height { var newSize, ax, ay int if imgCfg.Width > imgCfg.Height { @@ -74,13 +94,33 @@ func Prepare(data []byte) (*image.Image, error) { img, err = cutter.Crop(img, cutter.Config{ Width: newSize, Height: newSize, - Anchor: image.Point{ax, ay}, + Anchor: image.Point{X: ax, Y: ay}, }) if err != nil { return nil, err } } - img = resize.Resize(AvatarSize, AvatarSize, img, resize.Bilinear) - return &img, nil + targetSize := uint(DefaultAvatarSize * setting.Avatar.RenderedSizeFactor) + img = resize.Resize(targetSize, targetSize, img, resize.Bilinear) + + // try to encode the cropped/resized image to png + bs := bytes.Buffer{} + if err = png.Encode(&bs, img); err != nil { + return nil, err + } + resized := bs.Bytes() + + // usually the png compression is not good enough, use the original image (no cropping/resizing) if the origin is smaller + if len(data) <= len(resized) { + return data, nil + } + + return resized, nil +} + +// ProcessAvatarImage process the avatar image data, crop and resize it if necessary. +// the returned data could be the original image if no processing is needed. +func ProcessAvatarImage(data []byte) ([]byte, error) { + return processAvatarImage(data, setting.Avatar.MaxOriginSize) } diff --git a/modules/avatar/avatar_test.go b/modules/avatar/avatar_test.go index 5ef4ed379b..a721c77868 100644 --- a/modules/avatar/avatar_test.go +++ b/modules/avatar/avatar_test.go @@ -4,6 +4,9 @@ package avatar import ( + "bytes" + "image" + "image/png" "os" "testing" @@ -25,49 +28,109 @@ func Test_RandomImage(t *testing.T) { assert.NoError(t, err) } -func Test_PrepareWithPNG(t *testing.T) { +func Test_ProcessAvatarPNG(t *testing.T) { setting.Avatar.MaxWidth = 4096 setting.Avatar.MaxHeight = 4096 data, err := os.ReadFile("testdata/avatar.png") assert.NoError(t, err) - imgPtr, err := Prepare(data) + _, err = processAvatarImage(data, 262144) assert.NoError(t, err) - - assert.Equal(t, 290, (*imgPtr).Bounds().Max.X) - assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y) } -func Test_PrepareWithJPEG(t *testing.T) { +func Test_ProcessAvatarJPEG(t *testing.T) { setting.Avatar.MaxWidth = 4096 setting.Avatar.MaxHeight = 4096 data, err := os.ReadFile("testdata/avatar.jpeg") assert.NoError(t, err) - imgPtr, err := Prepare(data) + _, err = processAvatarImage(data, 262144) assert.NoError(t, err) - - assert.Equal(t, 290, (*imgPtr).Bounds().Max.X) - assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y) } -func Test_PrepareWithInvalidImage(t *testing.T) { +func Test_ProcessAvatarInvalidData(t *testing.T) { setting.Avatar.MaxWidth = 5 setting.Avatar.MaxHeight = 5 - _, err := Prepare([]byte{}) - assert.EqualError(t, err, "DecodeConfig: image: unknown format") + _, err := processAvatarImage([]byte{}, 12800) + assert.EqualError(t, err, "image.DecodeConfig: image: unknown format") } -func Test_PrepareWithInvalidImageSize(t *testing.T) { +func Test_ProcessAvatarInvalidImageSize(t *testing.T) { setting.Avatar.MaxWidth = 5 setting.Avatar.MaxHeight = 5 data, err := os.ReadFile("testdata/avatar.png") assert.NoError(t, err) - _, err = Prepare(data) - assert.EqualError(t, err, "Image width is too large: 10 > 5") + _, err = processAvatarImage(data, 12800) + assert.EqualError(t, err, "image width is too large: 10 > 5") +} + +func Test_ProcessAvatarImage(t *testing.T) { + setting.Avatar.MaxWidth = 4096 + setting.Avatar.MaxHeight = 4096 + scaledSize := DefaultAvatarSize * setting.Avatar.RenderedSizeFactor + + newImgData := func(size int, optHeight ...int) []byte { + width := size + height := size + if len(optHeight) == 1 { + height = optHeight[0] + } + img := image.NewRGBA(image.Rect(0, 0, width, height)) + bs := bytes.Buffer{} + err := png.Encode(&bs, img) + assert.NoError(t, err) + return bs.Bytes() + } + + // if origin image canvas is too large, crop and resize it + origin := newImgData(500, 600) + result, err := processAvatarImage(origin, 0) + assert.NoError(t, err) + assert.NotEqual(t, origin, result) + decoded, err := png.Decode(bytes.NewReader(result)) + assert.NoError(t, err) + assert.EqualValues(t, scaledSize, decoded.Bounds().Max.X) + assert.EqualValues(t, scaledSize, decoded.Bounds().Max.Y) + + // if origin image is smaller than the default size, use the origin image + origin = newImgData(1) + result, err = processAvatarImage(origin, 0) + assert.NoError(t, err) + assert.Equal(t, origin, result) + + // use the origin image if the origin is smaller + origin = newImgData(scaledSize + 100) + result, err = processAvatarImage(origin, 0) + assert.NoError(t, err) + assert.Less(t, len(result), len(origin)) + + // still use the origin image if the origin doesn't exceed the max-origin-size + origin = newImgData(scaledSize + 100) + result, err = processAvatarImage(origin, 262144) + assert.NoError(t, err) + assert.Equal(t, origin, result) + + // allow to use known image format (eg: webp) if it is small enough + origin, err = os.ReadFile("testdata/animated.webp") + assert.NoError(t, err) + result, err = processAvatarImage(origin, 262144) + assert.NoError(t, err) + assert.Equal(t, origin, result) + + // do not support unknown image formats, eg: SVG may contain embedded JS + origin = []byte("") + _, err = processAvatarImage(origin, 262144) + assert.ErrorContains(t, err, "image: unknown format") + + // make sure the canvas size limit works + setting.Avatar.MaxWidth = 5 + setting.Avatar.MaxHeight = 5 + origin = newImgData(10) + _, err = processAvatarImage(origin, 262144) + assert.ErrorContains(t, err, "image width is too large: 10 > 5") } diff --git a/modules/avatar/testdata/animated.webp b/modules/avatar/testdata/animated.webp new file mode 100644 index 0000000000000000000000000000000000000000..4c05f4695cb66da0d0aa119e31ff721fb212c391 GIT binary patch literal 4934 zcmchacTg1Dw#It^$%tfuL85?U1ONIg*oP8Ipp6 zjN}ZGljJm{={G3%+M(+&_cZNUV`g?@4RNudA%b(^4MRJ5Go!WflzP;SO>R2v<1zs-d0uf-~ktqUr! zGKO$IskQJ1d2O_V(hO;gpSkcQUAOD2AMb{ns1#C*i+R7%=<;5sTC`pa0#oL!zqb&4hT z)x1eFtL-yI+jRYc!w&0m%+=HxD~VR?rMjimgS|8{0SA7tdmxkp$jMCR`rOD!3#23A zuH~XVyfc;I9)n^YjqhvyTCF-sQ!*PGu=_m^eJ6)7FBlr2_jMJbz53|v2^%P+Ld?g$ zhKLPJJ)uIAlTfC;?_6#y@b5TL9xgU7xnZY4CyIoqE?fCwN9qYqcWD|j#fn0jZ#K};Y+ zUc2zm#ojv;vU@)v)hWamwYnBQahI)GZkwC+#j7hd-GMtfUT10%rgLto`pH@*9pRa3 zz9ws2CW!-tsUrSs@goNp$#`pB&BXwCU*_>?-gvb-gQ8lmh~m*)7pj30I|T|PvW+r4 z@7z2Dj}PEugzS($%@1(PfxTce=O}g;CBN6M_DHKRa5{>y<5E~%Ub2Mmmc+7~3HZ4JET zF`}=>jt{n2RhxPqnSrO5)=1wRq%jDn@MFK!*EFZ3)ydV+QXbD$9CrEs1t#P&0WdQe`(8o7eR%A&zQxu>sS;Dun#%tM`h#b>JCLX4TgbIdU zrsRsZ%rU45gDl4c&htfgX$ChC=`%eJZ}B*)Zd5UNwk9|?sO)t-32HT(h>TlrhVg1+g%3a#|5h({K&pyQaQY1Vm*^k*_NcztW?TM&ET6- zIoDr`AvzavyA8*-q=6#R^D36$2IJGUu#0b?zLO?qsAE2A)E;t$$2F;7(z=<Sb;e=+KsF_6psW;U zhim#9YBeV|(KA9Yi`nuQ7m7Ml6K+guNuSvq zT*^c6(uH-Ve%UycEcVQFm(>0c z-g%+=*R4~q6q~)_i)zOTOq^yY`3`!)cPHFM-M{VA-VKm>!NIIFMSG;)!}Ovz?~2}L zKo*YInKOUEF~PyK0sx_Z@Wid@e>k(HNJp*=A+`V#zJv@s6|G%=i>$*y&J-Mqi~r-x z!hd`@Odg*_>|DVzI(x*3lx4!qJ{#2|As4A#PeJdbPy6Zt7r+_ZMY;6QVS01leP7KdSE$7%s-` z#y_i*%PYT^KE!!+1@YOe5XNdMIL(?%G*fs7j1rO!pA04@r_PPHmOWwhrNNBK+|>}} z*bN@cg1GRv2n~hM_bVtX0z+J^dQ&u7Xrnkl$=-QNm5r}>{* z7ItUH`HNE^joVY{#mN{`to01Jx&Qp4I5YO4rrSI3Mg)a_k$KYO#V?#zhkBRi@{9~g zC~HzDm2J-C3DP&Su;ayahzc!rSS-$-cE_H`j$xjvW0zx)sCDol8cacT)xYrJPHRd^ zoBO2PZE^h`A7aOgU#rF}HlsG0tPL-UDp{|IAwl+K7{JW60cKu#Ns|G;nQ5FO=1wGs z$%Tq*i42=$w+W35l2KlM(xVcQp6b&{db(b9Ej@YHjqhoJ@P?VYsBr7MFZ0F$78*#4 zy&~@zMA8q&L}jOvKq!x7UR`3q)eB{4Yo6MI4O`YRu5Spo(;-!VGy};*-@w|M#p`Kr zZVd+=y_|xyu!{k+UPLhN1-OPip45c|eJZXPvoiiUz-z}==$P#~$8{(1cVk-(dKD4( zYI!cx6WR>oe-}Z$w8JkC64vG{r8OfPyg*+Ks@-L{u_jgb^nhV>Tt<%11xA z%pw&fZoYb;mv$H~C!rH~=Dxx13&dM!Udr3qz1wq_5TedtrlumO+s^-yLS=(zm;I0& zNeSNo`%p~dy(yPUsuVC$>LZ_bA4I^KUoU?QSz;uieS3qqj4TQjK=D;VcV9<4F*@L> z&QX3oUchBUm#wdZ7fA_6$;%JVrSDL5_}g;j%N|UUVGTM4s%w<-!KjehHAWV%hfX## z=p?S@H`l8}bcZK~{0LjoP57znnHV*sMsrYUx!^>Rq2!P2T>ZJ01>+|}0x@Dw3Ep?G zPdPfJW??NRk6Dvu-B*P0yp!3f#(avCiYwfyC2aM1s-oUrdmZ|$y9Gb?V&zaY;y!O2 zy}E-ujO5L-a>43k)f>a|F|ln``tqytuG$s_Gp|)};v&6LzY<`Dxkd8=N^05m z7b&dg3Znm7&+y@h|MDGqkL=Q%q4QKqHnoo>3a_LX9UZibvT=>w)M@%LjLrndjB_mJ z5jpp>rR;CciJPHL*kpOyIK%AqNb_v58{mWaM1aF!O~8t0Y{Mvl!d|+P3h>=OaUxXNst!BV0yJL_nw$B3r^?~uH(b@0J-pN@ zptwG4^z+HgJh{zkj*PH*$|QK~mVr9{CevVi;uM^U2IYN)L1NuEtp=~|5z@sM(#u)? zN-W-^{i*Ub^BrxNk4szOozG7e@G=zqBN!yHS?83`+!!ZR&)>BYF1`M%>tXwwa*Hk( zGgEjLQkr5Pq+-hfAqy%)R`uKbQTBhkG3i-#EQo8GWMS0dxOz$Myk5ffJVd)0%CS1z zrcc#sXn|0R*^=drgK?%)fOgbn^H_JxV`Xyk)Q@@Lo!rqtCfs=tZQG;S9+c<+C&`W>unP!uEvR4PJz?PqGnvt|+D3mr;(oAZ)J&z@#K@-Ykz9^ruo&CvirqW zUpq(`Ro?3^N##g8RhI3G1^Fq|mg{d$osjZOg^eQKdNvgn24<8_49ms`l|`n-^a;M9 z-y&Nv*5CPo$Fk&43b9cLsE6%q0*BoOVQ!x6cfJ+*H({oK6UOzmmL^NsYP_@3Y5nR>@hAKSm6Y0itA^MAS3Pj_dI8vPa=0AbuDHM`*@CSbR+$02~z%^t5bGc_1bH3u@MVqk_co zAxHHIcyu=cps^Znpw15t1iMTS$mWJ;_!%U!OzG5OZ?k^rs|ylwHuw5t)9}lR45HJr~I?{I@g%yinw9Wb8Fb zx4sk8TYRrP0wRt|Fw&1lptrJrRLZIIlMp{2u0W!DMXhxCMuJ%JDxvCr+06mpCB|7f WX6FrQg&F20yInV5F>lx6Sp65!k@2$t literal 0 HcmV?d00001 diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go index a407083f3a..b6ad967d4c 100644 --- a/modules/repository/commits_test.go +++ b/modules/repository/commits_test.go @@ -6,6 +6,7 @@ package repository import ( "crypto/md5" "fmt" + "strconv" "testing" "time" @@ -136,13 +137,11 @@ func TestPushCommits_AvatarLink(t *testing.T) { enableGravatar(t) assert.Equal(t, - "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s=84", + "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s="+strconv.Itoa(28*setting.Avatar.RenderedSizeFactor), pushCommits.AvatarLink(db.DefaultContext, "user2@example.com")) assert.Equal(t, - "https://secure.gravatar.com/avatar/"+ - fmt.Sprintf("%x", md5.Sum([]byte("nonexistent@example.com")))+ - "?d=identicon&s=84", + fmt.Sprintf("https://secure.gravatar.com/avatar/%x?d=identicon&s=%d", md5.Sum([]byte("nonexistent@example.com")), 28*setting.Avatar.RenderedSizeFactor), pushCommits.AvatarLink(db.DefaultContext, "nonexistent@example.com")) } diff --git a/modules/setting/picture.go b/modules/setting/picture.go index 6d7c8b33ce..64d9a608e6 100644 --- a/modules/setting/picture.go +++ b/modules/setting/picture.go @@ -3,21 +3,23 @@ package setting -// settings +// Avatar settings + var ( - // Picture settings Avatar = struct { Storage MaxWidth int MaxHeight int MaxFileSize int64 + MaxOriginSize int64 RenderedSizeFactor int }{ MaxWidth: 4096, - MaxHeight: 3072, + MaxHeight: 4096, MaxFileSize: 1048576, - RenderedSizeFactor: 3, + MaxOriginSize: 262144, + RenderedSizeFactor: 2, } GravatarSource string @@ -44,9 +46,10 @@ func loadPictureFrom(rootCfg ConfigProvider) { Avatar.Storage = getStorage(rootCfg, "avatars", storageType, avatarSec) Avatar.MaxWidth = sec.Key("AVATAR_MAX_WIDTH").MustInt(4096) - Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(3072) + Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(4096) Avatar.MaxFileSize = sec.Key("AVATAR_MAX_FILE_SIZE").MustInt64(1048576) - Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(3) + Avatar.MaxOriginSize = sec.Key("AVATAR_MAX_ORIGIN_SIZE").MustInt64(262144) + Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(2) switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source { case "duoshuo": @@ -94,5 +97,5 @@ func loadRepoAvatarFrom(rootCfg ConfigProvider) { RepoAvatar.Storage = getStorage(rootCfg, "repo-avatars", storageType, repoAvatarSec) RepoAvatar.Fallback = sec.Key("REPOSITORY_AVATAR_FALLBACK").MustString("none") - RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString("/assets/img/repo_default.png") + RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString(AppSubURL + "/assets/img/repo_default.png") } diff --git a/services/repository/avatar.go b/services/repository/avatar.go index 74e5de877e..38c2621bc4 100644 --- a/services/repository/avatar.go +++ b/services/repository/avatar.go @@ -6,7 +6,6 @@ package repository import ( "context" "fmt" - "image/png" "io" "strconv" "strings" @@ -21,7 +20,7 @@ import ( // UploadAvatar saves custom avatar for repository. // FIXME: split uploads to different subdirs in case we have massive number of repos. func UploadAvatar(ctx context.Context, repo *repo_model.Repository, data []byte) error { - m, err := avatar.Prepare(data) + avatarData, err := avatar.ProcessAvatarImage(data) if err != nil { return err } @@ -47,9 +46,7 @@ func UploadAvatar(ctx context.Context, repo *repo_model.Repository, data []byte) } if err := storage.SaveFrom(storage.RepoAvatars, repo.CustomAvatarRelativePath(), func(w io.Writer) error { - if err := png.Encode(w, *m); err != nil { - log.Error("Encode: %v", err) - } + _, err := w.Write(avatarData) return err }); err != nil { return fmt.Errorf("UploadAvatar %s failed: Failed to remove old repo avatar %s: %w", repo.RepoPath(), newAvatar, err) diff --git a/services/user/user.go b/services/user/user.go index d52a2f404b..5148f2168d 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -6,7 +6,6 @@ package user import ( "context" "fmt" - "image/png" "io" "time" @@ -244,7 +243,7 @@ func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error { // UploadAvatar saves custom avatar for user. func UploadAvatar(u *user_model.User, data []byte) error { - m, err := avatar.Prepare(data) + avatarData, err := avatar.ProcessAvatarImage(data) if err != nil { return err } @@ -262,9 +261,7 @@ func UploadAvatar(u *user_model.User, data []byte) error { } if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error { - if err := png.Encode(w, *m); err != nil { - log.Error("Encode: %v", err) - } + _, err := w.Write(avatarData) return err }); err != nil { return fmt.Errorf("Failed to create dir %s: %w", u.CustomAvatarRelativePath(), err) diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index 08e50fd0ce..8d172a7d02 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -7,11 +7,12 @@ diff --git a/web_src/css/base.css b/web_src/css/base.css index 507d92b011..6c1bbb00c4 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -1046,62 +1046,6 @@ a.label, box-shadow: -1px -1px 0 0 var(--color-secondary); } -.ui.cards > .card, -.ui.card { - background: var(--color-card); - border: 1px solid var(--color-secondary); - box-shadow: none; -} - -.ui.cards > .card > .content, -.ui.card > .content { - border-color: var(--color-secondary); -} - -.ui.cards > .card > .extra, -.ui.card > .extra, -.ui.cards > .card > .extra a:not(.ui), -.ui.card > .extra a:not(.ui) { - color: var(--color-text); -} - -.ui.cards > .card > .extra a:not(.ui):hover, -.ui.card > .extra a:not(.ui):hover { - color: var(--color-primary); -} - -.ui.cards > .card > .content > .header, -.ui.card > .content > .header { - color: var(--color-text); -} - -.ui.cards > .card > .content > .description, -.ui.card > .content > .description { - color: var(--color-text); -} - -.ui.cards > .card .meta > a:not(.ui), -.ui.card .meta > a:not(.ui) { - color: var(--color-text-light-2); -} - -.ui.cards > .card .meta > a:not(.ui):hover, -.ui.card .meta > a:not(.ui):hover { - color: var(--color-text); -} - -.ui.cards a.card:hover, -a.ui.card:hover { - border: 1px solid var(--color-secondary); - background: var(--color-card); -} - -.ui.cards > .card > .extra, -.ui.card > .extra { - color: var(--color-text); - border-top-color: var(--color-secondary-light-1) !important; -} - .ui.comments .comment .text { margin: 0; } @@ -1183,12 +1127,10 @@ a.ui.card:hover { img.ui.avatar, .ui.avatar img, -.ui.avatar svg, -.ui.cards > .card img.avatar, -.ui.cards > .card .avatar img, -.ui.card img.avatar, -.ui.card .avatar img { +.ui.avatar svg { border-radius: var(--border-radius); + object-fit: contain; + aspect-ratio: 1; } .ui.divided.list > .item { diff --git a/web_src/css/index.css b/web_src/css/index.css index 6fb92f2ecb..1723de3a2d 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -10,6 +10,7 @@ @import "./modules/tippy.css"; @import "./modules/modal.css"; @import "./modules/breadcrumb.css"; +@import "./modules/card.css"; @import "./code/linebutton.css"; @import "./markup/content.css"; @import "./markup/codecopy.css"; diff --git a/web_src/css/modules/card.css b/web_src/css/modules/card.css new file mode 100644 index 0000000000..c0f7e83037 --- /dev/null +++ b/web_src/css/modules/card.css @@ -0,0 +1,134 @@ +/* Below styles are a subset of the full fomantic card styles which are */ +/* needed to get all current uses of fomantic cards working. */ +/* TODO: remove all these styles and use custom styling instead */ + +.ui.card:last-child { + margin-bottom: 0; +} +.ui.card:first-child { + margin-top: 0; +} + +.ui.cards > .card, +.ui.card { + display: flex; + flex-direction: column; + max-width: 100%; + width: 290px; + min-height: 0; + padding: 0; + background: var(--color-card); + border: 1px solid var(--color-secondary); + box-shadow: none; + word-wrap: break-word; +} + +.ui.card { + margin: 1em 0; +} + +.ui.cards { + display: flex; + margin: -0.875em -0.5em; + flex-wrap: wrap; +} + +.ui.cards > .card { + display: flex; + margin: 0.875em 0.5em; + float: none; +} + +.ui.cards > .card > .content, +.ui.card > .content { + border-top: 1px solid var(--color-secondary); + max-width: 100%; + padding: 1em; + font-size: 1em; +} + +.ui.cards > .card > .content > .meta + .description, +.ui.cards > .card > .content > .header + .description, +.ui.card > .content > .meta + .description, +.ui.card > .content > .header + .description { + margin-top: .5em; +} + +.ui.cards > .card > .content > .header:not(.ui), +.ui.card > .content > .header:not(.ui) { + font-weight: 500; + font-size: 1.28571429em; + margin-top: -.21425em; + line-height: 1.28571429em; +} + +.ui.cards > .card > .content:first-child, +.ui.card > .content:first-child { + border-top: none; + border-radius: var(--border-radius) var(--border-radius) 0 0; +} + +.ui.cards > .card > :last-child, +.ui.card > :last-child { + border-radius: 0 0 var(--border-radius) var(--border-radius); +} + +.ui.cards > .card > :only-child, +.ui.card > :only-child { + border-radius: var(--border-radius) !important; +} + +.ui.cards > .card > .extra, +.ui.card > .extra, +.ui.cards > .card > .extra a:not(.ui), +.ui.card > .extra a:not(.ui) { + color: var(--color-text); +} + +.ui.cards > .card > .extra a:not(.ui):hover, +.ui.card > .extra a:not(.ui):hover { + color: var(--color-primary); +} + +.ui.cards > .card > .content > .header, +.ui.card > .content > .header { + color: var(--color-text); +} + +.ui.cards > .card > .content > .description, +.ui.card > .content > .description { + color: var(--color-text); +} + +.ui.cards > .card .meta > a:not(.ui), +.ui.card .meta > a:not(.ui) { + color: var(--color-text-light-2); +} + +.ui.cards > .card .meta > a:not(.ui):hover, +.ui.card .meta > a:not(.ui):hover { + color: var(--color-text); +} + +.ui.cards a.card:hover, +a.ui.card:hover { + border: 1px solid var(--color-secondary); + background: var(--color-card); +} + +.ui.cards > .card > .extra, +.ui.card > .extra { + color: var(--color-text); + border-top-color: var(--color-secondary-light-1) !important; +} + +.ui.three.cards { + margin-left: -1em; + margin-right: -1em; +} + +.ui.three.cards > .card { + width: calc(33.33333333333333% - 2em); + margin-left: 1em; + margin-right: 1em; +} diff --git a/web_src/css/user.css b/web_src/css/user.css index 0a8b49b038..648480d71d 100644 --- a/web_src/css/user.css +++ b/web_src/css/user.css @@ -39,16 +39,13 @@ } .user.profile .ui.card #profile-avatar { - background: none; padding: 1rem 1rem 0.25rem; justify-content: center; } .user.profile .ui.card #profile-avatar img { - width: 100%; + max-width: 100%; height: auto; - object-fit: contain; - margin: 0; } @media (max-width: 767px) { diff --git a/web_src/fomantic/build/semantic.css b/web_src/fomantic/build/semantic.css index f48201b46a..33d5311014 100644 --- a/web_src/fomantic/build/semantic.css +++ b/web_src/fomantic/build/semantic.css @@ -4623,1384 +4623,6 @@ /******************************* Site Overrides *******************************/ -/*! - * # Fomantic-UI - Card - * http://github.com/fomantic/Fomantic-UI/ - * - * - * Released under the MIT license - * http://opensource.org/licenses/MIT - * - */ - -/******************************* - Standard -*******************************/ - -/*-------------- - Card ----------------*/ - -.ui.cards > .card, -.ui.card { - max-width: 100%; - position: relative; - display: flex; - flex-direction: column; - width: 290px; - min-height: 0; - background: #FFFFFF; - padding: 0; - border: none; - border-radius: 0.28571429rem; - box-shadow: 0 1px 3px 0 #D4D4D5, 0 0 0 1px #D4D4D5; - transition: box-shadow 0.1s ease, transform 0.1s ease; - z-index: ''; - word-wrap: break-word; -} - -.ui.card { - margin: 1em 0; -} - -.ui.cards > .card a, -.ui.card a { - cursor: pointer; -} - -.ui.card:first-child { - margin-top: 0; -} - -.ui.card:last-child { - margin-bottom: 0; -} - -/*-------------- - Cards ----------------*/ - -.ui.cards { - display: flex; - margin: -0.875em -0.5em; - flex-wrap: wrap; -} - -.ui.cards > .card { - display: flex; - margin: 0.875em 0.5em; - float: none; -} - -/* Clearing */ - -.ui.cards:after, -.ui.card:after { - display: block; - content: ' '; - height: 0; - clear: both; - overflow: hidden; - visibility: hidden; -} - -/* Consecutive Card Groups Preserve Row Spacing */ - -.ui.cards ~ .ui.cards { - margin-top: 0.875em; -} - -/*-------------- - Rounded Edges ----------------*/ - -.ui.cards > .card > :first-child, -.ui.card > :first-child { - border-radius: 0.28571429rem 0.28571429rem 0 0 !important; - border-top: none !important; -} - -.ui.cards > .card > :last-child, -.ui.card > :last-child { - border-radius: 0 0 0.28571429rem 0.28571429rem !important; -} - -.ui.cards > .card > :only-child, -.ui.card > :only-child { - border-radius: 0.28571429rem !important; -} - -/*-------------- - Images ----------------*/ - -.ui.cards > .card > .image, -.ui.card > .image { - position: relative; - display: block; - flex: 0 0 auto; - padding: 0; - background: rgba(0, 0, 0, 0.05); -} - -.ui.cards > .card > .image > img, -.ui.card > .image > img { - display: block; - width: 100%; - height: auto; - border-radius: inherit; -} - -.ui.cards > .card > .image:not(.ui) > img, -.ui.card > .image:not(.ui) > img { - border: none; -} - -/*-------------- - Content ----------------*/ - -.ui.cards > .card > .content, -.ui.card > .content { - flex-grow: 1; - border: none; - border-top: 1px solid rgba(34, 36, 38, 0.1); - background: none; - margin: 0; - padding: 1em 1em; - box-shadow: none; - font-size: 1em; - border-radius: 0; -} - -.ui.cards > .card > .content:after, -.ui.card > .content:after { - display: block; - content: ' '; - height: 0; - clear: both; - overflow: hidden; - visibility: hidden; -} - -.ui.cards > .card > .content > .header, -.ui.card > .content > .header { - display: block; - margin: ''; - font-family: var(--fonts-regular); - color: rgba(0, 0, 0, 0.85); -} - -/* Default Header Size */ - -.ui.cards > .card > .content > .header:not(.ui), -.ui.card > .content > .header:not(.ui) { - font-weight: 500; - font-size: 1.28571429em; - margin-top: -0.21425em; - line-height: 1.28571429em; -} - -.ui.cards > .card > .content > .meta + .description, -.ui.cards > .card > .content > .header + .description, -.ui.card > .content > .meta + .description, -.ui.card > .content > .header + .description { - margin-top: 0.5em; -} - -/*---------------- - Floated Content ------------------*/ - -.ui.cards > .card [class*="left floated"], -.ui.card [class*="left floated"] { - float: left; -} - -.ui.cards > .card [class*="right floated"], -.ui.card [class*="right floated"] { - float: right; -} - -/*-------------- - Aligned ----------------*/ - -.ui.cards > .card [class*="left aligned"], -.ui.card [class*="left aligned"] { - text-align: left; -} - -.ui.cards > .card [class*="center aligned"], -.ui.card [class*="center aligned"] { - text-align: center; -} - -.ui.cards > .card [class*="right aligned"], -.ui.card [class*="right aligned"] { - text-align: right; -} - -/*-------------- - Content Image ----------------*/ - -.ui.cards > .card .content img, -.ui.card .content img { - display: inline-block; - vertical-align: middle; - width: ''; -} - -.ui.cards > .card img.avatar, -.ui.cards > .card .avatar img, -.ui.card img.avatar, -.ui.card .avatar img { - width: 2em; - height: 2em; - border-radius: 500rem; -} - -/*-------------- - Description ----------------*/ - -.ui.cards > .card > .content > .description, -.ui.card > .content > .description { - clear: both; - color: rgba(0, 0, 0, 0.68); -} - -/*-------------- - Paragraph ----------------*/ - -.ui.cards > .card > .content p, -.ui.card > .content p { - margin: 0 0 0.5em; -} - -.ui.cards > .card > .content p:last-child, -.ui.card > .content p:last-child { - margin-bottom: 0; -} - -/*-------------- - Meta ----------------*/ - -.ui.cards > .card .meta, -.ui.card .meta { - font-size: 1em; - color: rgba(0, 0, 0, 0.4); -} - -.ui.cards > .card .meta *, -.ui.card .meta * { - margin-right: 0.3em; -} - -.ui.cards > .card .meta :last-child, -.ui.card .meta :last-child { - margin-right: 0; -} - -.ui.cards > .card .meta [class*="right floated"], -.ui.card .meta [class*="right floated"] { - margin-right: 0; - margin-left: 0.3em; -} - -/*-------------- - Links ----------------*/ - -/* Generic */ - -.ui.cards > .card > .content a:not(.ui), -.ui.card > .content a:not(.ui) { - color: ''; - transition: color 0.1s ease; -} - -.ui.cards > .card > .content a:not(.ui):hover, -.ui.card > .content a:not(.ui):hover { - color: ''; -} - -/* Header */ - -.ui.cards > .card > .content > a.header, -.ui.card > .content > a.header { - color: rgba(0, 0, 0, 0.85); -} - -.ui.cards > .card > .content > a.header:hover, -.ui.card > .content > a.header:hover { - color: #1e70bf; -} - -/* Meta */ - -.ui.cards > .card .meta > a:not(.ui), -.ui.card .meta > a:not(.ui) { - color: rgba(0, 0, 0, 0.4); -} - -.ui.cards > .card .meta > a:not(.ui):hover, -.ui.card .meta > a:not(.ui):hover { - color: rgba(0, 0, 0, 0.87); -} - -/*-------------- - Buttons ----------------*/ - -.ui.cards > .card > .buttons, -.ui.card > .buttons, -.ui.cards > .card > .button, -.ui.card > .button { - margin: 0 -1px; - width: calc(100% + 2px); -} - -.ui.cards > .card > .buttons:last-child, -.ui.card > .buttons:last-child, -.ui.cards > .card > .button:last-child, -.ui.card > .button:last-child { - margin-bottom: -1px; -} - -/*-------------- - Dimmer ----------------*/ - -.ui.cards > .card .dimmer, -.ui.card .dimmer { - background: ''; - z-index: 10; -} - -/*-------------- - Labels ----------------*/ - -/*-----Star----- */ - -/* Icon */ - -.ui.cards > .card > .content .star.icon, -.ui.card > .content .star.icon { - cursor: pointer; - opacity: 0.75; - transition: color 0.1s ease; -} - -.ui.cards > .card > .content .star.icon:hover, -.ui.card > .content .star.icon:hover { - opacity: 1; - color: #FFB70A; -} - -.ui.cards > .card > .content .active.star.icon, -.ui.card > .content .active.star.icon { - color: #FFE623; -} - -/*-----Like----- */ - -/* Icon */ - -.ui.cards > .card > .content .like.icon, -.ui.card > .content .like.icon { - cursor: pointer; - opacity: 0.75; - transition: color 0.1s ease; -} - -.ui.cards > .card > .content .like.icon:hover, -.ui.card > .content .like.icon:hover { - opacity: 1; - color: #FF2733; -} - -.ui.cards > .card > .content .active.like.icon, -.ui.card > .content .active.like.icon { - color: #FF2733; -} - -/*---------------- - Extra Content ------------------*/ - -.ui.cards > .card > .extra, -.ui.card > .extra { - max-width: 100%; - min-height: 0 !important; - flex-grow: 0; - border-top: 1px solid rgba(0, 0, 0, 0.05) !important; - position: static; - background: none; - width: auto; - margin: 0 0; - padding: 0.75em 1em; - top: 0; - left: 0; - color: rgba(0, 0, 0, 0.4); - box-shadow: none; - transition: color 0.1s ease; -} - -.ui.cards > .card > .extra a:not(.ui), -.ui.card > .extra a:not(.ui) { - color: rgba(0, 0, 0, 0.4); -} - -.ui.cards > .card > .extra a:not(.ui):hover, -.ui.card > .extra a:not(.ui):hover { - color: #1e70bf; -} - -/******************************* - Variations -*******************************/ - -/*------------------- - Horizontal - --------------------*/ - -.ui.horizontal.cards > .card, -.ui.card.horizontal { - flex-direction: row; - flex-wrap: wrap; - min-width: 270px; - width: 400px; - max-width: 100%; -} - -.ui.horizontal.cards > .card > .image, -.ui.card.horizontal > .image { - border-radius: 0.28571429rem 0 0 0.28571429rem; - width: 150px; -} - -.ui.horizontal.cards > .card > .image > img, -.ui.card.horizontal > .image > img { - background-size: cover; - background-repeat: no-repeat; - background-position: center; - justify-content: center; - align-items: center; - display: flex; - width: 100%; - height: 100%; - border-radius: 0.28571429rem 0 0 0.28571429rem; -} - -.ui.horizontal.cards > .card > .image:last-child > img, -.ui.card.horizontal > .image:last-child > img { - border-radius: 0 0.28571429rem 0.28571429rem 0; -} - -.ui.horizontal.cards > .card > .content, -.ui.horizontal.card > .content { - flex-basis: 1px; -} - -.ui.horizontal.cards > .card > .extra, -.ui.horizontal.card > .extra { - flex-basis: 100%; -} - -/*------------------- - Raised - --------------------*/ - -.ui.raised.cards > .card, -.ui.raised.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 4px 0 rgba(34, 36, 38, 0.12), 0 2px 10px 0 rgba(34, 36, 38, 0.15); -} - -.ui.raised.cards a.card:hover, -.ui.link.cards .raised.card:hover, -a.ui.raised.card:hover, -.ui.link.raised.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 4px 0 rgba(34, 36, 38, 0.15), 0 2px 10px 0 rgba(34, 36, 38, 0.25); -} - -/*------------------- - Centered - --------------------*/ - -.ui.centered.cards { - justify-content: center; -} - -.ui.centered.card { - margin-left: auto; - margin-right: auto; -} - -/*------------------- - Fluid - --------------------*/ - -.ui.fluid.card { - width: 100%; - max-width: 9999px; -} - -/*------------------- - Link - --------------------*/ - -.ui.cards a.card, -.ui.link.cards .card, -a.ui.card, -.ui.link.card { - transform: none; -} - -.ui.cards a.card:hover, -.ui.link.cards .card:not(.icon):hover, -a.ui.card:hover, -.ui.link.card:hover { - cursor: pointer; - z-index: 5; - background: #FFFFFF; - border: none; - box-shadow: 0 1px 3px 0 #BCBDBD, 0 0 0 1px #D4D4D5; - transform: translateY(-3px); -} - -/*------------------- - Colors ---------------------*/ - -.ui.primary.cards > .card, -.ui.cards > .primary.card, -.ui.primary.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #2185D0, 0 1px 3px 0 #D4D4D5; -} - -.ui.primary.cards > .card:hover, -.ui.cards > .primary.card:hover, -.ui.primary.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #1678c2, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.primary.cards > .card, -.ui.inverted.cards > .primary.card, -.ui.inverted.primary.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #54C8FF, 0 0 0 1px #555555; -} - -.ui.inverted.primary.cards > .card:hover, -.ui.inverted.cards > .primary.card:hover, -.ui.inverted.primary.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #21b8ff, 0 0 0 1px #555555; -} - -.ui.secondary.cards > .card, -.ui.cards > .secondary.card, -.ui.secondary.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #1B1C1D, 0 1px 3px 0 #D4D4D5; -} - -.ui.secondary.cards > .card:hover, -.ui.cards > .secondary.card:hover, -.ui.secondary.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #27292a, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.secondary.cards > .card, -.ui.inverted.cards > .secondary.card, -.ui.inverted.secondary.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #545454, 0 0 0 1px #555555; -} - -.ui.inverted.secondary.cards > .card:hover, -.ui.inverted.cards > .secondary.card:hover, -.ui.inverted.secondary.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #6e6e6e, 0 0 0 1px #555555; -} - -.ui.red.cards > .card, -.ui.cards > .red.card, -.ui.red.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #DB2828, 0 1px 3px 0 #D4D4D5; -} - -.ui.red.cards > .card:hover, -.ui.cards > .red.card:hover, -.ui.red.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #d01919, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.red.cards > .card, -.ui.inverted.cards > .red.card, -.ui.inverted.red.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #FF695E, 0 0 0 1px #555555; -} - -.ui.inverted.red.cards > .card:hover, -.ui.inverted.cards > .red.card:hover, -.ui.inverted.red.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #ff392b, 0 0 0 1px #555555; -} - -.ui.orange.cards > .card, -.ui.cards > .orange.card, -.ui.orange.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #F2711C, 0 1px 3px 0 #D4D4D5; -} - -.ui.orange.cards > .card:hover, -.ui.cards > .orange.card:hover, -.ui.orange.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #f26202, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.orange.cards > .card, -.ui.inverted.cards > .orange.card, -.ui.inverted.orange.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #FF851B, 0 0 0 1px #555555; -} - -.ui.inverted.orange.cards > .card:hover, -.ui.inverted.cards > .orange.card:hover, -.ui.inverted.orange.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #e76b00, 0 0 0 1px #555555; -} - -.ui.yellow.cards > .card, -.ui.cards > .yellow.card, -.ui.yellow.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #FBBD08, 0 1px 3px 0 #D4D4D5; -} - -.ui.yellow.cards > .card:hover, -.ui.cards > .yellow.card:hover, -.ui.yellow.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #eaae00, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.yellow.cards > .card, -.ui.inverted.cards > .yellow.card, -.ui.inverted.yellow.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #FFE21F, 0 0 0 1px #555555; -} - -.ui.inverted.yellow.cards > .card:hover, -.ui.inverted.cards > .yellow.card:hover, -.ui.inverted.yellow.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #ebcd00, 0 0 0 1px #555555; -} - -.ui.olive.cards > .card, -.ui.cards > .olive.card, -.ui.olive.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #B5CC18, 0 1px 3px 0 #D4D4D5; -} - -.ui.olive.cards > .card:hover, -.ui.cards > .olive.card:hover, -.ui.olive.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #a7bd0d, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.olive.cards > .card, -.ui.inverted.cards > .olive.card, -.ui.inverted.olive.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #D9E778, 0 0 0 1px #555555; -} - -.ui.inverted.olive.cards > .card:hover, -.ui.inverted.cards > .olive.card:hover, -.ui.inverted.olive.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #d2e745, 0 0 0 1px #555555; -} - -.ui.green.cards > .card, -.ui.cards > .green.card, -.ui.green.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #21BA45, 0 1px 3px 0 #D4D4D5; -} - -.ui.green.cards > .card:hover, -.ui.cards > .green.card:hover, -.ui.green.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #16ab39, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.green.cards > .card, -.ui.inverted.cards > .green.card, -.ui.inverted.green.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #2ECC40, 0 0 0 1px #555555; -} - -.ui.inverted.green.cards > .card:hover, -.ui.inverted.cards > .green.card:hover, -.ui.inverted.green.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #1ea92e, 0 0 0 1px #555555; -} - -.ui.teal.cards > .card, -.ui.cards > .teal.card, -.ui.teal.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #00B5AD, 0 1px 3px 0 #D4D4D5; -} - -.ui.teal.cards > .card:hover, -.ui.cards > .teal.card:hover, -.ui.teal.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #009c95, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.teal.cards > .card, -.ui.inverted.cards > .teal.card, -.ui.inverted.teal.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #6DFFFF, 0 0 0 1px #555555; -} - -.ui.inverted.teal.cards > .card:hover, -.ui.inverted.cards > .teal.card:hover, -.ui.inverted.teal.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #3affff, 0 0 0 1px #555555; -} - -.ui.blue.cards > .card, -.ui.cards > .blue.card, -.ui.blue.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #2185D0, 0 1px 3px 0 #D4D4D5; -} - -.ui.blue.cards > .card:hover, -.ui.cards > .blue.card:hover, -.ui.blue.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #1678c2, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.blue.cards > .card, -.ui.inverted.cards > .blue.card, -.ui.inverted.blue.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #54C8FF, 0 0 0 1px #555555; -} - -.ui.inverted.blue.cards > .card:hover, -.ui.inverted.cards > .blue.card:hover, -.ui.inverted.blue.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #21b8ff, 0 0 0 1px #555555; -} - -.ui.violet.cards > .card, -.ui.cards > .violet.card, -.ui.violet.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #6435C9, 0 1px 3px 0 #D4D4D5; -} - -.ui.violet.cards > .card:hover, -.ui.cards > .violet.card:hover, -.ui.violet.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #5829bb, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.violet.cards > .card, -.ui.inverted.cards > .violet.card, -.ui.inverted.violet.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #A291FB, 0 0 0 1px #555555; -} - -.ui.inverted.violet.cards > .card:hover, -.ui.inverted.cards > .violet.card:hover, -.ui.inverted.violet.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #745aff, 0 0 0 1px #555555; -} - -.ui.purple.cards > .card, -.ui.cards > .purple.card, -.ui.purple.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #A333C8, 0 1px 3px 0 #D4D4D5; -} - -.ui.purple.cards > .card:hover, -.ui.cards > .purple.card:hover, -.ui.purple.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #9627ba, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.purple.cards > .card, -.ui.inverted.cards > .purple.card, -.ui.inverted.purple.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #DC73FF, 0 0 0 1px #555555; -} - -.ui.inverted.purple.cards > .card:hover, -.ui.inverted.cards > .purple.card:hover, -.ui.inverted.purple.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #cf40ff, 0 0 0 1px #555555; -} - -.ui.pink.cards > .card, -.ui.cards > .pink.card, -.ui.pink.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #E03997, 0 1px 3px 0 #D4D4D5; -} - -.ui.pink.cards > .card:hover, -.ui.cards > .pink.card:hover, -.ui.pink.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #e61a8d, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.pink.cards > .card, -.ui.inverted.cards > .pink.card, -.ui.inverted.pink.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #FF8EDF, 0 0 0 1px #555555; -} - -.ui.inverted.pink.cards > .card:hover, -.ui.inverted.cards > .pink.card:hover, -.ui.inverted.pink.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #ff5bd1, 0 0 0 1px #555555; -} - -.ui.brown.cards > .card, -.ui.cards > .brown.card, -.ui.brown.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #A5673F, 0 1px 3px 0 #D4D4D5; -} - -.ui.brown.cards > .card:hover, -.ui.cards > .brown.card:hover, -.ui.brown.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #975b33, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.brown.cards > .card, -.ui.inverted.cards > .brown.card, -.ui.inverted.brown.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #D67C1C, 0 0 0 1px #555555; -} - -.ui.inverted.brown.cards > .card:hover, -.ui.inverted.cards > .brown.card:hover, -.ui.inverted.brown.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #b0620f, 0 0 0 1px #555555; -} - -.ui.grey.cards > .card, -.ui.cards > .grey.card, -.ui.grey.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #767676, 0 1px 3px 0 #D4D4D5; -} - -.ui.grey.cards > .card:hover, -.ui.cards > .grey.card:hover, -.ui.grey.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #838383, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.grey.cards > .card, -.ui.inverted.cards > .grey.card, -.ui.inverted.grey.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #DCDDDE, 0 0 0 1px #555555; -} - -.ui.inverted.grey.cards > .card:hover, -.ui.inverted.cards > .grey.card:hover, -.ui.inverted.grey.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #c2c4c5, 0 0 0 1px #555555; -} - -.ui.black.cards > .card, -.ui.cards > .black.card, -.ui.black.card { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #1B1C1D, 0 1px 3px 0 #D4D4D5; -} - -.ui.black.cards > .card:hover, -.ui.cards > .black.card:hover, -.ui.black.card:hover { - box-shadow: 0 0 0 1px #D4D4D5, 0 2px 0 0 #27292a, 0 1px 3px 0 #BCBDBD; -} - -.ui.inverted.black.cards > .card, -.ui.inverted.cards > .black.card, -.ui.inverted.black.card { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #545454, 0 0 0 1px #555555; -} - -.ui.inverted.black.cards > .card:hover, -.ui.inverted.cards > .black.card:hover, -.ui.inverted.black.card:hover { - box-shadow: 0 1px 3px 0 #555555, 0 2px 0 0 #000000, 0 0 0 1px #555555; -} - -/*-------------- - Card Count ----------------*/ - -.ui.one.cards { - margin-left: 0; - margin-right: 0; -} - -.ui.one.cards > .card { - width: 100%; -} - -.ui.two.cards { - margin-left: -1em; - margin-right: -1em; -} - -.ui.two.cards > .card { - width: calc(50% - 2em); - margin-left: 1em; - margin-right: 1em; -} - -.ui.three.cards { - margin-left: -1em; - margin-right: -1em; -} - -.ui.three.cards > .card { - width: calc(33.33333333333333% - 2em); - margin-left: 1em; - margin-right: 1em; -} - -.ui.four.cards { - margin-left: -0.75em; - margin-right: -0.75em; -} - -.ui.four.cards > .card { - width: calc(25% - 1.5em); - margin-left: 0.75em; - margin-right: 0.75em; -} - -.ui.five.cards { - margin-left: -0.75em; - margin-right: -0.75em; -} - -.ui.five.cards > .card { - width: calc(20% - 1.5em); - margin-left: 0.75em; - margin-right: 0.75em; -} - -.ui.six.cards { - margin-left: -0.75em; - margin-right: -0.75em; -} - -.ui.six.cards > .card { - width: calc(16.666666666666664% - 1.5em); - margin-left: 0.75em; - margin-right: 0.75em; -} - -.ui.seven.cards { - margin-left: -0.5em; - margin-right: -0.5em; -} - -.ui.seven.cards > .card { - width: calc(14.285714285714285% - 1em); - margin-left: 0.5em; - margin-right: 0.5em; -} - -.ui.eight.cards { - margin-left: -0.5em; - margin-right: -0.5em; -} - -.ui.eight.cards > .card { - width: calc(12.5% - 1em); - margin-left: 0.5em; - margin-right: 0.5em; - font-size: 11px; -} - -.ui.nine.cards { - margin-left: -0.5em; - margin-right: -0.5em; -} - -.ui.nine.cards > .card { - width: calc(11.11111111111111% - 1em); - margin-left: 0.5em; - margin-right: 0.5em; - font-size: 10px; -} - -.ui.ten.cards { - margin-left: -0.5em; - margin-right: -0.5em; -} - -.ui.ten.cards > .card { - width: calc(10% - 1em); - margin-left: 0.5em; - margin-right: 0.5em; -} - -/*------------------- - Doubling - --------------------*/ - -/* Mobile Only */ - -@media only screen and (max-width: 767.98px) { - .ui.two.doubling.cards { - margin-left: 0; - margin-right: 0; - } - - .ui.two.doubling.cards > .card { - width: 100%; - margin-left: 0; - margin-right: 0; - } - - .ui.three.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.three.doubling.cards > .card { - width: calc(50% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.four.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.four.doubling.cards > .card { - width: calc(50% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.five.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.five.doubling.cards > .card { - width: calc(50% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.six.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.six.doubling.cards > .card { - width: calc(50% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.seven.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.seven.doubling.cards > .card { - width: calc(33.33333333333333% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.eight.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.eight.doubling.cards > .card { - width: calc(33.33333333333333% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.nine.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.nine.doubling.cards > .card { - width: calc(33.33333333333333% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.ten.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.ten.doubling.cards > .card { - width: calc(33.33333333333333% - 2em); - margin-left: 1em; - margin-right: 1em; - } -} - -/* Tablet Only */ - -@media only screen and (min-width: 768px) and (max-width: 991.98px) { - .ui.two.doubling.cards { - margin-left: 0; - margin-right: 0; - } - - .ui.two.doubling.cards > .card { - width: 100%; - margin-left: 0; - margin-right: 0; - } - - .ui.three.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.three.doubling.cards > .card { - width: calc(50% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.four.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.four.doubling.cards > .card { - width: calc(50% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.five.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.five.doubling.cards > .card { - width: calc(33.33333333333333% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.six.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.six.doubling.cards > .card { - width: calc(33.33333333333333% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.eight.doubling.cards { - margin-left: -1em; - margin-right: -1em; - } - - .ui.eight.doubling.cards > .card { - width: calc(33.33333333333333% - 2em); - margin-left: 1em; - margin-right: 1em; - } - - .ui.eight.doubling.cards { - margin-left: -0.75em; - margin-right: -0.75em; - } - - .ui.eight.doubling.cards > .card { - width: calc(25% - 1.5em); - margin-left: 0.75em; - margin-right: 0.75em; - } - - .ui.nine.doubling.cards { - margin-left: -0.75em; - margin-right: -0.75em; - } - - .ui.nine.doubling.cards > .card { - width: calc(25% - 1.5em); - margin-left: 0.75em; - margin-right: 0.75em; - } - - .ui.ten.doubling.cards { - margin-left: -0.75em; - margin-right: -0.75em; - } - - .ui.ten.doubling.cards > .card { - width: calc(20% - 1.5em); - margin-left: 0.75em; - margin-right: 0.75em; - } -} - -/*------------------- - Stackable - --------------------*/ - -@media only screen and (max-width: 767.98px) { - .ui.stackable.cards { - display: block !important; - } - - .ui.stackable.cards .card:first-child { - margin-top: 0 !important; - } - - .ui.stackable.cards > .card { - display: block !important; - height: auto !important; - margin: 1em 1em; - padding: 0 !important; - width: calc(100% - 2em) !important; - } -} - -/*-------------- - Size ----------------*/ - -.ui.cards > .card { - font-size: 1em; -} - -.ui.mini.cards .card { - font-size: 0.78571429rem; -} - -.ui.tiny.cards .card { - font-size: 0.85714286rem; -} - -.ui.small.cards .card { - font-size: 0.92857143rem; -} - -.ui.large.cards .card { - font-size: 1.14285714rem; -} - -.ui.big.cards .card { - font-size: 1.28571429rem; -} - -.ui.huge.cards .card { - font-size: 1.42857143rem; -} - -.ui.massive.cards .card { - font-size: 1.71428571rem; -} - -/*----------------- - Inverted - ------------------*/ - -.ui.inverted.cards > .card, -.ui.inverted.card { - background: #1B1C1D; - box-shadow: 0 1px 3px 0 #555555, 0 0 0 1px #555555; -} - -/* Content */ - -.ui.inverted.cards > .card > .content, -.ui.inverted.card > .content { - border-top: 1px solid rgba(255, 255, 255, 0.15); -} - -/* Header */ - -.ui.inverted.cards > .card > .content > .header, -.ui.inverted.card > .content > .header { - color: rgba(255, 255, 255, 0.9); -} - -/* Description */ - -.ui.inverted.cards > .card > .content > .description, -.ui.inverted.card > .content > .description { - color: rgba(255, 255, 255, 0.8); -} - -/* Meta */ - -.ui.inverted.cards > .card .meta, -.ui.inverted.card .meta { - color: rgba(255, 255, 255, 0.7); -} - -.ui.inverted.cards > .card .meta > a:not(.ui), -.ui.inverted.card .meta > a:not(.ui) { - color: rgba(255, 255, 255, 0.7); -} - -.ui.inverted.cards > .card .meta > a:not(.ui):hover, -.ui.inverted.card .meta > a:not(.ui):hover { - color: #ffffff; -} - -/* Extra */ - -.ui.inverted.cards > .card > .extra, -.ui.inverted.card > .extra { - border-top: 1px solid rgba(255, 255, 255, 0.15) !important; - color: rgba(255, 255, 255, 0.7); -} - -.ui.inverted.cards > .card > .extra a:not(.ui), -.ui.inverted.card > .extra a:not(.ui) { - color: rgba(255, 255, 255, 0.5); -} - -.ui.inverted.cards > .card > .extra a:not(.ui):hover, -.ui.inverted.card > .extra a:not(.ui):hover { - color: #1e70bf; -} - -/* Link card(s) */ - -.ui.inverted.cards a.card:hover, -.ui.inverted.link.cards .card:not(.icon):hover, -a.inverted.ui.card:hover, -.ui.inverted.link.card:hover { - background: #1B1C1D; -} - -/******************************* - Theme Overrides -*******************************/ - -/******************************* - User Variable Overrides -*******************************/ /*! * # Fomantic-UI - Checkbox * http://github.com/fomantic/Fomantic-UI/ diff --git a/web_src/fomantic/semantic.json b/web_src/fomantic/semantic.json index 738f53d297..4a516b2c3a 100644 --- a/web_src/fomantic/semantic.json +++ b/web_src/fomantic/semantic.json @@ -23,7 +23,6 @@ "components": [ "api", "button", - "card", "checkbox", "comment", "container", From 3075b996109f413a3f45d502155316be1be17b44 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 14 May 2023 03:48:56 +0800 Subject: [PATCH 21/55] Add icon support for safari (#24697) Co-authored-by: Giteabot --- routers/web/web.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routers/web/web.go b/routers/web/web.go index 8784b7c5f7..58623b4c67 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -112,6 +112,7 @@ func Routes(ctx gocontext.Context) *web.Route { routes.RouteMethods("/avatars/*", "GET, HEAD", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) routes.RouteMethods("/repo-avatars/*", "GET, HEAD", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) routes.RouteMethods("/apple-touch-icon.png", "GET, HEAD", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) + routes.RouteMethods("/apple-touch-icon-precomposed.png", "GET, HEAD", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) routes.RouteMethods("/favicon.ico", "GET, HEAD", misc.StaticRedirect("/assets/img/favicon.png")) _ = templates.HTMLRenderer() From 8a8b7536475bea0146298177affb74dddd8c6f6a Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 14 May 2023 04:38:22 +0800 Subject: [PATCH 22/55] Improve button-ghost, remove tertiary button (#24692) image image image And a page to test buttons:
image
--------- Co-authored-by: Giteabot Co-authored-by: silverwind --- templates/devtest/gitea-ui.tmpl | 33 +++++++++++++++++++ templates/repo/branch/list.tmpl | 20 +++++------ templates/repo/home.tmpl | 2 +- .../applications_oauth2_edit_form.tmpl | 2 +- web_src/css/base.css | 20 +++-------- web_src/css/user.css | 4 --- 6 files changed, 50 insertions(+), 31 deletions(-) diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl index 2fe478a07d..c00d12217d 100644 --- a/templates/devtest/gitea-ui.tmpl +++ b/templates/devtest/gitea-ui.tmpl @@ -1,5 +1,38 @@ {{template "base/head" .}}
+
+

Button

+
+ Style: + + + + +
+
+ State: + +
+
+
+ + + + + +
+
This is a button
+
+ +

Tooltip

diff --git a/templates/repo/branch/list.tmpl b/templates/repo/branch/list.tmpl index 199d4489a9..9388258921 100644 --- a/templates/repo/branch/list.tmpl +++ b/templates/repo/branch/list.tmpl @@ -27,7 +27,7 @@ {{if and $.IsWriter (not $.Repository.IsArchived) (not .IsDeleted)}} - {{else}} - {{end}} diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl index e7a76cdaf5..7c084b0686 100644 --- a/templates/repo/home.tmpl +++ b/templates/repo/home.tmpl @@ -30,7 +30,7 @@
{{range .Topics}}{{.Name}}{{end}} - {{if and .Permission.IsAdmin (not .Repository.IsArchived)}}{{end}} + {{if and .Permission.IsAdmin (not .Repository.IsArchived)}}{{end}}
{{end}} {{if and .Permission.IsAdmin (not .Repository.IsArchived)}} diff --git a/templates/user/settings/applications_oauth2_edit_form.tmpl b/templates/user/settings/applications_oauth2_edit_form.tmpl index 1a8336b150..8a9579c5c5 100644 --- a/templates/user/settings/applications_oauth2_edit_form.tmpl +++ b/templates/user/settings/applications_oauth2_edit_form.tmpl @@ -27,7 +27,7 @@ {{.CsrfTokenHtml}} {{.locale.Tr "settings.oauth2_regenerate_secret_hint"}} - +
diff --git a/web_src/css/base.css b/web_src/css/base.css index 6c1bbb00c4..4c8d30c1b4 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -2180,13 +2180,16 @@ a.ui.active.label:hover { border-left: none; } -.ui.button.button-ghost { +/* a ghost button can be used as inline text, it doesn't have obvious styles */ +.button.button-ghost { background: transparent; border: none; color: inherit; + margin: 0; + padding: 0; } -.ui.button.button-ghost:hover { +.button.button-ghost:hover { color: var(--color-primary); } @@ -2283,19 +2286,6 @@ a.ui.active.label:hover { border-color: var(--color-secondary-dark-3) !important; } -.ui.tertiary.button { - color: var(--color-text-light); - border: none; -} - -.ui.tertiary.button:hover { - color: var(--color-text); -} - -.ui.tertiary.button:focus { - color: var(--color-text-dark); -} - .ui.primary.label, .ui.primary.labels .label, .ui.ui.ui.primary.label { diff --git a/web_src/css/user.css b/web_src/css/user.css index 648480d71d..a7106599df 100644 --- a/web_src/css/user.css +++ b/web_src/css/user.css @@ -121,10 +121,6 @@ object-fit: contain; } -.user.notification table button { - padding: 3px 3px 3px 5px; -} - #notification_div .tab.segment { overflow-x: auto; } From 68081c4721b9aeabe368f3eaeb8a4922c15d6918 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sun, 14 May 2023 05:26:35 +0800 Subject: [PATCH 23/55] Add test for api team orgnization (#24699) Co-authored-by: Giteabot --- tests/integration/api_team_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/api_team_test.go b/tests/integration/api_team_test.go index 934e6bf230..60c61394d4 100644 --- a/tests/integration/api_team_test.go +++ b/tests/integration/api_team_test.go @@ -29,6 +29,7 @@ func TestAPITeam(t *testing.T) { teamUser := unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{ID: 1}) team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamUser.TeamID}) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: teamUser.OrgID}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: teamUser.UID}) session := loginUser(t, user.Name) @@ -40,6 +41,7 @@ func TestAPITeam(t *testing.T) { DecodeJSON(t, resp, &apiTeam) assert.EqualValues(t, team.ID, apiTeam.ID) assert.Equal(t, team.Name, apiTeam.Name) + assert.EqualValues(t, convert.ToOrganization(db.DefaultContext, org), apiTeam.Organization) // non team member user will not access the teams details teamUser2 := unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{ID: 3}) @@ -58,7 +60,7 @@ func TestAPITeam(t *testing.T) { session = loginUser(t, user.Name) token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAdminOrg) - org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 6}) + org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 6}) // Create team. teamToCreate := &api.CreateTeamOption{ From 4810fe55e3e73edb962052df46bef125eb1817b3 Mon Sep 17 00:00:00 2001 From: Yarden Shoham Date: Sun, 14 May 2023 00:59:01 +0300 Subject: [PATCH 24/55] Add status indicator on main home screen for each repo (#24638) It will show the calculated commit status state of the latest commit on the default branch for each repository in the dashboard repo list - Closes #15620 # Before ![image](https://github.com/go-gitea/gitea/assets/20454870/aa1326c7-43c0-458a-a798-3102c766bcf9) # After ![image](https://github.com/go-gitea/gitea/assets/20454870/8658cc03-2224-442a-b1c8-bf64126e4575) --------- Signed-off-by: Yarden Shoham Co-authored-by: delvh Co-authored-by: Giteabot --- models/git/commit_status.go | 50 +++++++++++++++++++ modules/git/repo_branch.go | 11 +++++ routers/web/repo/repo.go | 54 ++++++++++++++++----- services/repository/branch.go | 4 ++ services/repository/repository.go | 14 ++++++ web_src/js/components/DashboardRepoList.vue | 21 +++++++- web_src/js/features/org-team.js | 4 +- web_src/js/features/repo-issue.js | 4 +- web_src/js/features/repo-template.js | 4 +- web_src/js/svg.js | 6 +++ 10 files changed, 152 insertions(+), 20 deletions(-) diff --git a/models/git/commit_status.go b/models/git/commit_status.go index 82cbb23637..6028e46649 100644 --- a/models/git/commit_status.go +++ b/models/git/commit_status.go @@ -23,6 +23,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" + "xorm.io/builder" "xorm.io/xorm" ) @@ -240,6 +241,55 @@ func GetLatestCommitStatus(ctx context.Context, repoID int64, sha string, listOp return statuses, count, db.GetEngine(ctx).In("id", ids).Find(&statuses) } +// GetLatestCommitStatusForPairs returns all statuses with a unique context for a given list of repo-sha pairs +func GetLatestCommitStatusForPairs(ctx context.Context, repoIDsToLatestCommitSHAs map[int64]string, listOptions db.ListOptions) (map[int64][]*CommitStatus, error) { + type result struct { + ID int64 + RepoID int64 + } + + results := make([]result, 0, len(repoIDsToLatestCommitSHAs)) + + sess := db.GetEngine(ctx).Table(&CommitStatus{}) + + // Create a disjunction of conditions for each repoID and SHA pair + conds := make([]builder.Cond, 0, len(repoIDsToLatestCommitSHAs)) + for repoID, sha := range repoIDsToLatestCommitSHAs { + conds = append(conds, builder.Eq{"repo_id": repoID, "sha": sha}) + } + sess = sess.Where(builder.Or(conds...)). + Select("max( id ) as id, repo_id"). + GroupBy("context_hash, repo_id").OrderBy("max( id ) desc") + + sess = db.SetSessionPagination(sess, &listOptions) + + err := sess.Find(&results) + if err != nil { + return nil, err + } + + ids := make([]int64, 0, len(results)) + repoStatuses := make(map[int64][]*CommitStatus) + for _, result := range results { + ids = append(ids, result.ID) + } + + statuses := make([]*CommitStatus, 0, len(ids)) + if len(ids) > 0 { + err = db.GetEngine(ctx).In("id", ids).Find(&statuses) + if err != nil { + return nil, err + } + + // Group the statuses by repo ID + for _, status := range statuses { + repoStatuses[status.RepoID] = append(repoStatuses[status.RepoID], status) + } + } + + return repoStatuses, nil +} + // FindRepoRecentCommitStatusContexts returns repository's recent commit status contexts func FindRepoRecentCommitStatusContexts(ctx context.Context, repoID int64, before time.Duration) ([]string, error) { start := timeutil.TimeStampNow().AddDuration(-before) diff --git a/modules/git/repo_branch.go b/modules/git/repo_branch.go index 14dcf14d8a..3bb6ef5223 100644 --- a/modules/git/repo_branch.go +++ b/modules/git/repo_branch.go @@ -106,6 +106,17 @@ func GetBranchesByPath(ctx context.Context, path string, skip, limit int) ([]*Br return gitRepo.GetBranches(skip, limit) } +// GetBranchCommitID returns a branch commit ID by its name +func GetBranchCommitID(ctx context.Context, path, branch string) (string, error) { + gitRepo, err := OpenRepository(ctx, path) + if err != nil { + return "", err + } + defer gitRepo.Close() + + return gitRepo.GetBranchCommitID(branch) +} + // GetBranches returns a slice of *git.Branch func (repo *Repository) GetBranches(skip, limit int) ([]*Branch, int, error) { brs, countAll, err := repo.GetBranchNames(skip, limit) diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 2f87e19022..f697d9433e 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -9,9 +9,11 @@ import ( "fmt" "net/http" "strings" + "sync" "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" @@ -576,23 +578,49 @@ func SearchRepo(ctx *context.Context) { return } - results := make([]*api.Repository, len(repos)) + // collect the latest commit of each repo + repoIDsToLatestCommitSHAs := make(map[int64]string) + wg := sync.WaitGroup{} + wg.Add(len(repos)) + for _, repo := range repos { + go func(repo *repo_model.Repository) { + defer wg.Done() + commitID, err := repo_service.GetBranchCommitID(ctx, repo, repo.DefaultBranch) + if err != nil { + return + } + repoIDsToLatestCommitSHAs[repo.ID] = commitID + }(repo) + } + wg.Wait() + + // call the database O(1) times to get the commit statuses for all repos + repoToItsLatestCommitStatuses, err := git_model.GetLatestCommitStatusForPairs(ctx, repoIDsToLatestCommitSHAs, db.ListOptions{}) + if err != nil { + log.Error("GetLatestCommitStatusForPairs: %v", err) + return + } + + results := make([]*repo_service.WebSearchRepository, len(repos)) for i, repo := range repos { - results[i] = &api.Repository{ - ID: repo.ID, - FullName: repo.FullName(), - Fork: repo.IsFork, - Private: repo.IsPrivate, - Template: repo.IsTemplate, - Mirror: repo.IsMirror, - Stars: repo.NumStars, - HTMLURL: repo.HTMLURL(), - Link: repo.Link(), - Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, + results[i] = &repo_service.WebSearchRepository{ + Repository: &api.Repository{ + ID: repo.ID, + FullName: repo.FullName(), + Fork: repo.IsFork, + Private: repo.IsPrivate, + Template: repo.IsTemplate, + Mirror: repo.IsMirror, + Stars: repo.NumStars, + HTMLURL: repo.HTMLURL(), + Link: repo.Link(), + Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, + }, + LatestCommitStatus: git_model.CalcCommitStatus(repoToItsLatestCommitStatuses[repo.ID]), } } - ctx.JSON(http.StatusOK, api.SearchResults{ + ctx.JSON(http.StatusOK, repo_service.WebSearchResults{ OK: true, Data: results, }) diff --git a/services/repository/branch.go b/services/repository/branch.go index a085026ae1..cafad34cef 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -53,6 +53,10 @@ func GetBranches(ctx context.Context, repo *repo_model.Repository, skip, limit i return git.GetBranchesByPath(ctx, repo.RepoPath(), skip, limit) } +func GetBranchCommitID(ctx context.Context, repo *repo_model.Repository, branch string) (string, error) { + return git.GetBranchCommitID(ctx, repo.RepoPath(), branch) +} + // checkBranchName validates branch name with existing repository branches func checkBranchName(ctx context.Context, repo *repo_model.Repository, name string) error { _, err := git.WalkReferences(ctx, repo.RepoPath(), func(_, refName string) error { diff --git a/services/repository/repository.go b/services/repository/repository.go index 0d6529383c..0914a8f6ec 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" @@ -20,9 +21,22 @@ import ( "code.gitea.io/gitea/modules/notification" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" pull_service "code.gitea.io/gitea/services/pull" ) +// WebSearchRepository represents a repository returned by web search +type WebSearchRepository struct { + Repository *structs.Repository `json:"repository"` + LatestCommitStatus *git.CommitStatus `json:"latest_commit_status"` +} + +// WebSearchResults results of a successful web search +type WebSearchResults struct { + OK bool `json:"ok"` + Data []*WebSearchRepository `json:"data"` +} + // CreateRepository creates a repository for the user/organization. func CreateRepository(ctx context.Context, doer, owner *user_model.User, opts repo_module.CreateRepoOptions) (*repo_model.Repository, error) { repo, err := repo_module.CreateRepository(doer, owner, opts) diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue index 161fca9414..84ee886618 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -79,6 +79,8 @@