Compare commits

..
Author SHA1 Message Date
ab295e62e4 fix(markup): make heading anchors match GitHub for whitespace and combining marks (#40195)
Heading anchors now keep the same characters as GitHub: letters, marks,
decimal and letter numbers, connector punctuation and alphabetic
symbols, with ASCII spaces turned into `-`. The rule follows
[github-slugger](https://github.com/Flet/github-slugger), whose fixtures
are generated from real GitHub renders.

- Other whitespace, including newlines in multi-line headings, is
dropped instead of becoming `-`, so `1<em space>ab` gets `#1ab`
- Combining marks are kept, so `हिन्दी` no longer becomes `हनद`
- Other numbers like `²` and `①` are dropped

Fixes https://github.com/go-gitea/gitea/issues/27754

This PR was written with Claude Code.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-04 05:51:12 +00:00
wxiaoguangandsilverwind d919301d36 refactor: remove UnmarshalHandleDoubleEncode patch and fix repo unit TODO (#39998)
Remove the `UnmarshalHandleDoubleEncode` runtime workaround for an old
xorm bug, and normalize legacy double-encoded JSON in `repo_unit.config`
and `login_source.cfg` with a migration instead.

- Config `FromDB` no longer fails on corrupted values. A broken auth
source is deactivated, so admins can still fix it in the admin panel
- Remove `ProjectsConfig.GetProjectsMode`, `FromDB` already defaults
`ProjectsMode`

Actually it doesn't need so many changes, just because AI thinks it
should handle "corrupted database value" for auth sources.

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-04 05:28:49 +00:00
666e6e2d17 fix(web): allow clearing the full name in profile settings (#40186)
Clearing "Full Name" in the profile settings shows success but keeps the
old value: since #31959 it is only updated when non-empty, because the
input is disabled (not submitted) when `change_full_name` is disabled.

Use `*string` for the field, so a missing field keeps the value and an
empty one clears it. With `change_full_name` disabled, any submitted
value (including empty) is now rejected. Not `optional.Option[string]`,
because `MaxSize` would count slice elements instead of characters.

Fixes #40185

---
AI disclosure: written with Claude Code (claude-opus-5-5) and tested
locally.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-11-04 01:42:41 +00:00
Jon Fullerandwxiaoguang 4d00ed2a1e fix(notifications): avoid deadlock when creating issue notifications concurrently (#40166)
Concurrent `notification-service` workers can deadlock in the
transaction for-loop.

The transaction is useless, so it is removed to avoid deadlock.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-03 16:33:39 +00:00
silverwind ce0da8a378 build: cross-compile in docker (#40136)
Building the backend under QEMU takes 46 to 54 minutes per foreign
platform in the nightly image build, against 7 minutes natively. The
backend stage now runs on the build platform and cross-compiles with
`GOOS`/`GOARCH`/`GOARM`, so every platform builds at native speed.

- `go generate` runs once instead of once per platform.
- The PR docker dry run builds all three platforms in a single job, on
any docker-related change.

---------

Signed-off-by: silverwind <me@silverwind.io>
2026-11-03 16:20:48 +00:00
Gaurav Dubeyandsilverwind f26a314636 fix(api): keep wiki page name when editing with an empty title (#40153)
Match `PATCH /repos/{owner}/{repo}/wiki/page/{pageName}` document "leave
empty to keep unchanged"

Fixes #39179

---------

Co-authored-by: silverwind <me@silverwind.io>
2026-11-03 16:20:39 +00:00
5c68ba3e0a fix(repo): avoid deadlock when updating language stats concurrently (#40167)
The `repo_stats_update` queue only deduplicates waiting items, so
several workers can index the same repository at once, then
`UpdateLanguageStats` can lead to transaction deadlock.

Use a global lock to avoid concurrent updates, and also helps
https://github.com/go-gitea/gitea/pull/39342 to avoid duplicate updates.

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-03 16:18:27 +00:00
silverwindandwxiaoguang 78eb95ab19 fix: enable more djlint rules and improve keyboard navigation (#40081)
Enable djlint's [H018](https://djlint.com/docs/linter/#h018),
[H046](https://djlint.com/docs/linter/#h046) and
[H054](https://djlint.com/docs/linter/#h054) rules, fix their findings
and the keyboard navigation problems around them. H054 found links
nested in `<button>` dropdowns and radio inputs nested in links, both
invalid HTML.

- "Add File" and "More Operations" in the repo file list become `div`
dropdowns, the disabled state stays unfocusable
- The diff whitespace menu marks the current option with a check and
`aria-current` instead of radios, which made our dropdown aria patch
announce it as a combobox
- Sign-in moves "Forgot password?" below "Sign In" and drops the
positive tabindexes from https://github.com/go-gitea/gitea/pull/31689,
so the tab order follows the DOM: username, password, remember, sign in,
forgot
- The focus ring on primary, red and green buttons is near-white on dark
themes and near-black on light ones, the primary-colored ring was
invisible on primary buttons

<img width="500" alt="Whitespace menu, before and after"
src="https://github.com/user-attachments/assets/d22a423d-95e5-434c-aec4-59264c7132b7"
/>

<img width="600" alt="Sign-in form, main and this PR"
src="https://github.com/user-attachments/assets/d6780690-d906-4791-8a2b-8d80fb03067a"
/>

<img width="215" height="132" alt="Keyboard-focused primary and red
buttons, dark and light theme"
src="https://github.com/user-attachments/assets/a2400b18-4802-4383-8560-7e8267a7f02f"
/>

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-03 15:30:59 +00:00
silverwindandwxiaoguang 5c723ee0fd refactor(markdown): remove goldmark-highlighting (#39751)
Render fenced code blocks with Gitea's own code block renderer instead
of `goldmark-highlighting`, using the managed lexer lookup from
`modules/highlight`. Indented and fenced blocks now share one rendering
path.

Rendered output is unchanged. The trailing newline is stripped on copy
in the frontend since https://github.com/go-gitea/gitea/pull/39755.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-03 12:25:31 +00:00
69 changed files with 774 additions and 590 deletions

No files matched your search

-31
View File
@@ -1,31 +0,0 @@
name: docker-dryrun
description: Composite action that performs the container build steps for a single platform.
inputs:
platform:
description: "The target platform: linux/amd64, linux/arm64, linux/riscv64."
required: true
runs:
using: composite
steps:
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
cache-image: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build regular image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ inputs.platform }}
push: false
file: Dockerfile
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful
- name: Build rootless image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ inputs.platform }}
push: false
file: Dockerfile.rootless
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless
-8
View File
@@ -15,8 +15,6 @@ on:
value: ${{ jobs.detect.outputs.templates }}
docker:
value: ${{ jobs.detect.outputs.docker }}
dockerfile:
value: ${{ jobs.detect.outputs.dockerfile }}
swagger:
value: ${{ jobs.detect.outputs.swagger }}
yaml:
@@ -42,7 +40,6 @@ jobs:
actions: ${{ steps.changes.outputs.actions }}
templates: ${{ steps.changes.outputs.templates }}
docker: ${{ steps.changes.outputs.docker }}
dockerfile: ${{ steps.changes.outputs.dockerfile }}
swagger: ${{ steps.changes.outputs.swagger }}
yaml: ${{ steps.changes.outputs.yaml }}
json: ${{ steps.changes.outputs.json }}
@@ -103,16 +100,11 @@ jobs:
docker:
- ".github/workflows/pull-docker-dryrun.yml"
- ".github/actions/docker-dryrun/**"
- "Dockerfile"
- "Dockerfile.rootless"
- "docker/**"
- "Makefile"
dockerfile:
- "Dockerfile"
- "Dockerfile.rootless"
swagger:
- "templates/swagger/*.json"
- "Makefile"
+15 -23
View File
@@ -14,34 +14,26 @@ jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
# QEMU-based build is slow (40-50 minutes), so run arm64 and riscv64 when dockerfile changes.
# Run amd64 when any docker-related files change, which is fast (4 minutes).
container-amd64:
container:
if: needs.files-changed.outputs.docker == 'true'
needs: [files-changed]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.github/actions/docker-dryrun
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platform: linux/amd64
container-arm64:
if: needs.files-changed.outputs.dockerfile == 'true'
needs: [files-changed]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.github/actions/docker-dryrun
cache-image: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build regular image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
platform: linux/arm64
container-riscv64:
if: needs.files-changed.outputs.dockerfile == 'true'
needs: [files-changed]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.github/actions/docker-dryrun
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful
- name: Build rootless image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
platform: linux/riscv64
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
file: Dockerfile.rootless
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless
+7 -3
View File
@@ -8,8 +8,8 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --froze
COPY --exclude=.git/ . .
RUN make frontend
# Build backend for each target platform
FROM docker.io/library/golang:1.27-alpine3.24 AS build-env
# Cross-compile backend on the native platform to avoid slow QEMU emulation
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.27-alpine3.24 AS build-env
ARG GITEA_VERSION
ARG TAGS=""
@@ -28,11 +28,15 @@ RUN go mod download
# ".git" directory is mounted separately later only for version data extraction.
COPY --exclude=.git/ . .
COPY --from=frontend-build /src/public/assets public/assets
RUN --mount=type=cache,target="/root/.cache/go-build" make generate-backend
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
# Build gitea, .git mount is required for version data
RUN --mount=type=cache,target="/root/.cache/go-build" \
--mount=type=bind,source=".git/",target=".git/" \
make backend
GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} make gitea
COPY docker/root /tmp/local
+7 -3
View File
@@ -8,8 +8,8 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --froze
COPY --exclude=.git/ . .
RUN make frontend
# Build backend for each target platform
FROM docker.io/library/golang:1.27-alpine3.24 AS build-env
# Cross-compile backend on the native platform
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.27-alpine3.24 AS build-env
ARG GITEA_VERSION
ARG TAGS=""
@@ -27,11 +27,15 @@ RUN go mod download
# See the comments in Dockerfile
COPY --exclude=.git/ . .
COPY --from=frontend-build /src/public/assets public/assets
RUN --mount=type=cache,target="/root/.cache/go-build" make generate-backend
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
# Build gitea, .git mount is required for version data
RUN --mount=type=cache,target="/root/.cache/go-build" \
--mount=type=bind,source=".git/",target=".git/" \
make backend
GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} make gitea
COPY docker/rootless /tmp/local
-5
View File
@@ -964,11 +964,6 @@
"path": "github.com/yohcop/openid-go/LICENSE",
"licenseText": "Copyright 2015 Yohann Coppel\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n"
},
{
"name": "github.com/yuin/goldmark-highlighting/v2",
"path": "github.com/yuin/goldmark-highlighting/v2/LICENSE",
"licenseText": "MIT License\n\nCopyright (c) 2019 Yusuke Inuzuka\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
},
{
"name": "github.com/yuin/goldmark",
"path": "github.com/yuin/goldmark/LICENSE",
-1
View File
@@ -84,7 +84,6 @@ require (
github.com/wneessen/go-mail v0.8.1
github.com/yohcop/openid-go v1.0.1
github.com/yuin/goldmark v1.8.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
gitlab.com/gitlab-org/api/client-go/v3 v3.39.1
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
golang.org/x/crypto v0.58.0
-8
View File
@@ -48,10 +48,8 @@ github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0 h1:LvK7+C6qgz8BPnmn7x
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:I28hc9eaiqKCoOB+9Wh/P1IOScfm0xCgyWC6DAo0lmo=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
@@ -162,8 +160,6 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.11.0 h1:z5uRPHav+eTVpoffDSuUP2KPS4f1ONZaPDK73S8HA0w=
@@ -528,7 +524,6 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -559,11 +554,8 @@ github.com/yohcop/openid-go v1.0.1 h1:DPRd3iPO5F6O5zX2e62XpVAbPT6wV51cuucH0z9g3j
github.com/yohcop/openid-go v1.0.1/go.mod h1:b/AvD03P0KHj4yuihb+VtLD6bYYgsy0zqBzPCRjkCNs=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
+1
View File
@@ -441,6 +441,7 @@ func prepareMigrationTasks() []*migration {
newMigration(362, "Add reusable_workflow_ref column to action_run_job", v29.AddReusableWorkflowRefToActionRunJob),
newMigration(363, "Add release_id to reaction table", v29.AddReleaseIDToReaction),
newMigration(364, "Add new table project_workflow", v29.AddProjectWorkflow),
newMigration(365, "Normalize legacy repository unit and login source JSON", v29.NormalizeLegacyConfigJSON),
}
return preparedMigrations
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"context"
"encoding/binary"
"gitea.dev/modelmigration/base"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
)
// unmarshalHandleDoubleEncode - due to a bug in xorm (see https://gitea.com/xorm/xorm/pulls/1957) - it's
// possible that a Blob may be double encoded or gain an unwanted prefix of 0xff 0xfe.
func unmarshalHandleDoubleEncode(bs []byte, v any) error {
if len(bs) == 0 {
// json.Unmarshal will report errors if input is empty (nil or zero-length)
// It seems that XORM ignores the nil but still passes zero-length string into this function
// To be consistent, we should treat all empty inputs as success
return nil
}
err := json.Unmarshal(bs, v)
if err != nil {
ok := true
rs := []byte{}
temp := make([]byte, 2)
for _, rn := range string(bs) {
if rn > 0xffff {
ok = false
break
}
binary.LittleEndian.PutUint16(temp, uint16(rn))
rs = append(rs, temp...)
}
if ok {
if len(rs) > 1 && rs[0] == 0xff && rs[1] == 0xfe {
rs = rs[2:]
}
err = json.Unmarshal(rs, v)
}
}
if err != nil && len(bs) > 2 && bs[0] == 0xff && bs[1] == 0xfe {
err = json.Unmarshal(bs[2:], v)
}
return err
}
func normalizeLegacyConfigJSON(config string) string {
if config == "" || json.Valid(util.UnsafeStringToBytes(config)) {
return config
}
m := map[any]any{}
err := unmarshalHandleDoubleEncode(util.UnsafeStringToBytes(config), &m)
if err != nil {
return config
}
if bs, err := json.Marshal(m); err == nil {
return string(bs)
}
return config
}
func NormalizeLegacyConfigJSON(ctx context.Context, x base.EngineMigration) error {
type RepoUnit struct {
ID int64
Config string
}
type LoginSource struct {
ID int64
Cfg string
}
return base.EngineTransaction(x, func(sess base.Session) error {
if err := base.IterateTableByColumn(ctx, x, sess, "id", nil, func(_ context.Context, unit *RepoUnit) error {
config := normalizeLegacyConfigJSON(unit.Config)
if config == unit.Config {
return nil
}
_, err := sess.ID(unit.ID).Cols("config").Update(&RepoUnit{Config: config})
return err
}); err != nil {
return err
}
return base.IterateTableByColumn(ctx, x, sess, "id", nil, func(_ context.Context, source *LoginSource) error {
config := normalizeLegacyConfigJSON(source.Cfg)
if config == source.Cfg {
return nil
}
_, err := sess.ID(source.ID).Cols("cfg").Update(&LoginSource{Cfg: config})
return err
})
})
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"testing"
"gitea.dev/modelmigration/migrationtest"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeLegacyConfigJSONValue(t *testing.T) {
const (
config = `{"Host":"smtp.example.com"}`
packed = "≻潈瑳㨢猢瑭⹰硥浡汰⹥潣≭⁽"
)
for _, tc := range []struct {
name, input, want string
}{
{"empty", "", ""},
{"valid", config + " ", config + " "},
{"unicode and large number", ` {"name":"例😀","id":9007199254740993} `, ` {"name":"例😀","id":9007199254740993} `},
{"double encoded", packed, config},
{"double encoded with BOM", "\ufeff" + packed, config},
{"BOM", "\xff\xfe" + config, config},
{"BOM with non-BMP rune", "\xff\xfe" + `{"name":"😀"}`, `{"name":"😀"}`},
{"corrupt", "invalid", "invalid"},
{"corrupt non-BMP rune", "invalid😀", "invalid😀"},
} {
t.Run(tc.name, func(t *testing.T) {
got := normalizeLegacyConfigJSON(tc.input)
assert.Equal(t, tc.want, got)
assert.Equal(t, got, normalizeLegacyConfigJSON(got))
})
}
}
func TestNormalizeLegacyConfigJSON(t *testing.T) {
type RepoUnit struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64
Config string `xorm:"TEXT"`
}
type LoginSource struct {
ID int64 `xorm:"pk autoincr"`
Name string
Cfg string `xorm:"TEXT"`
}
x, cleanup := migrationtest.PrepareTestEnv(t, 0, new(RepoUnit), new(LoginSource))
defer cleanup()
if x == nil || t.Failed() {
return
}
defer test.MockVariableValue(&setting.Database.IterateBufferSize, 2)()
inputs := []string{
` {"id":9007199254740993,"name":"例😀"} `,
"≻潈瑳㨢猢瑭⹰硥浡汰⹥潣≭⁽",
"\ufeff≻潈瑳㨢猢瑭⹰硥浡汰⹥潣≭⁽",
"",
"invalid",
}
want := []string{inputs[0], `{"Host":"smtp.example.com"}`, `{"Host":"smtp.example.com"}`, "", "invalid", ""}
for _, input := range inputs {
_, err := x.Insert(&RepoUnit{RepoID: 42, Config: input})
require.NoError(t, err)
_, err = x.Insert(&LoginSource{Name: "test", Cfg: input})
require.NoError(t, err)
}
_, err := x.Exec("INSERT INTO `repo_unit` (`repo_id`, `config`) VALUES (?, NULL)", 42)
require.NoError(t, err)
_, err = x.Exec("INSERT INTO `login_source` (`name`, `cfg`) VALUES (?, NULL)", "test")
require.NoError(t, err)
for range 2 {
require.NoError(t, NormalizeLegacyConfigJSON(t.Context(), x))
var units []RepoUnit
require.NoError(t, x.Asc("id").Find(&units))
require.Len(t, units, len(want))
var sources []LoginSource
require.NoError(t, x.Asc("id").Find(&sources))
require.Len(t, sources, len(want))
for i, config := range want {
assert.Equal(t, config, units[i].Config)
assert.EqualValues(t, 42, units[i].RepoID)
assert.Equal(t, config, sources[i].Cfg)
assert.Equal(t, "test", sources[i].Name)
}
}
}
+2 -2
View File
@@ -6,10 +6,10 @@ package actions
import (
"context"
"gitea.dev/models/db"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
"gitea.dev/modules/util"
"xorm.io/xorm/convert"
@@ -30,7 +30,7 @@ type OwnerActionsConfig struct {
var _ convert.ConversionFrom = (*OwnerActionsConfig)(nil)
func (cfg *OwnerActionsConfig) FromDB(bytes []byte) error {
_ = json.Unmarshal(bytes, cfg)
db.UnmarshalFromDB(bytes, cfg)
cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode)
return nil
}
+5 -6
View File
@@ -180,15 +180,14 @@ func updateIssueNotification(ctx context.Context, userID, issueID, commentID, up
}
// NOTICE: Only update comment id when the before notification on this issue is read, otherwise you may miss some old comments.
// But we need update update_by so that the notification will be reorder
var cols []string
// Always update so that updated_unix moves the notification to the top of the list
// Actually, it's unclear what UpdatedBy is used for, or maybe not used at all, or maybe it should just be dropped.
notification.UpdatedBy = updatedByID
cols := []string{"updated_by"}
if notification.Status == NotificationStatusRead {
notification.Status = NotificationStatusUnread
notification.CommentID = commentID
cols = []string{"status", "update_by", "comment_id"}
} else {
notification.UpdatedBy = updatedByID
cols = []string{"update_by"}
cols = append(cols, "status", "comment_id")
}
_, err = db.GetEngine(ctx).ID(notification.ID).Cols(cols...).Update(notification)
+5 -11
View File
@@ -71,16 +71,6 @@ func (opts FindNotificationOptions) ToOrders() string {
// receiverID > 0 just send to receiver, else send to all watcher
// Returns the set of user IDs whose notification rows were created or updated.
func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) ([]int64, error) {
var notifiedIDs []int64
err := db.WithTx(ctx, func(ctx context.Context) error {
var innerErr error
notifiedIDs, innerErr = createOrUpdateIssueNotifications(ctx, issueID, commentID, notificationAuthorID, receiverID)
return innerErr
})
return notifiedIDs, err
}
func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) ([]int64, error) {
// init
var toNotify container.Set[int64]
notifications, err := db.Find[Notification](ctx, FindNotificationOptions{
@@ -181,7 +171,11 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
if notificationExists(notifications, issue.ID, userID) {
err = updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID)
} else {
err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID)
if err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID); err != nil {
if errUpdate := updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID); errUpdate == nil {
err = nil
}
}
}
if err != nil {
return nil, err
+12
View File
@@ -32,6 +32,18 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) {
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 4, IssueID: issue.ID})
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
// unread notification should get latest updated_unix when re-notified
_, err = db.GetEngine(t.Context()).ID(notf.ID).NoAutoTime().Cols("updated_unix").Update(&activities_model.Notification{UpdatedUnix: 123})
assert.NoError(t, err)
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: notf.ID})
assert.EqualValues(t, 123, notf.UpdatedUnix)
_, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), issue.ID, 0, 3, 0)
assert.NoError(t, err)
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: notf.ID})
assert.NotEqualValues(t, 123, notf.UpdatedUnix)
assert.EqualValues(t, 3, notf.UpdatedBy)
}
func TestCreateOrUpdateIssueNotificationsForAssigneeAndReviewer(t *testing.T) {
+30 -3
View File
@@ -10,6 +10,7 @@ import (
"reflect"
"gitea.dev/models/db"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
@@ -60,14 +61,40 @@ var Names = map[Type]string{
type Config interface {
convert.Conversion
SetAuthSource(*Source)
GetAuthSource() *Source
}
type ConfigBase struct {
AuthSource *Source
AuthSource *Source `json:"-"`
}
func (p *ConfigBase) SetAuthSource(s *Source) {
p.AuthSource = s
func (c *ConfigBase) SetAuthSource(source *Source) {
c.AuthSource = source
}
func (c *ConfigBase) GetAuthSource() *Source {
return c.AuthSource
}
func UnmarshalAuthSourceConfig(bs []byte, v Config) {
if db.UnmarshalFromDB(bs, &v) {
return
}
// This is only a workaround because we don't know whether this unmarshal happens on sign-in page or admin panel.
// * Admin should have the chance to edit the auth source in admin panel, so FromDB should not return an error
// * The sign-in page should not use the auth source in the next login attempt, but we don't have a clear way to disable it immediately (acceptable)
// Actually, corrupted database value is a really rare case, just because AI thinks it should be handled.
authSourceID := v.GetAuthSource().ID
log.Error("Unable to unmarshal login source id=%d, will mark it as inactive to avoid unexpected sign-in issues", authSourceID)
go graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
// A small time window to avoid race condition with admin's editing because there is no other way.
// The UpdateUnix field is not filled when this function is called.
_, err := db.GetEngine(ctx).ID(authSourceID).And("is_active = ? AND updated_unix < ?", true, timeutil.TimeStampNow()-5).
Cols("is_active").Update(&Source{IsActive: false})
if err != nil {
log.Error("Unable to set login source id=%d to inactive: %v", authSourceID, err)
}
})
}
// SkipVerifiable configurations provide a IsSkipVerify to check if SkipVerify is set
+41 -1
View File
@@ -6,10 +6,15 @@ package auth_test
import (
"strings"
"testing"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -24,7 +29,8 @@ type TestSource struct {
// FromDB fills up a LDAPConfig from serialized format.
func (source *TestSource) FromDB(bs []byte) error {
return json.Unmarshal(bs, &source)
auth_model.UnmarshalAuthSourceConfig(bs, source)
return nil
}
// ToDB exports a LDAPConfig to a serialized format.
@@ -53,3 +59,37 @@ func TestDumpAuthSource(t *testing.T) {
// INSERT INTO `login_source` (`id`, `type`, `name`, `is_active`, `is_sync_enabled`, `two_factor_policy`, `cfg`, `created_unix`, `updated_unix`) VALUES (1,6,'TestSource',0,0,'','{"TestField":"TestValue"}',1774179784,1774179784);
assert.Contains(t, sb.String(), `'{"TestField":"TestValue"}'`)
}
func TestUnmarshalAuthSourceConfig(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.IsProd, true)()
defer test.MockVariableValue(&setting.IsInTesting, false)()
cfg := &TestSource{}
source := &auth_model.Source{Type: auth_model.OAuth2, Name: t.Name(), IsActive: true, Cfg: cfg}
err := db.Insert(t.Context(), source)
require.NoError(t, err)
cfg.SetAuthSource(source)
t.Run("ValidConfig", func(t *testing.T) {
for _, data := range []string{`{"TestField":"TestValue"}`, "", "null"} {
auth_model.UnmarshalAuthSourceConfig([]byte(data), cfg)
assert.Equal(t, "TestValue", cfg.TestField)
assert.Same(t, source, cfg.GetAuthSource())
}
})
t.Run("InvalidConfigDeactivatesSource", func(t *testing.T) {
_, err = db.GetEngine(t.Context()).ID(source.ID).NoAutoTime().Cols("updated_unix").Update(&auth_model.Source{UpdatedUnix: timeutil.TimeStampNow() - 60})
require.NoError(t, err)
has, _ := db.GetEngine(t.Context()).ID(source.ID).And("is_active=?", true).Get(&auth_model.Source{})
assert.True(t, has)
auth_model.UnmarshalAuthSourceConfig([]byte(`{"TestField":false}`), cfg)
assert.Same(t, source, cfg.GetAuthSource())
require.Eventually(t, func() bool {
has, _ := db.GetEngine(t.Context()).ID(source.ID).And("is_active=?", false).Get(&auth_model.Source{})
return has
}, time.Second, time.Millisecond)
})
}
+19
View File
@@ -6,6 +6,7 @@ package db
import (
"fmt"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
"xorm.io/xorm"
@@ -84,3 +85,21 @@ func CellToInt[T ~int | int64](cell xorm.Cell, def T) (ret T, has bool, err erro
}
return T(val), true, err
}
func UnmarshalFromDB(data []byte, v any) bool {
if len(data) == 0 {
return true
}
err := json.Unmarshal(data, v)
if err != nil {
// Even if database value is corrupted, we should not let it cause unnecessary 500 error or block user's operation
// Corrupted database value is a really rare case, it should never happen unless site admin manually modified the database.
// Although some database values will be printed into logs, it isn't a real problem
// because such case should never really happen. If it happens, logging the value
// is the only way to tell site admin which record is wrong since there is no other context information,
// and then they need to re-create the config (with new secrets)
setting.PanicInDevOrTesting("UnmarshalFromDB %q failed: %v", string(data), err)
return false
}
return true
}
+1 -1
View File
@@ -151,7 +151,7 @@ func UpdateLanguageStats(ctx context.Context, repo *Repository, commitID string,
var topLang string
var s int64
for lang, size := range stats {
if size > s {
if size > s || (size == s && lang < topLang) {
s = size
topLang = lang
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo_test
import (
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateLanguageStatsPrimaryTie(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
require.NoError(t, repo_model.UpdateLanguageStats(t.Context(), repo, "1234567890", map[string]int64{"Go": 10, "C": 10}))
stats, err := repo_model.GetLanguageStats(t.Context(), repo)
require.NoError(t, err)
require.Len(t, stats, 2)
for _, stat := range stats {
assert.Equal(t, stat.Language == "C", stat.IsPrimary, stat.Language)
}
}
+13 -35
View File
@@ -56,12 +56,11 @@ func init() {
// UnitConfig describes common unit config
type UnitConfig struct{}
// FromDB fills up a UnitConfig from serialized format.
func (cfg *UnitConfig) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
db.UnmarshalFromDB(bs, cfg)
return nil
}
// ToDB exports a UnitConfig to a serialized format.
func (cfg *UnitConfig) ToDB() ([]byte, error) {
return json.Marshal(cfg)
}
@@ -71,12 +70,11 @@ type ExternalWikiConfig struct {
ExternalWikiURL string
}
// FromDB fills up a ExternalWikiConfig from serialized format.
func (cfg *ExternalWikiConfig) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
db.UnmarshalFromDB(bs, cfg)
return nil
}
// ToDB exports a ExternalWikiConfig to a serialized format.
func (cfg *ExternalWikiConfig) ToDB() ([]byte, error) {
return json.Marshal(cfg)
}
@@ -89,12 +87,11 @@ type ExternalTrackerConfig struct {
ExternalTrackerRegexpPattern string
}
// FromDB fills up a ExternalTrackerConfig from serialized format.
func (cfg *ExternalTrackerConfig) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
db.UnmarshalFromDB(bs, cfg)
return nil
}
// ToDB exports a ExternalTrackerConfig to a serialized format.
func (cfg *ExternalTrackerConfig) ToDB() ([]byte, error) {
return json.Marshal(cfg)
}
@@ -106,12 +103,11 @@ type IssuesConfig struct {
EnableDependencies bool
}
// FromDB fills up a IssuesConfig from serialized format.
func (cfg *IssuesConfig) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
db.UnmarshalFromDB(bs, cfg)
return nil
}
// ToDB exports a IssuesConfig to a serialized format.
func (cfg *IssuesConfig) ToDB() ([]byte, error) {
return json.Marshal(cfg)
}
@@ -155,16 +151,14 @@ func DefaultPullRequestsConfig() *PullRequestsConfig {
return cfg
}
// FromDB fills up a PullRequestsConfig from serialized format.
func (cfg *PullRequestsConfig) FromDB(bs []byte) error {
// set default values for existing PullRequestConfig in DB
*cfg = *DefaultPullRequestsConfig()
_ = json.UnmarshalHandleDoubleEncode(bs, &cfg) // don't let corrupted database value cause unnecessary 500 error
db.UnmarshalFromDB(bs, cfg)
cfg.DefaultUpdateStyle = util.IfZero(cfg.DefaultUpdateStyle, UpdateStyleMerge)
return nil
}
// ToDB exports a PullRequestsConfig to a serialized format.
func (cfg *PullRequestsConfig) ToDB() ([]byte, error) {
return json.Marshal(cfg)
}
@@ -228,34 +222,18 @@ type ProjectsConfig struct {
ProjectsMode ProjectsMode
}
// FromDB fills up a ProjectsConfig from serialized format.
func (cfg *ProjectsConfig) FromDB(bs []byte) error {
// TODO: remove GetProjectsMode, only use ProjectsMode
cfg.ProjectsMode = ProjectsModeAll
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
db.UnmarshalFromDB(bs, cfg)
cfg.ProjectsMode = util.IfZero(cfg.ProjectsMode, ProjectsModeAll)
return nil
}
// ToDB exports a ProjectsConfig to a serialized format.
func (cfg *ProjectsConfig) ToDB() ([]byte, error) {
return json.Marshal(cfg)
}
func (cfg *ProjectsConfig) GetProjectsMode() ProjectsMode {
if cfg.ProjectsMode != "" {
return cfg.ProjectsMode
}
return ProjectsModeAll
}
func (cfg *ProjectsConfig) IsProjectsAllowed(m ProjectsMode) bool {
projectsMode := cfg.GetProjectsMode()
if m == ProjectsModeNone {
return true
}
return projectsMode == m || projectsMode == ProjectsModeAll
return m == ProjectsModeNone || cfg.ProjectsMode == m || cfg.ProjectsMode == ProjectsModeAll
}
// BeforeSet is invoked from XORM before setting the value of a field of this object.
+2 -3
View File
@@ -6,6 +6,7 @@ package repo
import (
"slices"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/models/unit"
"gitea.dev/modules/json"
@@ -173,14 +174,12 @@ func (cfg *ActionsConfig) ClampPermissions(perms ActionsTokenPermissions) Action
return ClampActionsTokenPermissions(perms, maxPerms)
}
// FromDB fills up a ActionsConfig from serialized format.
func (cfg *ActionsConfig) FromDB(bs []byte) error {
_ = json.UnmarshalHandleDoubleEncode(bs, &cfg)
db.UnmarshalFromDB(bs, cfg)
cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode)
return nil
}
// ToDB exports a ActionsConfig to a serialized format.
func (cfg *ActionsConfig) ToDB() ([]byte, error) {
return json.Marshal(cfg)
}
+14 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
@@ -82,6 +83,13 @@ func htmlEscape(code string) template.HTML {
return template.HTML(gohtml.EscapeString(code))
}
func normalizeLexer(lexer chroma.Lexer) chroma.Lexer {
if lexer != lexers.Fallback {
lexer = chroma.Coalesce(lexer)
}
return lexer
}
// RenderCodeSlowGuess tries to get a lexer by file name and language first,
// if not found, it will try to guess the lexer by code content, which is slow (more than several hundreds of milliseconds).
func RenderCodeSlowGuess(fileName, language, code string) (output template.HTML, lexer chroma.Lexer, lexerDisplayName string) {
@@ -96,11 +104,15 @@ func RenderCodeSlowGuess(fileName, language, code string) (output template.HTML,
}
lexer = detectChromaLexerWithAnalyze(fileName, language, util.UnsafeStringToBytes(code)) // it is also slow
lexer = normalizeLexer(lexer)
return RenderCodeByLexer(lexer, code), lexer, formatLexerName(lexer.Config().Name)
}
// RenderCodeByLexer returns a HTML version of code string with chroma syntax highlighting classes
// RenderCodeByLexer returns an HTML version of code string with chroma syntax highlighting classes
func RenderCodeByLexer(lexer chroma.Lexer, code string) template.HTML {
if lexer == lexers.Fallback {
return htmlEscape(code) // chroma is 3x slower for plain text
}
formatter := chromahtml.New(chromahtml.WithClasses(true),
chromahtml.WithLineNumbers(false),
chromahtml.PreventSurroundingPre(true),
@@ -128,6 +140,7 @@ func RenderFullFile(fileName, language string, code []byte) ([]template.HTML, st
return renderPlainText(code), formatLexerName(LanguagePlaintext)
}
lexer := detectChromaLexerWithAnalyze(fileName, language, code)
lexer = normalizeLexer(lexer)
lexerName := formatLexerName(lexer.Config().Name)
rendered := RenderCodeByLexer(lexer, util.UnsafeBytesToString(code))
unsafeLines := UnsafeSplitHighlightedLines(rendered)
+4 -4
View File
@@ -215,7 +215,7 @@ func normalizeFileNameLang(fileName, fileLang string) (string, string) {
func DetectChromaLexerByFileName(fileName, fileLang string) chroma.Lexer {
lexer, _ := detectChromaLexerByFileName(fileName, fileLang)
return lexer
return normalizeLexer(lexer)
}
func detectChromaLexerByFileName(fileName, fileLang string) (_ chroma.Lexer, byLang bool) {
@@ -289,14 +289,14 @@ func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lex
// if lang is provided, and it matches a lexer, use it directly
if byLang {
return chroma.Coalesce(lexer)
return lexer
}
// if a lexer is detected and there is no conflict for the file extension, use it directly
fileExt := path.Ext(fileName)
_, hasConflicts := chromaLexers().conflictingExtLangMap[fileExt]
if !hasConflicts && lexer != lexers.Fallback {
return chroma.Coalesce(lexer)
return lexer
}
// try to detect language by content, for best guessing for the language
@@ -308,5 +308,5 @@ func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lex
log.Warn("No chroma lexer found for enry detected language: %s (file: %s), need to fix the language mapping between enry and chroma.", analyzedLanguage, fileName)
}
}
return chroma.Coalesce(lexer)
return lexer
}
+8
View File
@@ -9,6 +9,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/git"
"gitea.dev/modules/git/languagestats"
"gitea.dev/modules/globallock"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/process"
@@ -23,6 +24,13 @@ func (db *DBIndexer) Index(id int64) error {
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().ShutdownContext(), fmt.Sprintf("Stats.DB Index Repo[%d]", id))
defer finished()
// the unique queue doesn't stop two workers from indexing the same repository at once
releaser, err := globallock.Lock(ctx, fmt.Sprintf("repo_stats_index_%d", id))
if err != nil {
return err
}
defer releaser()
repo, err := repo_model.GetRepositoryByID(ctx, id)
if err != nil {
return err
-36
View File
@@ -5,7 +5,6 @@ package json
import (
"bytes"
"encoding/binary"
"encoding/json" //nolint:depguard // this package wraps it
"io"
)
@@ -81,38 +80,3 @@ func MarshalIndent(v any, prefix, indent string) ([]byte, error) {
func Valid(data []byte) bool {
return json.Valid(data)
}
// UnmarshalHandleDoubleEncode - due to a bug in xorm (see https://gitea.com/xorm/xorm/pulls/1957) - it's
// possible that a Blob may be double encoded or gain an unwanted prefix of 0xff 0xfe.
func UnmarshalHandleDoubleEncode(bs []byte, v any) error {
if len(bs) == 0 {
// json.Unmarshal will report errors if input is empty (nil or zero-length)
// It seems that XORM ignores the nil but still passes zero-length string into this function
// To be consistent, we should treat all empty inputs as success
return nil
}
err := json.Unmarshal(bs, v)
if err != nil {
ok := true
rs := []byte{}
temp := make([]byte, 2)
for _, rn := range string(bs) {
if rn > 0xffff {
ok = false
break
}
binary.LittleEndian.PutUint16(temp, uint16(rn))
rs = append(rs, temp...)
}
if ok {
if len(rs) > 1 && rs[0] == 0xff && rs[1] == 0xfe {
rs = rs[2:]
}
err = json.Unmarshal(rs, v)
}
}
if err != nil && len(bs) > 2 && bs[0] == 0xff && bs[1] == 0xfe {
err = json.Unmarshal(bs[2:], v)
}
return err
}
-8
View File
@@ -10,14 +10,6 @@ import (
"github.com/stretchr/testify/assert"
)
func TestGiteaDBJSONUnmarshal(t *testing.T) {
var m map[any]any
err := UnmarshalHandleDoubleEncode(nil, &m)
assert.NoError(t, err)
err = UnmarshalHandleDoubleEncode([]byte(""), &m)
assert.NoError(t, err)
}
func TestIndent(t *testing.T) {
buf := &bytes.Buffer{}
err := Indent(buf, []byte(`{"a":1}`), ">", " ")
+8 -11
View File
@@ -26,18 +26,15 @@ import (
// and more closely matches the output from the shurcooL sanitizer
// In particular Unicode letters and numbers are a lot more than a-zA-Z0-9...
func CleanValue(value []byte) []byte {
value = bytes.TrimSpace(value)
rs := bytes.Runes(value)
result := make([]rune, 0, len(rs))
for _, r := range rs {
if unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' || r == '-' {
result = append(result, unicode.ToLower(r))
return bytes.Map(func(r rune) rune {
switch {
case r == ' ':
return '-'
case r == '-' || unicode.In(r, unicode.L, unicode.M, unicode.Nd, unicode.Nl, unicode.Pc, unicode.Other_Alphabetic):
return unicode.ToLower(r)
}
if unicode.IsSpace(r) {
result = append(result, '-')
}
}
return []byte(string(result))
return -1
}, bytes.TrimSpace(value))
}
// Most of what follows is a subtly changed version of github.com/yuin/goldmark/extension/footnote.go
+4 -1
View File
@@ -50,9 +50,12 @@ func TestCleanValue(t *testing.T) {
{"tes\\a", "tesa"},
{"tes/a", "tesa"},
{"a啊啊b", "a啊啊b"},
{"c🤔️🤔️d", "cd"},
{"c🤔️🤔️d", "c\ufe0f\ufe0fd"},
{"a⚡a", "aa"},
{"e.~f", "ef"},
{"a \u2003 b", "a--b"},
{"हिन्दी", "हिन्दी"},
{"m²", "m"},
}
for _, test := range tests {
assert.Equal(t, []byte(test.expect), CleanValue([]byte(test.param)), test.param)
+1 -1
View File
@@ -70,7 +70,7 @@ func (r *RenderInternal) ProtectSafeAttrs(content template.HTML) template.HTML {
return template.HTML(reAttrClass().ReplaceAllString(string(content), `$1 data-attr-class="`+r.secureIDPrefix+`$2"$3`))
}
func (r *RenderInternal) FormatWithSafeAttrs(w io.Writer, fmt template.HTML, a ...any) error {
func (r *RenderInternal) FormatWithSafeAttrsf(w io.Writer, fmt template.HTML, a ...any) error {
htmlStr := r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))
_, err := io.WriteString(w, string(htmlStr))
return err
+14 -9
View File
@@ -8,7 +8,6 @@ import (
"gitea.dev/modules/container"
"gitea.dev/modules/highlight"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/internal"
"gitea.dev/modules/markup/markdown/math"
@@ -145,23 +144,29 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(KindSummary, r.renderSummary)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
reg.Register(KindAttention, r.renderAttention)
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
reg.Register(KindRawHTML, r.renderRawHTML)
}
// renderCodeBlock wraps indented code blocks like the fenced renderer
func (r *HTMLRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
preAttrs, codeAttrs := highlight.CodeBlockAttributes("") // no language
opening := r.renderInternal.ProtectSafeAttrs(htmlutil.HTMLFormat(`<div class="code-block-container code-overflow-scroll"><pre %s><code %s>`, preAttrs, codeAttrs))
if _, err := w.WriteString(string(opening)); err != nil {
var language string
if fenced, ok := n.(*ast.FencedCodeBlock); ok {
language = string(fenced.Language(source))
}
preAttrs, codeAttrs := highlight.CodeBlockAttributes(language)
err := r.renderInternal.FormatWithSafeAttrsf(w, `<div class="code-block-container code-overflow-scroll"><pre %s><code %s>`, preAttrs, codeAttrs)
if err != nil {
return ast.WalkStop, err
}
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.Writer.RawWrite(w, line.Value(source))
code := n.Lines().Value(source)
lexer := highlight.DetectChromaLexerByFileName("", language)
renderedHTML := highlight.RenderCodeByLexer(lexer, string(code))
if _, err = w.WriteString(string(renderedHTML)); err != nil {
return ast.WalkStop, err
}
} else {
if _, err := w.WriteString("</code></pre></div>"); err != nil {
+12 -49
View File
@@ -13,7 +13,6 @@ import (
"slices"
"strings"
"gitea.dev/modules/highlight"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
@@ -22,9 +21,7 @@ import (
"gitea.dev/modules/setting"
giteautil "gitea.dev/modules/util"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
@@ -68,32 +65,6 @@ func newParserContext(ctx *markup.RenderContext) parser.Context {
return pc
}
type GoldmarkRender struct {
ctx *markup.RenderContext
goldmarkMarkdown goldmark.Markdown
}
func (r *GoldmarkRender) Convert(source []byte, writer io.Writer, opts ...parser.ParseOption) error {
return r.goldmarkMarkdown.Convert(source, writer, opts...)
}
func (r *GoldmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
if entering {
languageBytes, _ := c.Language()
preAttrs, codeAttrs := highlight.CodeBlockAttributes(string(languageBytes))
err := r.ctx.RenderInternal.FormatWithSafeAttrs(w, `<div class="code-block-container code-overflow-scroll"><pre %s><code %s>`, preAttrs, codeAttrs)
if err != nil {
return
}
} else {
_, err := w.WriteString("</code></pre></div>")
if err != nil {
return
}
}
}
type goldmarkEmphasisParser struct {
parser.InlineParser
}
@@ -130,11 +101,10 @@ func goldmarkDefaultParser() parser.Parser {
}
// SpecializedMarkdown sets up the Gitea specific markdown extensions
func SpecializedMarkdown(ctx *markup.RenderContext) *GoldmarkRender {
func SpecializedMarkdown(ctx *markup.RenderContext) goldmark.Markdown {
// TODO: it could use a pool to cache the renderers to reuse them with different contexts
// at the moment it is fast enough (see the benchmarks)
r := &GoldmarkRender{ctx: ctx}
r.goldmarkMarkdown = goldmark.New(
return goldmark.New(
goldmark.WithParser(goldmarkDefaultParser()),
goldmark.WithExtensions(
extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
@@ -142,13 +112,6 @@ func SpecializedMarkdown(ctx *markup.RenderContext) *GoldmarkRender {
extension.TaskList,
extension.DefinitionList,
common.FootnoteExtension,
highlighting.NewHighlighting(
highlighting.WithFormatOptions(
chromahtml.WithClasses(true),
chromahtml.PreventSurroundingPre(true),
),
highlighting.WithWrapperRenderer(r.highlightingRenderer),
),
math.NewExtension(&ctx.RenderInternal, math.Options{
Enabled: setting.Markdown.EnableMath,
ParseInlineDollar: setting.Markdown.MathCodeBlockOptions.ParseInlineDollar,
@@ -161,15 +124,11 @@ func SpecializedMarkdown(ctx *markup.RenderContext) *GoldmarkRender {
parser.WithAttribute(),
parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)),
),
goldmark.WithRendererOptions(html.WithUnsafe()),
goldmark.WithRendererOptions(
html.WithUnsafe(),
renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)),
),
)
// Override the original Tasklist renderer!
r.goldmarkMarkdown.Renderer().AddOptions(
renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)),
)
return r
}
// render calls goldmark render to convert Markdown to HTML
@@ -275,14 +234,18 @@ func FeedExcerpt(ctx context.Context, content string) string {
pc := newParserContext(rctx)
pc.Set(renderConfigKey, &RenderConfig{})
start, texts := len(content), []text.Segment(nil)
_ = ast.Walk(SpecializedMarkdown(rctx).goldmarkMarkdown.Parser().Parse(text.NewReader([]byte(content)), parser.WithContext(pc)), func(node ast.Node, entering bool) (ast.WalkStatus, error) {
mdParser := SpecializedMarkdown(rctx).Parser()
mdReader := text.NewReader([]byte(content))
mdWalker := func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && node.Type() == ast.TypeBlock && node.Lines().Len() > 0 {
start = min(start, strings.LastIndexByte(content[:node.Lines().At(0).Start], '\n')+1)
} else if textNode, ok := node.(*ast.Text); ok && entering && node.Parent().Type() == ast.TypeBlock {
texts = append(texts, textNode.Segment)
}
return ast.WalkContinue, nil
})
}
_ = ast.Walk(mdParser.Parse(mdReader, parser.WithContext(pc)), mdWalker)
excerpt := giteautil.EllipsisDisplayString(content[start:], 200)
kept, ok := strings.CutSuffix(excerpt, "…")
@@ -17,8 +17,8 @@ import (
// Block render output:
// <pre class="code-block is-loading"><code class="language-math">...</code></pre>
//
// Keep in mind that there is another "code block" render in "func (r *GoldmarkRender) highlightingRenderer"
// "highlightingRenderer" outputs the math block with extra "chroma" class:
// Keep in mind that there is another "code block" render in "func (r *HTMLRenderer) renderCodeBlock"
// "renderCodeBlock" outputs the math block with extra "chroma" class:
// <pre class="code-block is-loading"><code class="chroma language-math">...</code></pre>
//
// Special classes:
@@ -39,7 +39,7 @@ func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Nod
r.Writer.RawWrite(w, value)
}
case *ColorPreview:
_ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, v.Color)
_ = r.renderInternal.FormatWithSafeAttrsf(w, `<span class="color-preview" style="background-color: %s"></span>`, v.Color)
}
}
return ast.WalkSkipChildren, nil
+1 -4
View File
@@ -15,7 +15,6 @@ import (
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
"github.com/alecthomas/chroma/v2"
"github.com/niklasfasching/go-org/org"
)
@@ -58,10 +57,8 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
preAttrs, codeAttrs := highlight.CodeBlockAttributes(lang)
lexer := highlight.DetectChromaLexerByFileName("", lang) // don't use content to detect, it is too slow
lexer = chroma.Coalesce(lexer)
sb := &strings.Builder{}
_ = ctx.RenderInternal.FormatWithSafeAttrs(sb, `<pre %s><code %s>`, preAttrs, codeAttrs)
_ = ctx.RenderInternal.FormatWithSafeAttrsf(sb, `<pre %s><code %s>`, preAttrs, codeAttrs)
_, _ = sb.WriteString(string(highlight.RenderCodeByLexer(lexer, source)))
_, _ = sb.WriteString("</code></pre>")
return sb.String()
+2
View File
@@ -11,6 +11,8 @@ import "strconv"
// * non-existing value are represented as an empty slice (None)
// * existing value is represented as a slice with one element (Some)
// * multiple values are represented as a slice with multiple elements (Some), the Value is the first element (not well-defined in this case)
//
// Attention: Option[T] doesn't work with some web middleware binding rules like "MaxSize" at the moment.
type Option[T any] []T
func None[T any]() Option[T] {
+1 -1
View File
@@ -43,7 +43,7 @@ type WikiPageMetaData struct {
// CreateWikiPageOptions form for creating wiki
type CreateWikiPageOptions struct {
// page title. leave empty to keep unchanged
Title string `json:"title" binding:"TrimSpace;Required"`
Title string `json:"title" binding:"TrimSpace"`
// content must be base64 encoded
ContentBase64 string `json:"content_base64"`
// optional commit message summarizing the change
+2 -1
View File
@@ -12,4 +12,5 @@ dev = [
[tool.djlint]
profile="golang"
ignore="H005,H013,H016,H020,H021,H030,H045,H046,H054"
ignore="H005,H013,H016,H020,H021,H030,H045"
include="H018"
+8 -4
View File
@@ -49,6 +49,11 @@ func NewWikiPage(ctx *context.APIContext) {
// "$ref": "#/responses/error"
form := web.GetForm[*api.CreateWikiPageOptions](ctx)
// EditWikiPage shares the form but allows an empty title
if form.Title == "" {
ctx.APIError(http.StatusUnprocessableEntity, "title is required")
return
}
wikiName := wiki_service.UserTitleToWebPath("", form.Title)
if len(form.Message) == 0 {
@@ -117,10 +122,9 @@ func EditWikiPage(ctx *context.APIContext) {
form := web.GetForm[*api.CreateWikiPageOptions](ctx)
oldWikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("pageName"))
newWikiName := wiki_service.UserTitleToWebPath("", form.Title)
if len(newWikiName) == 0 {
newWikiName = oldWikiName
newWikiName := oldWikiName
if form.Title != "" {
newWikiName = wiki_service.UserTitleToWebPath("", form.Title)
}
if len(form.Message) == 0 {
+19 -35
View File
@@ -54,71 +54,55 @@ func Profile(ctx *context.Context) {
// ProfilePost response for change user's profile
func ProfilePost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings_title")
ctx.Data["PageIsSettingsProfile"] = true
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSettingsProfile)
form := context.GetFetchActionForm[*forms.UpdateProfileForm](ctx)
if form == nil {
return
}
form := web.GetForm[*forms.UpdateProfileForm](ctx)
if form.Name != "" {
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeUsername) {
ctx.Flash.Error(ctx.Tr("user.form.change_username_disabled"))
ctx.Redirect(setting.AppSubURL + "/user/settings")
ctx.JSONErrorWithField(ctx.Tr("form.change_username_disabled"), "Name")
return
}
if err := user_service.RenameUser(ctx, ctx.Doer, form.Name, ctx.Doer); err != nil {
switch {
case user_model.IsErrUserIsNotLocal(err):
ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user"))
ctx.JSONErrorWithField(ctx.Tr("form.username_change_not_local_user"), "Name")
case user_model.IsErrUserAlreadyExist(err):
ctx.Flash.Error(ctx.Tr("form.username_been_taken"))
ctx.JSONErrorWithField(ctx.Tr("form.username_been_taken"), "Name")
case db.IsErrNameReserved(err):
ctx.Flash.Error(ctx.Tr("user.form.name_reserved", form.Name))
ctx.JSONErrorWithField(ctx.Tr("user.form.name_reserved", form.Name), "Name")
case db.IsErrNamePatternNotAllowed(err):
ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", form.Name))
ctx.JSONErrorWithField(ctx.Tr("user.form.name_pattern_not_allowed", form.Name), "Name")
case db.IsErrNameCharsNotAllowed(err):
ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", form.Name))
ctx.JSONErrorWithField(ctx.Tr("user.form.name_chars_not_allowed", form.Name), "Name")
default:
ctx.ServerError("RenameUser", err)
return
ctx.JSONErrorAuto(err)
}
ctx.Redirect(setting.AppSubURL + "/user/settings")
return
}
}
opts := &user_service.UpdateOptions{
if form.FullName != nil && user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeFullName) {
ctx.JSONErrorWithField(ctx.Tr("form.change_full_name_disabled"), "FullName")
return
}
if err := user_service.UpdateUser(ctx, ctx.Doer, &user_service.UpdateOptions{
KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
FullName: optional.FromPtr(form.FullName),
Description: optional.Some(form.Description),
Website: optional.Some(form.Website),
Location: optional.Some(form.Location),
Visibility: optional.Some(form.Visibility),
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
}
if form.FullName != "" {
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureChangeFullName) {
ctx.Flash.Error(ctx.Tr("user.form.change_full_name_disabled"))
ctx.Redirect(setting.AppSubURL + "/user/settings")
return
}
opts.FullName = optional.Some(form.FullName)
}
if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
ctx.ServerError("UpdateUser", err)
}); err != nil {
ctx.JSONErrorAuto(err)
return
}
log.Trace("User settings updated: %s", ctx.Doer.Name)
ctx.Flash.Success(ctx.Tr("settings.update_profile_success"))
ctx.Redirect(setting.AppSubURL + "/user/settings")
ctx.JSONRedirect(setting.AppSubURL + "/user/settings")
}
// UpdateAvatarSetting update user's avatar
+1 -1
View File
@@ -635,7 +635,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/user/settings", func() {
m.Get("", user_setting.Profile)
m.Post("", web.Bind[*forms.UpdateProfileForm](), user_setting.ProfilePost)
m.Post("", user_setting.ProfilePost)
m.Post("/update_preferences", user_setting.UpdatePreferences)
m.Get("/change_password", auth.MustChangePassword)
m.Post("/change_password", web.Bind[*forms.MustChangePasswordForm](), auth.MustChangePasswordPost)
+3 -15
View File
@@ -13,14 +13,7 @@ import (
"gitea.dev/modules/setting"
)
// .____ ________ _____ __________
// | | \______ \ / _ \\______ \
// | | | | \ / /_\ \| ___/
// | |___ | ` \/ | \ |
// |_______ \/_______ /\____|__ /____|
// \/ \/ \/
// Package ldap provide functions & structure to query a LDAP ldap directory
// Package ldap provide functions & structure to query an LDAP directory
// For now, it's mainly tested again an MS Active Directory service, see README.md for more information
// Source Basic LDAP authentication service
@@ -60,12 +53,8 @@ type Source struct {
UserUID string // User Attribute listed in Group
}
// FromDB fills up a LDAPConfig from serialized format.
func (source *Source) FromDB(bs []byte) error {
err := json.UnmarshalHandleDoubleEncode(bs, &source)
if err != nil {
return err
}
func (source *Source) FromDB(bs []byte) (err error) {
auth.UnmarshalAuthSourceConfig(bs, source)
if source.BindPasswordEncrypt != "" {
source.BindPassword, err = secret.DecryptSecret(setting.SecretKey, source.BindPasswordEncrypt)
if err != nil {
@@ -76,7 +65,6 @@ func (source *Source) FromDB(bs []byte) error {
return nil
}
// ToDB exports a LDAPConfig to a serialized format.
func (source *Source) ToDB() ([]byte, error) {
var err error
source.BindPasswordEncrypt, err = secret.EncryptSecret(setting.SecretKey, source.BindPassword)
+2 -3
View File
@@ -33,12 +33,11 @@ type Source struct {
ExternalIDClaim string
}
// FromDB fills up an OAuth2Config from serialized format.
func (source *Source) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &source)
auth.UnmarshalAuthSourceConfig(bs, source)
return nil
}
// ToDB exports an OAuth2Config to a serialized format.
func (source *Source) ToDB() ([]byte, error) {
return json.Marshal(source)
}
+2 -10
View File
@@ -8,13 +8,6 @@ import (
"gitea.dev/modules/json"
)
// __________ _____ _____
// \______ \/ _ \ / \
// | ___/ /_\ \ / \ / \
// | | / | \/ Y \
// |____| \____|__ /\____|__ /
// \/ \/
// Source holds configuration for the PAM login source.
type Source struct {
auth.ConfigBase `json:"-"`
@@ -23,12 +16,11 @@ type Source struct {
EmailDomain string
}
// FromDB fills up a PAMConfig from serialized format.
func (source *Source) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &source)
auth.UnmarshalAuthSourceConfig(bs, source)
return nil
}
// ToDB exports a PAMConfig to a serialized format.
func (source *Source) ToDB() ([]byte, error) {
return json.Marshal(source)
}
+2 -10
View File
@@ -8,13 +8,6 @@ import (
"gitea.dev/modules/json"
)
// _________ __________________________
// / _____/ / \__ ___/\______ \
// \_____ \ / \ / \| | | ___/
// / \/ Y \ | | |
// /_______ /\____|__ /____| |____|
// \/ \/
// Source holds configuration for the SMTP login source.
type Source struct {
auth.ConfigBase `json:"-"`
@@ -29,12 +22,11 @@ type Source struct {
DisableHelo bool
}
// FromDB fills up an SMTPConfig from serialized format.
func (source *Source) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &source)
auth.UnmarshalAuthSourceConfig(bs, source)
return nil
}
// ToDB exports an SMTPConfig to a serialized format.
func (source *Source) ToDB() ([]byte, error) {
return json.Marshal(source)
}
+5 -13
View File
@@ -8,13 +8,6 @@ import (
"gitea.dev/modules/json"
)
// _________ ___________________.___
// / _____// _____/\______ \ |
// \_____ \ \_____ \ | ___/ |
// / \/ \ | | | |
// /_______ /_______ / |____| |___|
// \/ \/
// Source holds configuration for SSPI single sign-on.
type Source struct {
auth.ConfigBase `json:"-"`
@@ -26,14 +19,13 @@ type Source struct {
DefaultLanguage string
}
// FromDB fills up an SSPIConfig from serialized format.
func (cfg *Source) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
func (source *Source) FromDB(bs []byte) error {
auth.UnmarshalAuthSourceConfig(bs, source)
return nil
}
// ToDB exports an SSPIConfig to a serialized format.
func (cfg *Source) ToDB() ([]byte, error) {
return json.Marshal(cfg)
func (source *Source) ToDB() ([]byte, error) {
return json.Marshal(source)
}
func init() {
+2 -2
View File
@@ -160,8 +160,8 @@ type IntrospectTokenForm struct {
// UpdateProfileForm form for updating profile
type UpdateProfileForm struct {
middleware.FormDefaultValidator
Name string `binding:"Username;MaxSize(40)"`
FullName string `binding:"MaxSize(100)"`
Name string `binding:"Username;MaxSize(40)"`
FullName *string `binding:"MaxSize(100)"`
KeepEmailPrivate bool
Website string `binding:"ValidSiteUrl;MaxSize(255)"`
Location string `binding:"MaxSize(50)"`
-1
View File
@@ -1 +0,0 @@
+2 -10
View File
@@ -15,17 +15,9 @@
<a id="collapse-files-btn" class="item">{{ctx.Locale.Tr "repo.pulls.collapse_files"}}</a>
{{if .Issue.Index}}
{{if .ShowOutdatedComments}}
<a class="item" href="?style={{if $.IsSplitStyle}}split{{else}}unified{{end}}&whitespace={{$.WhitespaceBehavior}}&show-outdated=false">
<label class="tw-pointer-events-none">
{{ctx.Locale.Tr "repo.issues.review.option.hide_outdated_comments"}}
</label>
</a>
<a class="item" href="?style={{if $.IsSplitStyle}}split{{else}}unified{{end}}&whitespace={{$.WhitespaceBehavior}}&show-outdated=false">{{ctx.Locale.Tr "repo.issues.review.option.hide_outdated_comments"}}</a>
{{else}}
<a class="item" href="?style={{if $.IsSplitStyle}}split{{else}}unified{{end}}&whitespace={{$.WhitespaceBehavior}}&show-outdated=true">
<label class="tw-pointer-events-none">
{{ctx.Locale.Tr "repo.issues.review.option.show_outdated_comments"}}
</label>
</a>
<a class="item" href="?style={{if $.IsSplitStyle}}split{{else}}unified{{end}}&whitespace={{$.WhitespaceBehavior}}&show-outdated=true">{{ctx.Locale.Tr "repo.issues.review.option.show_outdated_comments"}}</a>
{{end}}
{{end}}
</div>
+12 -20
View File
@@ -1,29 +1,21 @@
<div class="ui dropdown tiny basic button" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.whitespace_button"}}">
{{svg "gitea-whitespace"}}
<div class="menu">
<a class="item" href="?style={{if .IsSplitStyle}}split{{else}}unified{{end}}&whitespace=show-all&show-outdated={{$.ShowOutdatedComments}}">
<label class="tw-pointer-events-none">
<input class="tw-mr-2 tw-pointer-events-none" type="radio"{{if eq .WhitespaceBehavior "show-all"}} checked{{end}}>
{{ctx.Locale.Tr "repo.diff.whitespace_show_everything"}}
</label>
<a class="item" href="?style={{if .IsSplitStyle}}split{{else}}unified{{end}}&whitespace=show-all&show-outdated={{$.ShowOutdatedComments}}" aria-current="{{eq .WhitespaceBehavior "show-all"}}">
{{svg "octicon-check" 16 (Iif (eq .WhitespaceBehavior "show-all") "" "tw-invisible")}}
{{ctx.Locale.Tr "repo.diff.whitespace_show_everything"}}
</a>
<a class="item" href="?style={{if .IsSplitStyle}}split{{else}}unified{{end}}&whitespace=ignore-all&show-outdated={{$.ShowOutdatedComments}}">
<label class="tw-pointer-events-none">
<input class="tw-mr-2 tw-pointer-events-none" type="radio"{{if eq .WhitespaceBehavior "ignore-all"}} checked{{end}}>
{{ctx.Locale.Tr "repo.diff.whitespace_ignore_all_whitespace"}}
</label>
<a class="item" href="?style={{if .IsSplitStyle}}split{{else}}unified{{end}}&whitespace=ignore-all&show-outdated={{$.ShowOutdatedComments}}" aria-current="{{eq .WhitespaceBehavior "ignore-all"}}">
{{svg "octicon-check" 16 (Iif (eq .WhitespaceBehavior "ignore-all") "" "tw-invisible")}}
{{ctx.Locale.Tr "repo.diff.whitespace_ignore_all_whitespace"}}
</a>
<a class="item" href="?style={{if .IsSplitStyle}}split{{else}}unified{{end}}&whitespace=ignore-change&show-outdated={{$.ShowOutdatedComments}}">
<label class="tw-pointer-events-none">
<input class="tw-mr-2 tw-pointer-events-none" type="radio"{{if eq .WhitespaceBehavior "ignore-change"}} checked{{end}}>
{{ctx.Locale.Tr "repo.diff.whitespace_ignore_amount_changes"}}
</label>
<a class="item" href="?style={{if .IsSplitStyle}}split{{else}}unified{{end}}&whitespace=ignore-change&show-outdated={{$.ShowOutdatedComments}}" aria-current="{{eq .WhitespaceBehavior "ignore-change"}}">
{{svg "octicon-check" 16 (Iif (eq .WhitespaceBehavior "ignore-change") "" "tw-invisible")}}
{{ctx.Locale.Tr "repo.diff.whitespace_ignore_amount_changes"}}
</a>
<a class="item" href="?style={{if .IsSplitStyle}}split{{else}}unified{{end}}&whitespace=ignore-eol&show-outdated={{$.ShowOutdatedComments}}">
<label class="tw-pointer-events-none">
<input class="tw-mr-2 tw-pointer-events-none" type="radio"{{if eq .WhitespaceBehavior "ignore-eol"}} checked{{end}}>
{{ctx.Locale.Tr "repo.diff.whitespace_ignore_at_eol"}}
</label>
<a class="item" href="?style={{if .IsSplitStyle}}split{{else}}unified{{end}}&whitespace=ignore-eol&show-outdated={{$.ShowOutdatedComments}}" aria-current="{{eq .WhitespaceBehavior "ignore-eol"}}">
{{svg "octicon-check" 16 (Iif (eq .WhitespaceBehavior "ignore-eol") "" "tw-invisible")}}
{{ctx.Locale.Tr "repo.diff.whitespace_ignore_at_eol"}}
</a>
</div>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
{{if $attachments}}
<div class="card-attachment-images">
{{range $att := $attachments}}
<img loading="lazy" src="{{$att.DownloadURL ctx}}" alt="{{$att.Name}}" />
<img loading="lazy" src="{{$att.DownloadURL ctx}}" alt="{{$att.Name}}">
{{end}}
</div>
{{end}}
+1 -1
View File
@@ -38,7 +38,7 @@
<div class="ui two column grid">
<div class="column">
<strong>{{ctx.Locale.Tr "repo.issues.choose.blank"}}</strong>
<br/>{{ctx.Locale.Tr "repo.issues.choose.blank_about"}}
<br>{{ctx.Locale.Tr "repo.issues.choose.blank_about"}}
</div>
<div class="column tw-text-right">
<a href="{{.RepoLink}}/issues/new?{{$.PrefillQuery}}" class="ui primary button">{{ctx.Locale.Tr "repo.issues.choose.get_started"}}</a>
+6 -6
View File
@@ -466,19 +466,19 @@
</p>
<div class="ui dropdown selection">
<select name="projects_mode">
<option value="repo" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</option>
<option value="owner" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</option>
<option value="all" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</option>
<option value="repo" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.ProjectsMode "repo")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}</option>
<option value="owner" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.ProjectsMode "owner")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}</option>
<option value="all" {{if or (not $isProjectsEnabled) (eq $projectsUnit.ProjectsConfig.ProjectsMode "all")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}</option>
</select>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text">
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "repo")}}
{{if (eq $projectsUnit.ProjectsConfig.ProjectsMode "repo")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_repo"}}
{{end}}
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "owner")}}
{{if (eq $projectsUnit.ProjectsConfig.ProjectsMode "owner")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_owner"}}
{{end}}
{{if (eq $projectsUnit.ProjectsConfig.GetProjectsMode "all")}}
{{if (eq $projectsUnit.ProjectsConfig.ProjectsMode "all")}}
{{ctx.Locale.Tr "repo.settings.projects_mode_all"}}
{{end}}
</div>
+4 -4
View File
@@ -70,7 +70,7 @@
{{$addFilePath = ""}}
{{end}}
{{end}}
<button type="button" class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanContentChange}}disabled{{end}}>
<div class="ui dropdown basic compact jump button repo-add-file{{if not .Repository.CanContentChange}} disabled{{end}}"{{if not .Repository.CanContentChange}} tabindex="-1" aria-disabled="true"{{end}}>
{{ctx.Locale.Tr "repo.editor.add_file"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
@@ -86,10 +86,10 @@
{{svg "octicon-diff" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.patch"}}
</a>
</div>
</button>
</div>
{{if and (not .IsViewFile) (not $isTreePathRoot)}}
<button type="button" class="ui dropdown basic compact jump button tw-px-3" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}">
<div class="ui dropdown basic compact jump button tw-px-3" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}">
{{svg "octicon-kebab-horizontal"}}
<div class="menu">
<a class="item" data-clipboard-text="{{.Repository.HTMLURL ctx}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}">
@@ -107,7 +107,7 @@
</a>
{{end}}
</div>
</button>
</div>
{{end}}
{{end}}
<!-- Only show clone panel in repository home page -->
+2 -2
View File
@@ -3,8 +3,8 @@
<div class="content">
<div class="ui warning message">{{ctx.Locale.Tr "user.block.info"}} <a target="_blank" href="https://docs.gitea.com/usage/access-control/blocking-user">{{ctx.Locale.Tr "user.block.info.docs"}}</a></div>
<form class="ui form modal-form" method="post">
<input type="hidden" name="action" value="block" />
<input type="hidden" name="blockee" class="modal-blockee" />
<input type="hidden" name="action" value="block">
<input type="hidden" name="blockee" class="modal-blockee">
<div class="field">
<label>{{ctx.Locale.Tr "user.block.user_to_block"}}: <span class="tw-text-red modal-blockee-name"></span></label>
</div>
+18 -12
View File
@@ -14,22 +14,19 @@
<form class="ui form" action="{{.SignInLink}}" method="post">
<div class="required field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
<label for="user_name">{{ctx.Locale.Tr "home.uname_holder"}}</label>
<input id="user_name" type="text" name="user_name" value="{{.user_name}}" autofocus required tabindex="1">
<input id="user_name" type="text" name="user_name" value="{{.user_name}}" autofocus required>
</div>
{{if or (not .DisablePassword) .LinkAccountMode}}
<div class="required field {{if and (.Err_Password) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
<div class="tw-flex tw-mb-1">
<label for="password" class="tw-flex-1">{{ctx.Locale.Tr "password"}}</label>
<a href="{{AppSubUrl}}/user/forgot_password" tabindex="4">{{ctx.Locale.Tr "auth.forgot_password"}}</a>
</div>
<input id="password" name="password" type="password" value="{{.password}}" autocomplete="current-password" required tabindex="2">
<label for="password">{{ctx.Locale.Tr "password"}}</label>
<input id="password" name="password" type="password" value="{{.password}}" autocomplete="current-password" required>
</div>
{{end}}
{{if not .LinkAccountMode}}
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "auth.remember_me"}}</label>
<input name="remember" type="checkbox" tabindex="5">
<input name="remember" type="checkbox">
</div>
</div>
{{end}}
@@ -37,7 +34,7 @@
{{template "user/auth/captcha" .}}
<div class="field">
<button type="submit" class="ui primary button tw-w-full" tabindex="3">
<button type="submit" class="ui primary button tw-w-full">
{{if .LinkAccountMode}}
{{ctx.Locale.Tr "auth.oauth_signin_submit"}}
{{else}}
@@ -45,6 +42,11 @@
{{end}}
</button>
</div>
{{if or (not .DisablePassword) .LinkAccountMode}}
<div class="field tw-text-center">
<a href="{{AppSubUrl}}/user/forgot_password">{{ctx.Locale.Tr "auth.forgot_password"}}</a>
</div>
{{end}}
</form>
{{end}}{{/*end if .EnablePasswordSignInForm*/}}
{{$showExternalAuthMethods := or .OAuth2Providers .EnableOpenIDSignIn .EnableSSPI}}
@@ -59,14 +61,18 @@
{{if or .EnablePasskeyAuth .ShowRegistrationButton}}
<div class="ui container fluid">
<div class="ui attached segment header top tw-max-w-2xl tw-m-auto tw-flex tw-flex-col tw-items-center">
<div class="ui segment flex-relaxed-list tw-items-center">
{{if .EnablePasskeyAuth}}
{{template "user/auth/webauthn_error" .}}
<a class="signin-passkey">{{ctx.Locale.Tr "auth.signin_passkey"}}</a>
<div class="item signin-passkey-container">
<div class="tw-flex tw-flex-col tw-items-center">
{{template "user/auth/webauthn_error" .}}
<button type="button" class="btn interact-link signin-passkey tw-p-1">{{ctx.Locale.Tr "auth.signin_passkey"}}</button>
</div>
</div>
{{end}}
{{if .ShowRegistrationButton}}
<div class="field">
<div class="item">
<span>{{ctx.Locale.Tr "auth.need_account"}}</span>
<a href="{{AppSubUrl}}/user/sign_up">{{ctx.Locale.Tr "auth.sign_up_now"}}</a>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
</h4>
<div class="ui attached segment">
<p>{{ctx.Locale.Tr "settings.profile_desc"}}</p>
<form class="ui form" action="{{.Link}}" method="post" data-global-init="initUserSettingsForm">
<form class="ui form form-fetch-action" action="{{.Link}}" method="post" data-global-init="initUserSettingsForm">
<div class="required field {{if .Err_Name}}error{{end}}">
<label for="username">{{ctx.Locale.Tr "username"}}
<span class="tw-text-red tw-hidden" id="name-change-prompt"> {{ctx.Locale.Tr "settings.change_username_prompt"}}</span>
+13 -3
View File
@@ -7,11 +7,21 @@ test('homepage', async ({page}) => {
await expect(page.getByRole('img', {name: 'Logo'})).toHaveAttribute('src', '/assets/img/logo.svg');
});
test('login form and logout', async ({page}) => {
test('login form in tab order and logout', async ({page}) => {
await page.goto('/user/login');
await page.getByLabel('Username or Email Address').fill(env.GITEA_TEST_E2E_USER);
await page.getByLabel('Password').fill(env.GITEA_TEST_E2E_PASSWORD);
await page.getByRole('button', {name: 'Sign In'}).click();
await page.keyboard.press('Tab');
const password = page.getByLabel('Password');
await expect(password).toBeFocused();
await password.fill(env.GITEA_TEST_E2E_PASSWORD);
await page.keyboard.press('Tab');
await expect(page.getByLabel('Remember This Device')).toBeFocused();
await page.keyboard.press('Tab');
const signIn = page.getByRole('button', {name: 'Sign In', exact: true});
await expect(signIn).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('link', {name: 'Forgot password?'})).toBeFocused();
await signIn.click();
await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden();
await logout(page);
});
+2
View File
@@ -9,9 +9,11 @@ test('update profile biography', async ({page, request}) => {
await page.goto('/user/settings');
await page.getByLabel('Biography').fill(bio);
await page.getByRole('button', {name: 'Update Profile'}).click();
await expect(page.getByText('Your profile has been updated.', {exact: true})).toBeVisible();
await expect(page.getByLabel('Biography')).toHaveValue(bio);
await page.getByLabel('Biography').fill('');
await page.getByRole('button', {name: 'Update Profile'}).click();
await expect(page.getByText('Your profile has been updated.', {exact: true})).toBeVisible();
await expect(page.getByLabel('Biography')).toHaveValue('');
});
+1 -1
View File
@@ -15,7 +15,7 @@ async function signInWithPassword(page: Page, username: string) {
await page.goto('/user/login');
await page.getByLabel('Username or Email Address').fill(username);
await page.getByLabel('Password').fill(testUserPassword);
await page.getByRole('button', {name: 'Sign In'}).click();
await page.getByRole('button', {name: 'Sign In', exact: true}).click();
}
// regression: credProtect level 3 hid the credential from the second-factor login
@@ -785,7 +785,7 @@ func TestPackageContainer(t *testing.T) {
"email": "user2@example.com",
"language": "en-US",
})
session.MakeRequest(t, req, http.StatusSeeOther)
session.MakeRequest(t, req, http.StatusOK)
t.Run(fmt.Sprintf("Catalog[%s]", newOwnerName), checkCatalog(newOwnerName))
@@ -794,6 +794,6 @@ func TestPackageContainer(t *testing.T) {
"email": "user2@example.com",
"language": "en-US",
})
session.MakeRequest(t, req, http.StatusSeeOther)
session.MakeRequest(t, req, http.StatusOK)
})
}
+19 -7
View File
@@ -172,6 +172,17 @@ func TestAPIGetWikiPage(t *testing.T) {
session := loginUser(t, username)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
for _, title := range []string{"", " "} {
content := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "Edited with title %q", title))
req := NewRequestWithJSON(t, "PATCH", "/api/v1/repos/user2/repo1/wiki/page/Home", &api.CreateWikiPageOptions{
Title: title,
ContentBase64: content,
}).AddTokenAuth(token)
page := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.WikiPage{})
assert.Equal(t, "Home", page.Title)
assert.Equal(t, content, page.ContentBase64)
}
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/Page-With-Spaced-Name", username, "repo1")
req := NewRequestWithJSON(t, "PATCH", urlStr, &api.CreateWikiPageOptions{
@@ -197,13 +208,14 @@ func testAPICreateWikiPage(t *testing.T, session *TestSession, userName, repoNam
}
func TestAPINewWikiPage(t *testing.T) {
for _, title := range []string{
"New page",
"&&&&",
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
for title, status := range map[string]int{
"New page": http.StatusCreated,
"&&&&": http.StatusCreated,
"": http.StatusUnprocessableEntity,
" ": http.StatusUnprocessableEntity,
} {
defer tests.PrepareTestEnv(t)()
username := "user2"
session := loginUser(t, username)
testAPICreateWikiPage(t, session, username, "repo1", title, http.StatusCreated)
testAPICreateWikiPage(t, session, "user2", "repo1", title, status)
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ func testPrivateActivityHelperEnablePrivateActivity(t *testing.T) {
"language": "en-US",
"keep_activity_private": "1",
})
session.MakeRequest(t, req, http.StatusSeeOther)
session.MakeRequest(t, req, http.StatusOK)
}
func testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc *HTMLDoc) bool {
+175 -32
View File
@@ -10,14 +10,36 @@ import (
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/translation"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
func TestUserSettings(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("RevokeOAuth2GrantOfOtherUser", testRevokeOAuth2GrantOfOtherUser)
t.Run("Account", testUserSettingsAccount)
t.Run("UpdatePassword", testUserSettingsUpdatePassword)
t.Run("UpdateEmail", testUserSettingsUpdateEmail)
t.Run("DeleteEmail", testUserSettingsDeleteEmail)
t.Run("Delete", testUserSettingsDelete)
t.Run("Pages", testUserSettingsPages)
t.Run("Security", testUserSettingsSecurity)
t.Run("Applications", testUserSettingsApplications)
t.Run("Keys", testUserSettingsKeys)
t.Run("Secrets", testUserSettingsSecrets)
t.Run("RenameInvalidUsername", testRenameInvalidUsername)
t.Run("RenameReservedUsername", testRenameReservedUsername)
t.Run("ChangeFullName", testChangeFullName)
t.Run("LocationMapLink", testUserLocationMapLink)
t.Run("RenameUsername", testRenameUsername)
}
// Validate that each navbar setting is correct. This checks that the
// appropriate context is passed everywhere the navbar is rendered
func assertNavbar(t *testing.T, doc *HTMLDoc) {
@@ -56,9 +78,7 @@ func WithDisabledFeatures(t *testing.T, features ...string) {
})
}
func TestRevokeOAuth2GrantOfOtherUser(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testRevokeOAuth2GrantOfOtherUser(t *testing.T) {
// grant 1 belongs to user1, so user2 must not be able to act on it
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user/settings/applications/oauth2/1/revoke/1", nil)
@@ -68,9 +88,7 @@ func TestRevokeOAuth2GrantOfOtherUser(t *testing.T) {
assert.EqualValues(t, 1, grant.UserID)
}
func TestUserSettingsAccount(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testUserSettingsAccount(t *testing.T) {
for _, testCase := range []struct {
name string
features []string
@@ -123,9 +141,7 @@ func TestUserSettingsAccount(t *testing.T) {
})
}
func TestUserSettingsUpdatePassword(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testUserSettingsUpdatePassword(t *testing.T) {
t.Run("enabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
@@ -148,9 +164,7 @@ func TestUserSettingsUpdatePassword(t *testing.T) {
})
}
func TestUserSettingsUpdateEmail(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testUserSettingsUpdateEmail(t *testing.T) {
t.Run("credentials disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
@@ -183,9 +197,7 @@ func TestUserSettingsUpdateEmail(t *testing.T) {
}
}
func TestUserSettingsDeleteEmail(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testUserSettingsDeleteEmail(t *testing.T) {
t.Run("credentials disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
@@ -196,9 +208,7 @@ func TestUserSettingsDeleteEmail(t *testing.T) {
})
}
func TestUserSettingsDelete(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testUserSettingsDelete(t *testing.T) {
t.Run("deletion disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
@@ -210,10 +220,10 @@ func TestUserSettingsDelete(t *testing.T) {
})
}
func TestUserSettingsPages(t *testing.T) {
func testUserSettingsPages(t *testing.T) {
for _, page := range []string{"appearance", "packages", "packages/rules/add", "organization", "repos", "blocked_users"} {
t.Run(page, func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user/settings/"+page)
@@ -225,9 +235,7 @@ func TestUserSettingsPages(t *testing.T) {
}
}
func TestUserSettingsSecurity(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testUserSettingsSecurity(t *testing.T) {
for _, testCase := range []struct {
name string
feature string
@@ -261,9 +269,7 @@ func TestUserSettingsSecurity(t *testing.T) {
})
}
func TestUserSettingsApplications(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testUserSettingsApplications(t *testing.T) {
t.Run("Applications", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
@@ -342,9 +348,7 @@ func TestUserSettingsApplications(t *testing.T) {
})
}
func TestUserSettingsKeys(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testUserSettingsKeys(t *testing.T) {
for _, testCase := range []struct {
name string
feature string
@@ -384,9 +388,7 @@ func TestUserSettingsKeys(t *testing.T) {
})
}
func TestUserSettingsSecrets(t *testing.T) {
defer tests.PrepareTestEnv(t)()
func testUserSettingsSecrets(t *testing.T) {
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user/settings/actions/secrets")
if setting.Actions.Enabled {
@@ -398,3 +400,144 @@ func TestUserSettingsSecrets(t *testing.T) {
session.MakeRequest(t, req, http.StatusNotFound)
}
}
func testRenameInvalidUsername(t *testing.T) {
invalidUsernames := []string{
"%2f*",
"%2f.",
"%2f..",
"%00",
"thisHas ASpace",
"p<A>tho>lo<gical",
".",
"..",
".well-known",
".abc",
"abc.",
"a..bc",
"a...bc",
"a.-bc",
"a._bc",
"a_-bc",
"a/bc",
"☁️",
"-",
"--diff",
"-im-here",
"a space",
}
session := loginUser(t, "user2")
for _, invalidUsername := range invalidUsernames {
t.Logf("Testing username %s", invalidUsername)
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
"name": invalidUsername,
"email": "user2@example.com",
})
resp := session.MakeRequest(t, req, http.StatusBadRequest)
data := DecodeJSON(t, resp, map[string]any{})
assert.Equal(t, []any{"Name"}, data["errorFields"])
assert.Contains(t,
data["errorMessage"],
translation.NewLocale("en-US").TrString("form.username_error", "Name"),
)
unittest.AssertNotExistsBean(t, &user_model.User{Name: invalidUsername})
}
}
func testRenameReservedUsername(t *testing.T) {
// ".", "..", ".well-known" are also reserved but invalid as form input.
reservedNames := []string{
"api",
"openapi3.v1.json",
"swagger.v1.json",
}
patternNotAllowedNames := []string{
"name.keys",
}
session := loginUser(t, "user2")
locale := translation.NewLocale("en-US")
check := func(name, msgKey string) {
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
"name": name,
"email": "user2@example.com",
"language": "en-US",
})
resp := session.MakeRequest(t, req, http.StatusBadRequest)
data := DecodeJSON(t, resp, map[string]any{})
assert.Equal(t, locale.TrString(msgKey, name), data["errorMessage"])
assert.Equal(t, []any{"Name"}, data["errorFields"])
unittest.AssertNotExistsBean(t, &user_model.User{Name: name})
}
for _, name := range reservedNames {
check(name, "user.form.name_reserved")
}
for _, name := range patternNotAllowedNames {
check(name, "user.form.name_pattern_not_allowed")
}
}
func testChangeFullName(t *testing.T) {
session := loginUser(t, "user2")
postSettings := func(t *testing.T, values map[string]string, expectedStatus int) map[string]any {
t.Helper()
resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user/settings", values), expectedStatus)
return DecodeJSON(t, resp, map[string]any{})
}
t.Run("Change", func(t *testing.T) {
postSettings(t, map[string]string{"full_name": "New Name"}, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2", FullName: "New Name"})
})
t.Run("Disabled", func(t *testing.T) {
defer test.MockVariableValue(&setting.Admin.UserDisabledFeatures, container.SetOf(setting.UserFeatureChangeFullName))()
postSettings(t, map[string]string{"location": "Elsewhere"}, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2", FullName: "New Name", Location: "Elsewhere"})
data := postSettings(t, map[string]string{"full_name": "Other Name"}, http.StatusBadRequest)
assert.Equal(t, []any{"FullName"}, data["errorFields"])
assert.Equal(t, "Changing full name is disabled.", data["errorMessage"])
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2", FullName: "New Name"})
})
t.Run("Clear", func(t *testing.T) {
postSettings(t, map[string]string{"full_name": ""}, http.StatusOK)
assert.Empty(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}).FullName)
})
}
func testRenameUsername(t *testing.T) {
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
"name": "newUsername",
"email": "user2@example.com",
"language": "en-US",
})
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "/user/settings", DecodeJSON(t, resp, map[string]any{})["redirect"])
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "newUsername"})
unittest.AssertNotExistsBean(t, &user_model.User{Name: "user2"})
}
func testUserLocationMapLink(t *testing.T) {
defer test.MockVariableValue(&setting.Service.UserLocationMapURL, "https://example/foo/")()
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
"name": "user2",
"email": "user@example.com",
"language": "en-US",
"location": "A/b",
})
session.MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", "/user2/")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
AssertHTMLElement(t, htmlDoc, `a[href="https://example/foo/A%2Fb"]`, true)
}
+1 -119
View File
@@ -5,7 +5,6 @@ package integration
import (
"net/http"
"strings"
"testing"
asymkey_model "gitea.dev/models/asymkey"
@@ -18,7 +17,6 @@ import (
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/modules/translation"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -29,14 +27,10 @@ func TestUser(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("ViewUser", testViewUser)
t.Run("FollowUser", testUserFollow)
t.Run("RenameInvalidUsername", testRenameInvalidUsername)
t.Run("RenameReservedUsername", testRenameReservedUsername)
t.Run("ViewLimitedAndPrivateUserAndRename", testViewLimitedAndPrivateUserAndRename)
t.Run("ExportUserGPGKeys", testExportUserGPGKeys)
t.Run("GetUserRss", testGetUserRss)
t.Run("ListStopWatches", testUserListStopWatches)
t.Run("LocationMapLink", testUserLocationMapLink)
t.Run("RenameUsername", testRenameUsername)
}
func testViewUser(t *testing.T) {
@@ -71,19 +65,6 @@ ssh-rsa AAAA
`, resp.Body.String())
}
func testRenameUsername(t *testing.T) {
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
"name": "newUsername",
"email": "user2@example.com",
"language": "en-US",
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "newUsername"})
unittest.AssertNotExistsBean(t, &user_model.User{Name: "user2"})
}
func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
// user 22 is a limited visibility org
org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22})
@@ -146,7 +127,7 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
"name": newName,
"visibility": "2", // private
})
session2.MakeRequest(t, req, http.StatusSeeOther)
session2.MakeRequest(t, req, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: newName})
unittest.AssertNotExistsBean(t, &user_model.User{Name: oldName})
@@ -157,87 +138,6 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) {
session.MakeRequest(t, req, http.StatusTemporaryRedirect) // login user2 can visit private visibility user via old name
}
func testRenameInvalidUsername(t *testing.T) {
invalidUsernames := []string{
"%2f*",
"%2f.",
"%2f..",
"%00",
"thisHas ASpace",
"p<A>tho>lo<gical",
".",
"..",
".well-known",
".abc",
"abc.",
"a..bc",
"a...bc",
"a.-bc",
"a._bc",
"a_-bc",
"a/bc",
"☁️",
"-",
"--diff",
"-im-here",
"a space",
}
session := loginUser(t, "user2")
for _, invalidUsername := range invalidUsernames {
t.Logf("Testing username %s", invalidUsername)
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
"name": invalidUsername,
"email": "user2@example.com",
})
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Contains(t,
htmlDoc.doc.Find(".ui.negative.message").Text(),
translation.NewLocale("en-US").TrString("form.username_error", "Name"),
)
unittest.AssertNotExistsBean(t, &user_model.User{Name: invalidUsername})
}
}
func testRenameReservedUsername(t *testing.T) {
// ".", "..", ".well-known" are also reserved but invalid as form input.
reservedNames := []string{
"api",
"openapi3.v1.json",
"swagger.v1.json",
}
patternNotAllowedNames := []string{
"name.keys",
}
session := loginUser(t, "user2")
locale := translation.NewLocale("en-US")
check := func(name, msgKey string) {
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
"name": name,
"email": "user2@example.com",
"language": "en-US",
})
resp := session.MakeRequest(t, req, http.StatusSeeOther)
req = NewRequest(t, "GET", test.RedirectURL(resp))
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
actualMsg := strings.TrimSpace(htmlDoc.doc.Find(".ui.negative.message").Text())
assert.Equal(t, locale.TrString(msgKey, name), actualMsg)
unittest.AssertNotExistsBean(t, &user_model.User{Name: name})
}
for _, name := range reservedNames {
check(name, "user.form.name_reserved")
}
for _, name := range patternNotAllowedNames {
check(name, "user.form.name_pattern_not_allowed")
}
}
func testExportUserGPGKeys(t *testing.T) {
testExportUserGPGKeys := func(t *testing.T, user, expected string) {
t.Helper()
@@ -370,24 +270,6 @@ func testUserListStopWatches(t *testing.T) {
}
}
func testUserLocationMapLink(t *testing.T) {
defer test.MockVariableValue(&setting.Service.UserLocationMapURL, "https://example/foo/")()
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
"name": "user2",
"email": "user@example.com",
"language": "en-US",
"location": "A/b",
})
session.MakeRequest(t, req, http.StatusSeeOther)
req = NewRequest(t, "GET", "/user2/")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
AssertHTMLElement(t, htmlDoc, `a[href="https://example/foo/A%2Fb"]`, true)
}
func testUserFollow(t *testing.T) {
bot := &user_model.User{
Name: "follow-bot",
+10
View File
@@ -331,11 +331,21 @@ a.suppressed:hover {
a:focus-visible,
button:focus-visible,
.ui.button:focus-visible,
.ui.dropdown:not(.selection):focus-visible,
[role="button"]:focus-visible {
outline: 2px solid var(--color-primary);
}
button:focus-visible,
.ui.button:focus-visible {
outline-offset: -2px;
}
.ui:is(.primary, .red, .green).button:focus-visible,
.ui:is(.primary, .red, .green).buttons .button:focus-visible {
outline-color: var(--color-text-dark);
}
a.silenced:hover {
text-decoration-line: none;
}
+4
View File
@@ -30,6 +30,10 @@ Gitea's private styles use `g-` prefix.
.interact-bg:hover { background: var(--color-hover) !important; }
.interact-bg:active { background: var(--color-active) !important; }
/* sometimes, we'd like to make "<button>" look like a link */
.interact-link { color: var(--color-primary); cursor: pointer; }
.interact-link:hover { text-decoration: underline; }
/* primary-color dot centered on the top-right corner of the element */
.indicator-dot {
position: relative;
+1
View File
@@ -306,6 +306,7 @@
border: none;
}
/* sometimes, we'd like to make "<a href>" look like a button */
a.btn,
a.btn:hover {
color: inherit;
+2 -1
View File
@@ -17,7 +17,8 @@ export function initUserAuthWebAuthn() {
const errorType = detectWebAuthnSupport();
if (errorType) {
if (elSignInPasskeyBtn) hideElem(elSignInPasskeyBtn);
// no webauthn support, completely hide the passkey related UI elements, including the error message
if (elSignInPasskeyBtn) hideElem(elSignInPasskeyBtn.closest('.signin-passkey-container')!);
return;
}