···15031503;; - manage_ssh_keys: a user cannot configure ssh keys
15041504;; - manage_gpg_keys: a user cannot configure gpg keys
15051505;USER_DISABLED_FEATURES =
15061506+;; Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`. This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior.
15071507+;; - deletion: a user cannot delete their own account
15081508+;; - manage_ssh_keys: a user cannot configure ssh keys
15091509+;; - manage_gpg_keys: a user cannot configure gpg keys
15101510+;;EXTERNAL_USER_DISABLE_FEATURES =
1506151115071512;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
15081513;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
···23262331;SHOW_FOOTER_VERSION = true
23272332;; Show template execution time in the footer
23282333;SHOW_FOOTER_TEMPLATE_LOAD_TIME = true
23342334+;; Show the "powered by" text in the footer
23352335+;SHOW_FOOTER_POWERED_BY = true
23292336;; Generate sitemap. Defaults to `true`.
23302337;ENABLE_SITEMAP = true
23312338;; Enable/Disable RSS/Atom feed
+16-11
models/actions/variable.go
···66import (
77 "context"
88 "errors"
99- "fmt"
109 "strings"
11101211 "code.gitea.io/gitea/models/db"
1312 "code.gitea.io/gitea/modules/log"
1413 "code.gitea.io/gitea/modules/timeutil"
1515- "code.gitea.io/gitea/modules/util"
16141715 "xorm.io/builder"
1816)
···5553 db.ListOptions
5654 OwnerID int64
5755 RepoID int64
5656+ Name string
5857}
59586059func (opts FindVariablesOpts) ToConds() builder.Cond {
6160 cond := builder.NewCond()
6161+ // Since we now support instance-level variables,
6262+ // there is no need to check for null values for `owner_id` and `repo_id`
6263 cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
6364 cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
6565+6666+ if opts.Name != "" {
6767+ cond = cond.And(builder.Eq{"name": strings.ToUpper(opts.Name)})
6868+ }
6469 return cond
6570}
66716767-func GetVariableByID(ctx context.Context, variableID int64) (*ActionVariable, error) {
6868- var variable ActionVariable
6969- has, err := db.GetEngine(ctx).Where("id=?", variableID).Get(&variable)
7070- if err != nil {
7171- return nil, err
7272- } else if !has {
7373- return nil, fmt.Errorf("variable with id %d: %w", variableID, util.ErrNotExist)
7474- }
7575- return &variable, nil
7272+func FindVariables(ctx context.Context, opts FindVariablesOpts) ([]*ActionVariable, error) {
7373+ return db.Find[ActionVariable](ctx, opts)
7674}
77757876func UpdateVariable(ctx context.Context, variable *ActionVariable) (bool, error) {
···8280 Data: variable.Data,
8381 })
8482 return count != 0, err
8383+}
8484+8585+func DeleteVariable(ctx context.Context, id int64) error {
8686+ if _, err := db.DeleteByID[ActionVariable](ctx, id); err != nil {
8787+ return err
8888+ }
8989+ return nil
8590}
86918792func GetVariablesOfRun(ctx context.Context, run *ActionRun) (map[string]string, error) {
···12461246 }
12471247 return "name"
12481248}
12491249+12501250+// IsFeatureDisabledWithLoginType checks if a user feature is disabled, taking into account the login type of the
12511251+// user if applicable
12521252+func IsFeatureDisabledWithLoginType(user *User, feature string) bool {
12531253+ // NOTE: in the long run it may be better to check the ExternalLoginUser table rather than user.LoginType
12541254+ return (user != nil && user.LoginType > auth.Plain && setting.Admin.ExternalUserDisableFeatures.Contains(feature)) ||
12551255+ setting.Admin.UserDisabledFeatures.Contains(feature)
12561256+}
12571257+12581258+// DisabledFeaturesWithLoginType returns the set of user features disabled, taking into account the login type
12591259+// of the user if applicable
12601260+func DisabledFeaturesWithLoginType(user *User) *container.Set[string] {
12611261+ // NOTE: in the long run it may be better to check the ExternalLoginUser table rather than user.LoginType
12621262+ if user != nil && user.LoginType > auth.Plain {
12631263+ return &setting.Admin.ExternalUserDisableFeatures
12641264+ }
12651265+ return &setting.Admin.UserDisabledFeatures
12661266+}
+35
models/user/user_test.go
···1616 "code.gitea.io/gitea/models/unittest"
1717 user_model "code.gitea.io/gitea/models/user"
1818 "code.gitea.io/gitea/modules/auth/password/hash"
1919+ "code.gitea.io/gitea/modules/container"
1920 "code.gitea.io/gitea/modules/optional"
2021 "code.gitea.io/gitea/modules/setting"
2122 "code.gitea.io/gitea/modules/structs"
···542543 }
543544 }
544545}
546546+547547+func TestDisabledUserFeatures(t *testing.T) {
548548+ assert.NoError(t, unittest.PrepareTestDatabase())
549549+550550+ testValues := container.SetOf(setting.UserFeatureDeletion,
551551+ setting.UserFeatureManageSSHKeys,
552552+ setting.UserFeatureManageGPGKeys)
553553+554554+ oldSetting := setting.Admin.ExternalUserDisableFeatures
555555+ defer func() {
556556+ setting.Admin.ExternalUserDisableFeatures = oldSetting
557557+ }()
558558+ setting.Admin.ExternalUserDisableFeatures = testValues
559559+560560+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
561561+562562+ assert.Len(t, setting.Admin.UserDisabledFeatures.Values(), 0)
563563+564564+ // no features should be disabled with a plain login type
565565+ assert.LessOrEqual(t, user.LoginType, auth.Plain)
566566+ assert.Len(t, user_model.DisabledFeaturesWithLoginType(user).Values(), 0)
567567+ for _, f := range testValues.Values() {
568568+ assert.False(t, user_model.IsFeatureDisabledWithLoginType(user, f))
569569+ }
570570+571571+ // check disabled features with external login type
572572+ user.LoginType = auth.OAuth2
573573+574574+ // all features should be disabled
575575+ assert.NotEmpty(t, user_model.DisabledFeaturesWithLoginType(user).Values())
576576+ for _, f := range testValues.Values() {
577577+ assert.True(t, user_model.IsFeatureDisabledWithLoginType(user, f))
578578+ }
579579+}
···4141 return parser.start[0:1]
4242}
43434444+func isPunctuation(b byte) bool {
4545+ return b == '.' || b == '!' || b == '?' || b == ',' || b == ';' || b == ':'
4646+}
4747+4448func isAlphanumeric(b byte) bool {
4545- // Github only cares about 0-9A-Za-z
4646- return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
4949+ return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
4750}
48514952// Parse parses the current line and returns a result of parsing.
···5659 }
57605861 precedingCharacter := block.PrecendingCharacter()
5959- if precedingCharacter < 256 && isAlphanumeric(byte(precedingCharacter)) {
6262+ if precedingCharacter < 256 && (isAlphanumeric(byte(precedingCharacter)) || isPunctuation(byte(precedingCharacter))) {
6063 // need to exclude things like `a$` from being considered a start
6164 return nil
6265 }
···7578 ender += pos
76797780 // Now we want to check the character at the end of our parser section
7878- // that is ender + len(parser.end)
8181+ // that is ender + len(parser.end) and check if char before ender is '\'
7982 pos = ender + len(parser.end)
8083 if len(line) <= pos {
8184 break
8285 }
8383- if !isAlphanumeric(line[pos]) {
8686+ suceedingCharacter := line[pos]
8787+ if !isPunctuation(suceedingCharacter) && !(suceedingCharacter == ' ') {
8888+ return nil
8989+ }
9090+ if line[ender-1] != '\\' {
8491 break
8592 }
9393+8694 // move the pointer onwards
8795 ender += len(parser.end)
8896 }
···11+// Copyright 2024 The Gitea Authors. All rights reserved.
22+// SPDX-License-Identifier: MIT
33+44+package structs
55+66+// CreateVariableOption the option when creating variable
77+// swagger:model
88+type CreateVariableOption struct {
99+ // Value of the variable to create
1010+ //
1111+ // required: true
1212+ Value string `json:"value" binding:"Required"`
1313+}
1414+1515+// UpdateVariableOption the option when updating variable
1616+// swagger:model
1717+type UpdateVariableOption struct {
1818+ // New name for the variable. If the field is empty, the variable name won't be updated.
1919+ Name string `json:"name"`
2020+ // Value of the variable to update
2121+ //
2222+ // required: true
2323+ Value string `json:"value" binding:"Required"`
2424+}
2525+2626+// ActionVariable return value of the query API
2727+// swagger:model
2828+type ActionVariable struct {
2929+ // the owner to which the variable belongs
3030+ OwnerID int64 `json:"owner_id"`
3131+ // the repository to which the variable belongs
3232+ RepoID int64 `json:"repo_id"`
3333+ // the name of the variable
3434+ Name string `json:"name"`
3535+ // the value of the variable
3636+ Data string `json:"data"`
3737+}
···212212func ToPointer[T any](val T) *T {
213213 return &val
214214}
215215+216216+func ReserveLineBreakForTextarea(input string) string {
217217+ // Since the content is from a form which is a textarea, the line endings are \r\n.
218218+ // It's a standard behavior of HTML.
219219+ // But we want to store them as \n like what GitHub does.
220220+ // And users are unlikely to really need to keep the \r.
221221+ // Other than this, we should respect the original content, even leading or trailing spaces.
222222+ return strings.ReplaceAll(input, "\r\n", "\n")
223223+}
···11+Copyright 1989, 1990 Advanced Micro Devices, Inc.
22+33+This software is the property of Advanced Micro Devices, Inc (AMD) which
44+specifically grants the user the right to modify, use and distribute this
55+software provided this notice is not removed or altered. All other rights
66+are reserved by AMD.
77+88+AMD MAKES NO WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, WITH REGARD TO THIS
99+SOFTWARE. IN NO EVENT SHALL AMD BE LIABLE FOR INCIDENTAL OR CONSEQUENTIAL
1010+DAMAGES IN CONNECTION WITH OR ARISING FROM THE FURNISHING, PERFORMANCE, OR
1111+USE OF THIS SOFTWARE.
+12
options/license/OAR
···11+COPYRIGHT (c) 1989-2013, 2015.
22+On-Line Applications Research Corporation (OAR).
33+44+Permission to use, copy, modify, and distribute this software for any
55+purpose without fee is hereby granted, provided that this entire notice
66+is included in all copies of any software which is or includes a copy
77+or modification of this software.
88+99+THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED
1010+WARRANTY. IN PARTICULAR, THE AUTHOR MAKES NO REPRESENTATION
1111+OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY OF THIS
1212+SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE.
+12
options/license/xzoom
···11+Copyright Itai Nahshon 1995, 1996.
22+This program is distributed with no warranty.
33+44+Source files for this program may be distributed freely.
55+Modifications to this file are okay as long as:
66+ a. This copyright notice and comment are preserved and
77+ left at the top of the file.
88+ b. The man page is fixed to reflect the change.
99+ c. The author of this change adds his name and change
1010+ description to the list of changes below.
1111+Executable files may be distributed with sources, or with
1212+exact location where the source code can be obtained.
···77 "errors"
88 "net/http"
991010+ actions_model "code.gitea.io/gitea/models/actions"
1111+ "code.gitea.io/gitea/models/db"
1012 api "code.gitea.io/gitea/modules/structs"
1113 "code.gitea.io/gitea/modules/util"
1214 "code.gitea.io/gitea/modules/web"
1515+ "code.gitea.io/gitea/routers/api/v1/utils"
1616+ actions_service "code.gitea.io/gitea/services/actions"
1317 "code.gitea.io/gitea/services/context"
1418 secret_service "code.gitea.io/gitea/services/secrets"
1519)
···101105102106 ctx.Status(http.StatusNoContent)
103107}
108108+109109+// CreateVariable create a user-level variable
110110+func CreateVariable(ctx *context.APIContext) {
111111+ // swagger:operation POST /user/actions/variables/{variablename} user createUserVariable
112112+ // ---
113113+ // summary: Create a user-level variable
114114+ // consumes:
115115+ // - application/json
116116+ // produces:
117117+ // - application/json
118118+ // parameters:
119119+ // - name: variablename
120120+ // in: path
121121+ // description: name of the variable
122122+ // type: string
123123+ // required: true
124124+ // - name: body
125125+ // in: body
126126+ // schema:
127127+ // "$ref": "#/definitions/CreateVariableOption"
128128+ // responses:
129129+ // "201":
130130+ // description: response when creating a variable
131131+ // "204":
132132+ // description: response when creating a variable
133133+ // "400":
134134+ // "$ref": "#/responses/error"
135135+ // "404":
136136+ // "$ref": "#/responses/notFound"
137137+138138+ opt := web.GetForm(ctx).(*api.CreateVariableOption)
139139+140140+ ownerID := ctx.Doer.ID
141141+ variableName := ctx.Params("variablename")
142142+143143+ v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{
144144+ OwnerID: ownerID,
145145+ Name: variableName,
146146+ })
147147+ if err != nil && !errors.Is(err, util.ErrNotExist) {
148148+ ctx.Error(http.StatusInternalServerError, "GetVariable", err)
149149+ return
150150+ }
151151+ if v != nil && v.ID > 0 {
152152+ ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName))
153153+ return
154154+ }
155155+156156+ if _, err := actions_service.CreateVariable(ctx, ownerID, 0, variableName, opt.Value); err != nil {
157157+ if errors.Is(err, util.ErrInvalidArgument) {
158158+ ctx.Error(http.StatusBadRequest, "CreateVariable", err)
159159+ } else {
160160+ ctx.Error(http.StatusInternalServerError, "CreateVariable", err)
161161+ }
162162+ return
163163+ }
164164+165165+ ctx.Status(http.StatusNoContent)
166166+}
167167+168168+// UpdateVariable update a user-level variable which is created by current doer
169169+func UpdateVariable(ctx *context.APIContext) {
170170+ // swagger:operation PUT /user/actions/variables/{variablename} user updateUserVariable
171171+ // ---
172172+ // summary: Update a user-level variable which is created by current doer
173173+ // consumes:
174174+ // - application/json
175175+ // produces:
176176+ // - application/json
177177+ // parameters:
178178+ // - name: variablename
179179+ // in: path
180180+ // description: name of the variable
181181+ // type: string
182182+ // required: true
183183+ // - name: body
184184+ // in: body
185185+ // schema:
186186+ // "$ref": "#/definitions/UpdateVariableOption"
187187+ // responses:
188188+ // "201":
189189+ // description: response when updating a variable
190190+ // "204":
191191+ // description: response when updating a variable
192192+ // "400":
193193+ // "$ref": "#/responses/error"
194194+ // "404":
195195+ // "$ref": "#/responses/notFound"
196196+197197+ opt := web.GetForm(ctx).(*api.UpdateVariableOption)
198198+199199+ v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{
200200+ OwnerID: ctx.Doer.ID,
201201+ Name: ctx.Params("variablename"),
202202+ })
203203+ if err != nil {
204204+ if errors.Is(err, util.ErrNotExist) {
205205+ ctx.Error(http.StatusNotFound, "GetVariable", err)
206206+ } else {
207207+ ctx.Error(http.StatusInternalServerError, "GetVariable", err)
208208+ }
209209+ return
210210+ }
211211+212212+ if opt.Name == "" {
213213+ opt.Name = ctx.Params("variablename")
214214+ }
215215+ if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil {
216216+ if errors.Is(err, util.ErrInvalidArgument) {
217217+ ctx.Error(http.StatusBadRequest, "UpdateVariable", err)
218218+ } else {
219219+ ctx.Error(http.StatusInternalServerError, "UpdateVariable", err)
220220+ }
221221+ return
222222+ }
223223+224224+ ctx.Status(http.StatusNoContent)
225225+}
226226+227227+// DeleteVariable delete a user-level variable which is created by current doer
228228+func DeleteVariable(ctx *context.APIContext) {
229229+ // swagger:operation DELETE /user/actions/variables/{variablename} user deleteUserVariable
230230+ // ---
231231+ // summary: Delete a user-level variable which is created by current doer
232232+ // produces:
233233+ // - application/json
234234+ // parameters:
235235+ // - name: variablename
236236+ // in: path
237237+ // description: name of the variable
238238+ // type: string
239239+ // required: true
240240+ // responses:
241241+ // "201":
242242+ // description: response when deleting a variable
243243+ // "204":
244244+ // description: response when deleting a variable
245245+ // "400":
246246+ // "$ref": "#/responses/error"
247247+ // "404":
248248+ // "$ref": "#/responses/notFound"
249249+250250+ if err := actions_service.DeleteVariableByName(ctx, ctx.Doer.ID, 0, ctx.Params("variablename")); err != nil {
251251+ if errors.Is(err, util.ErrInvalidArgument) {
252252+ ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err)
253253+ } else if errors.Is(err, util.ErrNotExist) {
254254+ ctx.Error(http.StatusNotFound, "DeleteVariableByName", err)
255255+ } else {
256256+ ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err)
257257+ }
258258+ return
259259+ }
260260+261261+ ctx.Status(http.StatusNoContent)
262262+}
263263+264264+// GetVariable get a user-level variable which is created by current doer
265265+func GetVariable(ctx *context.APIContext) {
266266+ // swagger:operation GET /user/actions/variables/{variablename} user getUserVariable
267267+ // ---
268268+ // summary: Get a user-level variable which is created by current doer
269269+ // produces:
270270+ // - application/json
271271+ // parameters:
272272+ // - name: variablename
273273+ // in: path
274274+ // description: name of the variable
275275+ // type: string
276276+ // required: true
277277+ // responses:
278278+ // "200":
279279+ // "$ref": "#/responses/ActionVariable"
280280+ // "400":
281281+ // "$ref": "#/responses/error"
282282+ // "404":
283283+ // "$ref": "#/responses/notFound"
284284+285285+ v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{
286286+ OwnerID: ctx.Doer.ID,
287287+ Name: ctx.Params("variablename"),
288288+ })
289289+ if err != nil {
290290+ if errors.Is(err, util.ErrNotExist) {
291291+ ctx.Error(http.StatusNotFound, "GetVariable", err)
292292+ } else {
293293+ ctx.Error(http.StatusInternalServerError, "GetVariable", err)
294294+ }
295295+ return
296296+ }
297297+298298+ variable := &api.ActionVariable{
299299+ OwnerID: v.OwnerID,
300300+ RepoID: v.RepoID,
301301+ Name: v.Name,
302302+ Data: v.Data,
303303+ }
304304+305305+ ctx.JSON(http.StatusOK, variable)
306306+}
307307+308308+// ListVariables list user-level variables
309309+func ListVariables(ctx *context.APIContext) {
310310+ // swagger:operation GET /user/actions/variables user getUserVariablesList
311311+ // ---
312312+ // summary: Get the user-level list of variables which is created by current doer
313313+ // produces:
314314+ // - application/json
315315+ // parameters:
316316+ // - name: page
317317+ // in: query
318318+ // description: page number of results to return (1-based)
319319+ // type: integer
320320+ // - name: limit
321321+ // in: query
322322+ // description: page size of results
323323+ // type: integer
324324+ // responses:
325325+ // "200":
326326+ // "$ref": "#/responses/VariableList"
327327+ // "400":
328328+ // "$ref": "#/responses/error"
329329+ // "404":
330330+ // "$ref": "#/responses/notFound"
331331+332332+ vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{
333333+ OwnerID: ctx.Doer.ID,
334334+ ListOptions: utils.GetListOptions(ctx),
335335+ })
336336+ if err != nil {
337337+ ctx.Error(http.StatusInternalServerError, "FindVariables", err)
338338+ return
339339+ }
340340+341341+ variables := make([]*api.ActionVariable, len(vars))
342342+ for i, v := range vars {
343343+ variables[i] = &api.ActionVariable{
344344+ OwnerID: v.OwnerID,
345345+ RepoID: v.RepoID,
346346+ Name: v.Name,
347347+ Data: v.Data,
348348+ }
349349+ }
350350+351351+ ctx.SetTotalCountHeader(count)
352352+ ctx.JSON(http.StatusOK, variables)
353353+}
+3-2
routers/api/v1/user/gpg_key.go
···10101111 asymkey_model "code.gitea.io/gitea/models/asymkey"
1212 "code.gitea.io/gitea/models/db"
1313+ user_model "code.gitea.io/gitea/models/user"
1314 "code.gitea.io/gitea/modules/setting"
1415 api "code.gitea.io/gitea/modules/structs"
1516 "code.gitea.io/gitea/modules/web"
···133134134135// CreateUserGPGKey creates new GPG key to given user by ID.
135136func CreateUserGPGKey(ctx *context.APIContext, form api.CreateGPGKeyOption, uid int64) {
136136- if setting.Admin.UserDisabledFeatures.Contains(setting.UserFeatureManageGPGKeys) {
137137+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
137138 ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited"))
138139 return
139140 }
···274275 // "404":
275276 // "$ref": "#/responses/notFound"
276277277277- if setting.Admin.UserDisabledFeatures.Contains(setting.UserFeatureManageGPGKeys) {
278278+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
278279 ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited"))
279280 return
280281 }
+2-2
routers/api/v1/user/key.go
···199199200200// CreateUserPublicKey creates new public key to given user by ID.
201201func CreateUserPublicKey(ctx *context.APIContext, form api.CreateKeyOption, uid int64) {
202202- if setting.Admin.UserDisabledFeatures.Contains(setting.UserFeatureManageSSHKeys) {
202202+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
203203 ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited"))
204204 return
205205 }
···269269 // "404":
270270 // "$ref": "#/responses/notFound"
271271272272- if setting.Admin.UserDisabledFeatures.Contains(setting.UserFeatureManageSSHKeys) {
272272+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
273273 ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited"))
274274 return
275275 }
···66 <div class="ui attached segment">
77 {{if or .allowAdopt .allowDelete}}
88 {{if .Dirs}}
99- <div class="ui middle aligned divided list">
99+ <div class="ui list">
1010 {{range $dirI, $dir := .Dirs}}
1111 {{$repo := index $.ReposMap $dir}}
1212 <div class="item {{if not $repo}}tw-py-1{{end}}">{{/* if not repo, then there are "adapt" buttons, so the padding shouldn't be that default large*/}}
···66.is-loading {
77 pointer-events: none !important;
88 position: relative !important;
99- overflow: hidden !important;
109}
11101211.is-loading > * {
···3534 border-radius: var(--border-radius-circle);
3635}
37363838-.is-loading.small-loading-icon::after {
3737+.is-loading.loading-icon-2px::after {
3938 border-width: 2px;
4039}
41404141+.is-loading.loading-icon-3px::after {
4242+ border-width: 3px;
4343+}
4444+4245/* for single form button, the loading state should be on the button, but not go semi-transparent, just replace the text on the button with the loader. */
4346form.single-button-form.is-loading > * {
4447 opacity: 1;
···6366 background: transparent;
6467}
65686666-/* TODO: not needed, use "is-loading small-loading-icon" instead */
6969+/* TODO: not needed, use "is-loading loading-icon-2px" instead */
6770code.language-math.is-loading::after {
6871 padding: 0;
6972 border-width: 2px;
···135135 font-weight: var(--font-weight-normal);
136136}
137137138138+/* open dropdown menus to the left in right-attached headers */
139139+.ui.attached.header > .ui.right .ui.dropdown .menu {
140140+ right: 0;
141141+ left: auto;
142142+}
143143+138144/* if a .top.attached.header is followed by a .segment, add some margin */
139145.ui.segments + .ui.top.attached.header,
140146.ui.attached.segment + .ui.top.attached.header {
···1919 // the text to copy is not in the DOM or it is an image which should be
2020 // fetched to copy in full resolution
2121 if (link) {
2222- btn.classList.add('is-loading', 'small-loading-icon');
2222+ btn.classList.add('is-loading', 'loading-icon-2px');
2323 try {
2424 const res = await GET(link, {credentials: 'include', redirect: 'follow'});
2525 const contentType = res.headers.get('content-type');
···3333 } catch {
3434 return showTemporaryTooltip(btn, i18n.copy_error);
3535 } finally {
3636- btn.classList.remove('is-loading', 'small-loading-icon');
3636+ btn.classList.remove('is-loading', 'loading-icon-2px');
3737 }
3838 } else { // text, read from DOM
3939 const lineEls = document.querySelectorAll('.file-view .lines-code');
···7777}
78787979export function initRepoSettingBranches() {
8080- if (!$('.repository.settings.branches').length) return;
8181- $('.toggle-target-enabled').on('change', function () {
8282- const $target = $(this.getAttribute('data-target'));
8383- $target.toggleClass('disabled', !this.checked);
8484- });
8585- $('.toggle-target-disabled').on('change', function () {
8686- const $target = $(this.getAttribute('data-target'));
8787- if (this.checked) $target.addClass('disabled'); // only disable, do not auto enable
8888- });
8989- $('#dismiss_stale_approvals').on('change', function () {
9090- const $target = $('#ignore_stale_approvals_box');
9191- $target.toggleClass('disabled', this.checked);
8080+ if (!document.querySelector('.repository.settings.branches')) return;
8181+8282+ for (const el of document.getElementsByClassName('toggle-target-enabled')) {
8383+ el.addEventListener('change', function () {
8484+ const target = document.querySelector(this.getAttribute('data-target'));
8585+ target?.classList.toggle('disabled', !this.checked);
8686+ });
8787+ }
8888+8989+ for (const el of document.getElementsByClassName('toggle-target-disabled')) {
9090+ el.addEventListener('change', function () {
9191+ const target = document.querySelector(this.getAttribute('data-target'));
9292+ if (this.checked) target?.classList.add('disabled'); // only disable, do not auto enable
9393+ });
9494+ }
9595+9696+ document.getElementById('dismiss_stale_approvals')?.addEventListener('change', function () {
9797+ document.getElementById('ignore_stale_approvals_box')?.classList.toggle('disabled', this.checked);
9298 });
939994100 // show the `Matched` mark for the status checks that match the pattern
···106112 break;
107113 }
108114 }
109109-110115 toggleElem(el, matched);
111116 }
112117 };
+2
web_src/js/index.js
···8686import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js';
8787import {initDirAuto} from './modules/dirauto.js';
8888import {initRepositorySearch} from './features/repo-search.js';
8989+import {initColorPickers} from './features/colorpicker.js';
89909091// Init Gitea's Fomantic settings
9192initGiteaFomantic();
···188189 initRepoDiffView();
189190 initPdfViewer();
190191 initScopedAccessTokenCategories();
192192+ initColorPickers();
191193});
-2
web_src/js/modules/fomantic.js
···1111export function initGiteaFomantic() {
1212 // Silence fomantic's error logging when tabs are used without a target content element
1313 $.fn.tab.settings.silent = true;
1414- // Disable the behavior of fomantic to toggle the checkbox when you press enter on a checkbox element.
1515- $.fn.checkbox.settings.enableEnterKey = false;
16141715 // By default, use "exact match" for full text search
1816 $.fn.dropdown.settings.fullTextSearch = 'exact';
+6-11
web_src/js/modules/fomantic/aria.md
···4141<label><input type="checkbox"> ... </label>
4242```
43434444-However, related CSS styles aren't supported (not implemented) yet, so at the moment,
4545-almost all the checkboxes are still using Fomantic UI checkbox.
4646-4747-## Fomantic UI Checkbox
4444+However, the templates still have the Fomantic-style HTML layout:
48454946```html
5047<div class="ui checkbox">
5151- <input type="checkbox"> <!-- class "hidden" will be added by $.checkbox() -->
4848+ <input type="checkbox">
5249 <label>...</label>
5350</div>
5451```
55525656-Then the JS `$.checkbox()` should be called to make it work with keyboard and label-clicking,
5757-then it works like the ideal checkboxes.
5858-5959-There is still a problem: Fomantic UI checkbox is not friendly to screen readers,
6060-so we add IDs to all the Fomantic UI checkboxes automatically by JS.
6161-If the `label` part is empty, then the checkbox needs to get the `aria-label` attribute manually.
5353+We call `initAriaCheckboxPatch` to link the `input` and `label` which makes clicking the
5454+label etc. work. There is still a problem: These checkboxes are not friendly to screen readers,
5555+so we add IDs to all the Fomantic UI checkboxes automatically by JS. If the `label` part is empty,
5656+then the checkbox needs to get the `aria-label` attribute manually.
62576358# Fomantic Dropdown
6459
+18-32
web_src/js/modules/fomantic/checkbox.js
···11-import $ from 'jquery';
21import {generateAriaId} from './base.js';
3244-const ariaPatchKey = '_giteaAriaPatchCheckbox';
55-const fomanticCheckboxFn = $.fn.checkbox;
66-77-// use our own `$.fn.checkbox` to patch Fomantic's checkbox module
83export function initAriaCheckboxPatch() {
99- if ($.fn.checkbox === ariaCheckboxFn) throw new Error('initAriaCheckboxPatch could only be called once');
1010- $.fn.checkbox = ariaCheckboxFn;
1111- ariaCheckboxFn.settings = fomanticCheckboxFn.settings;
1212-}
44+ // link the label and the input element so it's clickable and accessible
55+ for (const el of document.querySelectorAll('.ui.checkbox')) {
66+ if (el.hasAttribute('data-checkbox-patched')) continue;
77+ const label = el.querySelector('label');
88+ const input = el.querySelector('input');
99+ if (!label || !input) continue;
1010+ const inputId = input.getAttribute('id');
1111+ const labelFor = label.getAttribute('for');
13121414-// the patched `$.fn.checkbox` checkbox function
1515-// * it does the one-time attaching on the first call
1616-function ariaCheckboxFn(...args) {
1717- const ret = fomanticCheckboxFn.apply(this, args);
1818- for (const el of this) {
1919- if (el[ariaPatchKey]) continue;
2020- attachInit(el);
1313+ if (inputId && !labelFor) { // missing "for"
1414+ label.setAttribute('for', inputId);
1515+ } else if (!inputId && !labelFor) { // missing both "id" and "for"
1616+ const id = generateAriaId();
1717+ input.setAttribute('id', id);
1818+ label.setAttribute('for', id);
1919+ } else {
2020+ continue;
2121+ }
2222+ el.setAttribute('data-checkbox-patched', 'true');
2123 }
2222- return ret;
2323-}
2424-2525-function attachInit(el) {
2626- // Fomantic UI checkbox needs to be something like: <div class="ui checkbox"><label /><input /></div>
2727- // It doesn't work well with <label><input />...</label>
2828- // To make it work with aria, the "id"/"for" attributes are necessary, so add them automatically if missing.
2929- // In the future, refactor to use native checkbox directly, then this patch could be removed.
3030- el[ariaPatchKey] = {}; // record that this element has been patched
3131- const label = el.querySelector('label');
3232- const input = el.querySelector('input');
3333- if (!label || !input || input.getAttribute('id')) return;
3434-3535- const id = generateAriaId();
3636- input.setAttribute('id', id);
3737- label.setAttribute('for', id);
3824}
+1-1
web_src/js/modules/fomantic/dropdown.js
···207207 if (!$item) $item = $(menu).find('> .item.selected'); // when dropdown filters items by input, there is no "value", so query the "selected" item
208208 // if the selected item is clickable, then trigger the click event.
209209 // we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click.
210210- if ($item && ($item[0].matches('a') || $item.hasClass('js-aria-clickable'))) $item[0].click();
210210+ if ($item?.[0]?.matches('a, .js-aria-clickable')) $item[0].click();
211211 }
212212 });
213213
+4-3
web_src/js/modules/tippy.js
···33import {formatDatetime} from '../utils/time.js';
4455const visibleInstances = new Set();
66+const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
6778export function createTippy(target, opts = {}) {
89 // the callback functions should be destructured from opts,
910 // because we should use our own wrapper functions to handle them, do not let the user override them
1010- const {onHide, onShow, onDestroy, role, theme, ...other} = opts;
1111+ const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
11121213 const instance = tippy(target, {
1314 appendTo: document.body,
···3536 visibleInstances.add(instance);
3637 return onShow?.(instance);
3738 },
3838- arrow: `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`,
3939+ arrow: arrow || (theme === 'bare' ? false : arrowSvg),
3940 role: role || 'menu', // HTML role attribute
4040- theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu" or "box-with-header"
4141+ theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu", "box-with-header" or "bare"
4142 plugins: [followCursor],
4243 ...other,
4344 });