Compare commits

...
Author SHA1 Message Date
d 0169937741 Per-domain nonce for GPG auth (#10)
nonces for GPG-based federated auth are only generated per-domain

Reviewed-on: #10
2026-06-18 20:47:52 +00:00
d 3d9efe626d Merge branch 'x-main' 2026-06-18 23:43:22 +03:00
fwagandGitHub 9c82394315 fix: Fix issue target branch selection for non-collaborators (#36916)
This PR fixes a bug in the UI that prevented non-collaborator users (the
issue poster or creator) from setting the target branch (ref) of an
issue. The backend API already supports this, but the UI was rigidly
disabling the dropdown based only on collaborator status.

Changes:
- Enable the branch selector for the issue poster and during new issue
creation.
- Fix a typo (.IsIssueWriter -> .IsIssuePoster) that was preventing the
reference update URL from being correctly set for posters.
2026-06-18 12:24:37 +00:00
de83393487 refactor: replace legacy delete-button with link-action (#38143)
Removes the legacy `delete-button` handler (`initGlobalDeleteButton`)
and migrates all remaining usages to `link-action` and `show-modal` /
`form-fetch-action`.

Two handlers are adjusted for the new request shape: webauthn key delete
reads `id` from the query, and account deletion returns `JSONError` on
validation failure.

A E2E test ist added to cover one of the use cases.

Suggested in
https://github.com/go-gitea/gitea/pull/38046#discussion_r3414936737.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-06-18 12:02:11 +00:00
Lunny XiaoandGitHub 64f3796567 fix: Fix the panic when ssh remote lfs endpoint parsing failure (#38026)
Fix #38016
2026-06-18 07:54:16 +02:00
bircniandGitHub 240d0efa7e perf: extend action c_u index to include created_unix for faster dashboard feeds (#38076)
Adds `created_unix` as the third column of the `c_u` composite index on
the `action` table, changing it from `(user_id, is_deleted)` to
`(user_id, is_deleted, created_unix)`.

Migration 337 drops and recreates the index. No data is touched.

## Root causes

#32333 introduced the `c_u` index to speed up dashboard queries, but
defined it as `(user_id, is_deleted)` — without `created_unix`.

#3368 The simple query is now efficient enough for the database to
actually use `c_u`, but because `created_unix` is absent from the index,
the database must load and sort **every** matching row before returning
the first page of 20.

The existing `c_u_d` index `(created_unix, user_id, is_deleted)` does
not help because its leading column is `created_unix`, which can't be
used for an equality seek on `user_id`.

Those two caused this issue:
https://github.com/go-gitea/gitea/issues/38075

With the fix, the database seeks directly to `(user_id=X,
is_deleted=false)` and walks `created_unix` in descending order,
stopping after 20 rows.

Fixes https://github.com/go-gitea/gitea/issues/38075
2026-06-17 23:37:55 +03:00
68692e19d4 fix: Various security fixes (#38103)
- Enforce org visibility on organization label read endpoints (private
org labels no longer leak to non-members).
- Block fork sync (`merge-upstream`) when the base repo is no longer
readable (stops pulling commits after a parent goes private).
- Remove `REVERSE_PROXY_LIMIT` / `REVERSE_PROXY_TRUSTED_PROXIES` from
the Docker `app.ini` templates (the `= *` default allowed
`X-WEBAUTH-USER` impersonation; reverse-proxy auth is now opt-in and
admin-configured).
- Enforce single-use TOTP passcodes across web login, password-reset,
and Basic-Auth `X-Gitea-OTP` (fixes a TOCTOU race and a stateless
replay).
- Re-check branch write permission for every ref in a push (the
pre-receive hook cached the first ref's result, letting a per-branch
maintainer-edit grant escalate to full repo write).

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-06-17 16:06:51 +00:00
c68925152b docs: add development setup guide (#37960)
Moves the "Hacking on Gitea" page out of the documentation website and into the repository as `docs/development.md`, so contributors find build and test instructions next to the code. The content has been cleaned up and corrected for in-repo use.

---------

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-17 06:39:22 +00:00
9e84deb969 fix: Various sec fixes 2 (#38108)
- Enforce repository token scope on RSS/Atom feed endpoints so a PAT
without repo scope can no longer read private repo commit data.
- Block HTTP redirects during repository migration clones to prevent
SSRF reaching internal addresses via an attacker-controlled redirect.
- Redact the notification subject after repo access is revoked so
private issue/PR metadata is no longer leaked through the notification
API.

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-17 06:50:25 +02:00
GiteaBot 795531cea0 [skip ci] Updated translations via Crowdin 2026-06-17 01:24:58 +00:00
Lunny XiaoandGitHub 0be7543560 fix(mssql): expand legacy issue and comment long-text columns (#38120)
## Summary

This fixes pull request creation failures on upgraded MSSQL instances
where legacy `issue` and `comment` long-text columns are still limited
to `nvarchar(4000)`.

When a PR is created, Gitea stores a pull request push timeline comment
containing JSON with `commit_ids`. For PRs with many commits, that
payload can exceed 4000 characters and MSSQL rejects the insert with:

> String or binary data would be truncated in table 'comment', column
'content'

This change adds a migration that expands the affected legacy MSSQL
columns to `NVARCHAR(MAX)`.

The previous migration in models/migrations/v1_16/v191.go only applies
to MySQL, not MSSQL.

migration now skips columns already using NVARCHAR(MAX) / VARCHAR(MAX)

Closes #37893

## Changes

- add migration `338` for MSSQL-only long-text expansion
- expand:
  - `issue.content`
  - `comment.content`
  - `comment.patch`
- add an MSSQL regression test that starts from a legacy `VARCHAR(4000)`
schema and verifies inserts larger than 4000 characters succeed after
migration

## Why this approach

The current model already declares these fields as `LONGTEXT`, so the
bug is caused by stale upgraded MSSQL schemas rather than by PR creation
logic itself. Fixing the schema is the smallest and safest change, and
also prevents similar truncation issues for other long issue/comment
content.
2026-06-16 17:40:13 +00:00
87 changed files with 1661 additions and 514 deletions
+15 -35
View File
@@ -2,12 +2,17 @@
This document explains how to contribute changes to the Gitea project. Topic-specific guides live in separate files so the essentials are easier to find.
| Topic | Document |
| :---- | :------- |
| Backend (Go modules, API v1) | [docs/guideline-backend.md](docs/guideline-backend.md) |
| Frontend (npm, UI guidelines) | [docs/guideline-frontend.md](docs/guideline-frontend.md) |
| Maintainers, TOC, labels, merge queue, commit format for mergers | [docs/community-governance.md](docs/community-governance.md) |
| Release cycle, backports, tagging releases | [docs/release-management.md](docs/release-management.md) |
| Topic | Document |
|:-----------------------|:-----------------------------------------------------------------|
| Setup and requirements | [docs/build-setup.md](docs/build-setup.md) |
| Development workflow | [docs/development.md](docs/development.md) |
| Build from source | [docs/build-source.md](docs/build-source.md) |
| Running the tests | [docs/testing.md](docs/testing.md) |
| Frontend guidelines | [docs/guidelines-frontend.md](docs/guidelines-frontend.md) |
| Backend guidelines | [docs/guidelines-backend.md](docs/guidelines-backend.md) |
| Refactoring | [docs/guidelines-refactoring.md](docs/guidelines-refactoring.md) |
| Community Governance | [docs/community-governance.md](docs/community-governance.md) |
| Release management | [docs/release-management.md](docs/release-management.md) |
<details><summary>Table of Contents</summary>
@@ -43,7 +48,7 @@ This document explains how to contribute changes to the Gitea project. Topic-spe
It assumes you have followed the [installation instructions](https://docs.gitea.com/category/installation). \
Sensitive security-related issues should be reported to [security@gitea.io](mailto:security@gitea.io).
For configuring IDEs for Gitea development, see the [contributed IDE configurations](contrib/ide/).
For configuring IDEs for Gitea development, see the [IDE setup notes](docs/development.md#ide-configuration) and the [contributed configurations](contrib/development/).
## AI Contribution Policy
@@ -106,7 +111,8 @@ If further discussion is needed, we encourage you to open a new issue instead an
## Building Gitea
See the [development setup instructions](https://docs.gitea.com/development/hacking-on-gitea).
See [docs/setup.md](docs/setup.md) for prerequisites and [docs/development.md](docs/development.md)
for building Gitea and the development workflow.
## Styleguide
@@ -125,33 +131,7 @@ Afterwards, copyright should only be modified when the copyright author changes.
## Testing
Before submitting a pull request, run all tests to make sure your changes don't cause a regression elsewhere.
Here's how to run the test suite:
- code lint
| | |
| :-------------------- | :--------------------------------------------------------------------------- |
|``make lint`` | lint everything (not needed if you only change the front- **or** backend) |
|``make lint-frontend`` | lint frontend files |
|``make lint-backend`` | lint backend files |
- run tests (we suggest running them on Linux)
| Command | Action | |
|:----------------------------------------------|:-----------------------------------------------------| ------------------------------------------- |
| ``make test-backend[\#SpecificTestName]`` | run unit test(s) | |
| ``make test-integration[\#SpecificTestName]`` | run [integration](tests/integration) test(s) | [More details](tests/integration/README.md) |
| ``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | |
- E2E test environment variables
| Variable | Description |
| :-------------------------------- | :---------------------------------------------------------- |
| ``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output |
| ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright, for example ``--ui`` |
| ``GITEA_TEST_E2E_TIMEOUT_FACTOR`` | Timeout multiplier (default: 4 on CI, 1 locally) |
Before submitting a pull request, run the linters (`make lint`, or the scoped `make lint-backend` / `make lint-frontend`) and the tests to make sure your changes don't cause a regression elsewhere. See [docs/testing.md](docs/testing.md) for how to run the unit, integration, end-to-end, and migration tests.
## Translation
+3 -1
View File
@@ -1,12 +1,14 @@
# IDE and code editor configuration
## Table of Contents
- [IDE and code editor configuration](#ide-and-code-editor-configuration)
- [Microsoft Visual Studio Code](#microsoft-visual-studio-code)
## Microsoft Visual Studio Code
Download Microsoft Visual Studio Code at https://code.visualstudio.com/ and follow instructions at https://code.visualstudio.com/docs/languages/go to setup Go extension for it.
Create new directory `.vscode` in Gitea root folder and copy contents of folder [contrib/ide/vscode](vscode/) to it. You can now use `Ctrl`+`Shift`+`B` to build gitea executable and `F5` to run it in debug mode.
Create new directory `.vscode` in Gitea root folder and copy contents of folder [contrib/development/vscode](vscode/) to it. You can now use `Ctrl`+`Shift`+`B` to build gitea executable and `F5` to run it in debug mode.
Supported on Debian, Ubuntu, Red Hat, Fedora, SUSE Linux, MacOS and Microsoft Windows.
-2
View File
@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = $INSTALL_LOCK
SECRET_KEY = $SECRET_KEY
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service]
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
-2
View File
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
[security]
INSTALL_LOCK = $INSTALL_LOCK
SECRET_KEY = $SECRET_KEY
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service]
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
+67
View File
@@ -0,0 +1,67 @@
# Setup and requirements
This document lists the tools you need to build Gitea from source and how to get
the code. Once your environment is ready, see [development.md](development.md) for
the build and development workflow, and [testing.md](testing.md) for running tests.
For the contribution workflow and review process, see [CONTRIBUTING.md](../CONTRIBUTING.md).
## Requirements
### Go
[Install Go](https://go.dev/doc/install) and set up your Go environment. The
required version is the one declared in [`go.mod`](../go.mod); installing the same
version your continuous integration uses avoids `gofmt` differences between Go
releases.
> [!NOTE]
> Some `make` tasks build external Go tools on demand (for example `make
> watch-backend`). To use them, the `"$GOPATH"/bin` directory must be on your
> executable `PATH`; otherwise you have to manage those tools yourself.
### Node.js and pnpm
[Install Node.js](https://nodejs.org/en/download/) to build the JavaScript and CSS
files. The minimum supported version is the one declared in
[`package.json`](../package.json) (`engines.node`); the latest LTS is recommended.
Gitea manages frontend dependencies with [pnpm](https://pnpm.io/). The `make`
targets invoke it for you, so installing pnpm manually is only needed if you want
to run `pnpm` commands directly.
### Make
Gitea uses [Make](https://www.gnu.org/software/make/) to drive builds, linting, and
tests. On Windows it can be installed via [MSYS2](https://www.msys2.org/) or
[Chocolatey](https://chocolatey.org/packages/make).
### Python with uv (optional)
Linting the templates, workflow files, and YAML requires Python tooling that Gitea
runs through [uv](https://docs.astral.sh/uv/). After installing uv, `make` creates
the environment automatically (`uv sync`); you only need this if you run
`make lint-templates`, `make lint-yaml`, or `make lint-actions` locally.
### Git LFS
The integration tests require [Git LFS](https://git-lfs.com/) to be installed.
## Getting the source code
Clone the repository:
```bash
git clone https://github.com/go-gitea/gitea
```
To contribute changes, [fork the repository](https://github.com/go-gitea/gitea) on
GitHub and add your fork as a git remote so you can push branches and open pull
requests. See GitHub's [working with forks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks)
documentation for the details.
## Installing dependencies
Most build and test targets install the dependencies they need on their own. To
fetch everything up front, run `make deps` (or the per-group `make deps-frontend`,
`make deps-backend`, `make deps-tools`, `make deps-py`).
+92
View File
@@ -0,0 +1,92 @@
# Prepare build environment
Complete the steps in [build-setup.md](build-setup.md) to prepare your environment for building Gitea from source.
## Choose a branch
By default, the cloned repository is on main branch (the current development branch for next major release, aka: main nightly).
You can switch to a versioned branch (the branch for the next minor stable release, aka: stable nightly )
or a versioned tag (matches the official releases with version numbers)
To test a Pull Request, you can fetch its code by its Pull Request number (take `PR #123456` as example):
```bash
git fetch origin pull/123456/head:pr-123456
```
# Build
Various [make tasks](https://github.com/go-gitea/gitea/blob/main/Makefile)
are provided to keep the build process as simple as possible.
Depending on requirements, the following build tags can be included.
- `bindata`: Build a single monolithic binary, with all assets included. Required for distribution and production build.
- `pam`: Enable support for PAM (Linux Pluggable Authentication Modules).
Can be used to authenticate local users or extend authentication to methods available to PAM.
- `gogit`: (EXPERIMENTAL) Use go-git variants of Git commands.
To include all assets, use the `bindata` tag:
```bash
TAGS="bindata" make build
```
Tag `gogit` is used to try to resolve some Windows-specific performance problems, POSIX systems don't need it.
You can build a Windows binary by:
```bash
GOOS=windows TAGS="bindata gogit" make build
```
## Changing default paths
Gitea will search for a number of things from the _`CustomPath`_.
By default, this is the `custom/` directory in the current working directory when running Gitea.
It will also look for its configuration file _`CustomConf`_ in `$(CustomPath)/conf/app.ini`,
and will use the current working directory as the relative base path _`AppWorkPath`_.
These values, although useful when developing, may conflict with downstream users preferences.
For packagers who need to use paths like `/etc/gitea/app.ini`,
they should define these values at build time for `make build` by environment variable like
`LDFLAGS='-X "module.Var1=Value1" -X "module.Var2=Value2"' TAGS="bindata" make build`.
- _`CustomConf`_: `-X "code.gitea.io/gitea/modules/setting.CustomConf=/etc/gitea/app.ini"`
- _`AppWorkPath`_: `-X "code.gitea.io/gitea/modules/setting.AppWorkPath=/var/lib/gitea"`
- _`CustomPath`_: `-X "code.gitea.io/gitea/modules/setting.CustomPath=/var/lib/gitea/custom"`
- Default PID file location: `-X "code.gitea.io/gitea/cmd.PIDFile=/run/gitea.pid"`
Add as many of the strings with their preceding `-X` to the `LDFLAGS` variable and run `make build`
with the appropriate `TAGS` as above.
Running `gitea help` will allow you to review what the computed settings will be for your `gitea`.
## Cross Build
Gitea use's Golang's toolchain variables for cross-building.
For example, to cross build for Linux ARM64:
```
GOOS=linux GOARCH=arm64 TAGS="bindata" make build
```
### Adding shell autocompletion
Shell completion can be generated directly from binary with:
```sh
gitea completion <shell>
```
Supported values for `<shell>` are `bash`, `fish`, `pwsh` and `zsh`.
Details on how to load the completion for your shell can be found in the completion command help.
## Source Maps
By default, gitea generates reduced source maps for frontend files to conserve space. This can be controlled with the `ENABLE_SOURCEMAP` environment variable:
- `ENABLE_SOURCEMAP=true` generates all source maps, the default for development builds
- `ENABLE_SOURCEMAP=reduced` generates limited source maps, the default for production builds
- `ENABLE_SOURCEMAP=false` generates no source maps
+141
View File
@@ -0,0 +1,141 @@
# Development
This document describes how to build Gitea from source and the day-to-day
development workflow. For prerequisites and how to obtain the code, see
[build-setup.md](build-setup.md). For running tests, see [testing.md](testing.md). For the
contribution workflow and review process, see [CONTRIBUTING.md](../CONTRIBUTING.md).
Area-specific guidelines:
- [Backend development guidelines](guidelines-backend.md)
- [Frontend development guidelines](guidelines-frontend.md)
- [Refactoring guidelines](guidelines-refactoring.md)
## Building
To build Gitea for development, run:
```bash
make build
```
No build tags are required: SQLite support is compiled in by default, which is
enough for local development. The `build` target runs two sub-targets, `frontend`
and `backend`. The `bindata` tag embeds the frontend assets into the binary and is
only needed when packaging a self-contained build, so leave it out during
development.
See `make help` for all available targets, and the workflows in
[`.github/workflows`](https://github.com/go-gitea/gitea/tree/main/.github/workflows)
to see how continuous integration builds and checks Gitea.
## Building continuously
To rebuild automatically when source files change:
```bash
# watch both frontend and backend
make watch
# or watch only the frontend (starts the Vite dev server)
make watch-frontend
# or watch only the backend (Go)
make watch-backend
```
Watching all backend source files may hit the default open-files limit on macOS or
Linux; raise it with `ulimit -n 12288` for the current shell, or in your shell
startup file to make it permanent.
## Formatting, linting and checks
Continuous integration rejects pull requests that fail formatting, linting, or
consistency checks. Format your code first:
```bash
make fmt
```
Then lint:
```bash
# lint everything
make lint
# or only one side
make lint-backend
make lint-frontend
```
Many linters can fix issues automatically with `make lint-fix` (or the scoped
`make lint-backend-fix` / `make lint-frontend-fix`). The combined consistency
checks that CI runs are available as `make checks`.
## Building and adding SVGs
SVG icons are built with `make svg`, which compiles the icon sources into
`public/assets/img/svg`. Custom icons can be added under `web_src/svg`.
## Updating the API
When you create or change API routes, you **must** update the
[Swagger](https://swagger.io/docs/specification/2-0/what-is-swagger/) documentation
using [go-swagger](https://goswagger.io/) comments. See the
[backend development guidelines](guidelines-backend.md) for how API routes,
request/response structs, and swagger definitions fit together.
Regenerate and validate the spec after changing an endpoint, then commit the
updated JSON:
```bash
make generate-swagger
make swagger-validate
```
CI verifies the committed spec is up to date with:
```bash
make swagger-check
```
## Creating new configuration options
When adding configuration options it is not enough to add them to the
`modules/setting` files. Also update
[`custom/conf/app.example.ini`](../custom/conf/app.example.ini), and document them in
the [configuration cheat sheet](https://docs.gitea.com/administration/config-cheat-sheet),
which lives in the [documentation repository](https://gitea.com/gitea/docs).
## Database migrations
If you make breaking changes to a database-persisted struct under `models/`, add a
new migration in `models/migrations/`. See [testing.md](testing.md#migration-tests)
for running the migration tests.
## Testing
For unit, integration, end-to-end, and migration tests, see [testing.md](testing.md).
## IDE configuration
### Visual Studio Code
A `launch.json` and `tasks.json` are provided in
[`contrib/development/vscode`](../contrib/development/vscode). See
[`contrib/development/README.md`](../contrib/development/README.md) for details.
### GoLand
Clicking the `Run Application` arrow on `func main()` in `/main.go` starts a
debuggable Gitea instance.
The `Output Directory` in `Run/Debug Configuration` **must** be set to the Gitea
project directory (the one containing `main.go` and `go.mod`). Otherwise the working
directory is a GoLand temporary directory, which prevents Gitea from loading dynamic
resources (such as templates) in development.
## Submitting your changes
Push your branch and open a pull request. See [CONTRIBUTING.md](../CONTRIBUTING.md)
for the review process and PR requirements. For help, join the `#Develop` channel on
[Discord](https://discord.gg/gitea).
-63
View File
@@ -1,63 +0,0 @@
# Backend development
This document covers backend-specific contribution expectations. For general contribution workflow, see [CONTRIBUTING.md](../CONTRIBUTING.md).
For coding style and architecture, see also the [backend development guideline](https://docs.gitea.com/contributing/guidelines-backend) on the documentation site.
## Dependencies
Go dependencies are managed using [Go Modules](https://go.dev/cmd/go/#hdr-Module_maintenance). \
You can find more details in the [go mod documentation](https://go.dev/ref/mod) and the [Go Modules Wiki](https://github.com/golang/go/wiki/Modules).
Pull requests should only modify `go.mod` and `go.sum` where it is related to your change, be it a bugfix or a new feature. \
Apart from that, these files should only be modified by Pull Requests whose only purpose is to update dependencies.
The `go.mod`, `go.sum` update needs to be justified as part of the PR description,
and must be verified by the reviewers and/or merger to always reference
an existing upstream commit.
## API v1
The API is documented by [swagger](https://gitea.com/api/swagger) and is based on [the GitHub API](https://docs.github.com/en/rest).
### GitHub API compatibility
Gitea's API should use the same endpoints and fields as the GitHub API as far as possible, unless there are good reasons to deviate. \
If Gitea provides functionality that GitHub does not, a new endpoint can be created. \
If information is provided by Gitea that is not provided by the GitHub API, a new field can be used that doesn't collide with any GitHub fields. \
Updating an existing API should not remove existing fields unless there is a really good reason to do so. \
The same applies to status responses. If you notice a problem, feel free to leave a comment in the code for future refactoring to API v2 (which is currently not planned).
### Adding/Maintaining API routes
All expected results (errors, success, fail messages) must be documented ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L319-L327)). \
All JSON input types must be defined as a struct in [modules/structs/](modules/structs/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L76-L91)) \
and referenced in [routers/api/v1/swagger/options.go](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/options.go). \
They can then be used like [this example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L318). \
All JSON responses must be defined as a struct in [modules/structs/](modules/structs/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L36-L68)) \
and referenced in its category in [routers/api/v1/swagger/](routers/api/v1/swagger/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/issue.go#L11-L16)) \
They can be used like [this example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L277-L279).
### When to use what HTTP method
In general, HTTP methods are chosen as follows:
- **GET** endpoints return the requested object(s) and status **OK (200)**
- **DELETE** endpoints return the status **No Content (204)** and no content either
- **POST** endpoints are used to **create** new objects (e.g. a User) and return the status **Created (201)** and the created object
- **PUT** endpoints are used to **add/assign** existing Objects (e.g. a user to a team) and return the status **No Content (204)** and no content either
- **PATCH** endpoints are used to **edit/change** an existing object and return the changed object and the status **OK (200)**
### Requirements for API routes
All parameters of endpoints changing/editing an object must be optional (except the ones to identify the object, which are required).
Endpoints returning lists must
- support pagination (`page` & `limit` options in query)
- set `X-Total-Count` header via **SetTotalCountHeader** ([example](https://github.com/go-gitea/gitea/blob/7aae98cc5d4113f1e9918b7ee7dd09f67c189e3e/routers/api/v1/repo/issue.go#L444))
### Knowledge
- Partially database table migration must use `SyncWithOptions(IgnoreDrop...)`
- Template variables with "camelCase" or "snake_case" are used for restoring the form values from a submitted form
-17
View File
@@ -1,17 +0,0 @@
# Frontend development
This document covers frontend-specific contribution expectations. For general contribution workflow, see [CONTRIBUTING.md](../CONTRIBUTING.md).
## Dependencies
For the frontend, we use [npm](https://www.npmjs.com/).
The same restrictions apply for frontend dependencies as for [backend dependencies](guideline-backend.md#dependencies), with the exceptions that the files for it are `package.json` and `package-lock.json`, and that new versions must always reference an existing version.
## Design guideline
Depending on your change, please read the
- [backend development guideline](https://docs.gitea.com/contributing/guidelines-backend)
- [frontend development guideline](https://docs.gitea.com/contributing/guidelines-frontend)
- [refactoring guideline](https://docs.gitea.com/contributing/guidelines-refactoring)
+132
View File
@@ -0,0 +1,132 @@
# Backend development guidelines
This document covers backend-specific architecture and contribution expectations.
For the general workflow see [CONTRIBUTING.md](../CONTRIBUTING.md), and for building
and testing see [development.md](development.md) and [testing.md](testing.md).
## Background
The backend is written in Go. Web routing is handled by
[chi](https://github.com/go-chi/chi) and database access goes through the
[XORM](https://xorm.io/) ORM. Understanding how the packages depend on each other is
essential before contributing backend code.
## Package design
### Package layout
The backend is split into top-level packages, each with a focused responsibility:
- `build`: helper scripts used at compile time
- `cmd`: subcommands such as `web`, `serv`, `hooks`, `doctor`, and admin utilities
- `models`: data structures and database operations (XORM); keeps external
dependencies to a minimum
- `models/db`: core database operations
- `models/fixtures`: sample data used by tests
- `models/migrations`: schema migration scripts
- `modules`: standalone functionality with few dependencies
- `modules/setting`: configuration handling
- `modules/git`: interaction with the Git command line
- `routers`: request handlers, split into `api`, `web`, `install`, and `private`
- `services`: business logic that ties routers and models together
- `templates`: Go HTML templates
- `public`: compiled frontend assets
- `tests`: integration and end-to-end test helpers
### Dependency direction
Dependencies only flow in one direction:
```text
cmd → routers → services → models → modules
```
A package on the left may import a package on its right, but never the reverse.
### Naming conventions
- Top-level packages use the plural form: `services`, `models`, `routers`.
- Subpackages use the singular form: `services/user`, `models/repository`.
When packages from different layers share a name, use a snake_case import alias to
disambiguate:
```go
import user_service "gitea.dev/services/user"
```
### Database transactions
Operations that must roll back together should run inside `db.WithTx()` (or
`db.WithTx2()` when a value must be returned), defined in `models/db/context.go`.
Functions that participate in a transaction take a `context.Context` as their first
parameter so the transaction can be propagated.
### XORM gotchas
- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates
every row in the table.
- Partial table migrations must use `SyncWithOptions(IgnoreDrop...)` rather than a
plain `Sync`.
- When inserting rows with preset IDs, MSSQL requires `SET IDENTITY_INSERT` to be
enabled and PostgreSQL requires the sequence to be updated afterwards.
## Dependencies
Go dependencies are managed with [Go Modules](https://go.dev/ref/mod).
Pull requests should only modify `go.mod` and `go.sum` where it relates to the
change at hand, be it a bug fix or a new feature. Otherwise, these files should only
be touched by pull requests whose sole purpose is updating dependencies. Run
`make tidy` after any change to `go.mod`.
Any `go.mod` / `go.sum` update must be justified in the PR description and must be
verified by reviewers and the merger to reference an existing upstream commit.
## API v1
The API is documented with [Swagger](https://gitea.com/api/swagger) and is modelled
on [the GitHub API](https://docs.github.com/en/rest).
### GitHub API compatibility
Gitea's API should use the same endpoints and fields as the GitHub API where
possible, unless there is a good reason to deviate.
- If Gitea offers functionality GitHub does not, a new endpoint may be added.
- If Gitea exposes information the GitHub API does not, a new field may be added as
long as it does not collide with a GitHub field.
- Existing fields should not be removed unless there is a strong reason; the same
applies to status responses.
If you notice a problem that would require a breaking change, leave a comment in the
code for a future refactor to API v2 (which is currently not planned) rather than
breaking v1.
### Adding and maintaining API routes
- All possible results (errors, success, and failure messages) must be documented in
the swagger comments on the route.
- Every JSON request body must be defined as a struct in `modules/structs/` and
registered in [`routers/api/v1/swagger/options.go`](../routers/api/v1/swagger/options.go).
- Every JSON response must be defined as a struct in `modules/structs/` and
registered with its category under [`routers/api/v1/swagger/`](../routers/api/v1/swagger).
### HTTP methods and status codes
In general, choose HTTP methods as follows:
- **GET** returns the requested object(s) with status **200 OK**.
- **POST** creates a new object (e.g. a user) and returns **201 Created** with the
created object.
- **PUT** adds or assigns an existing object (e.g. a user to a team) and returns
**204 No Content** with no body.
- **PATCH** edits an existing object and returns the changed object with **200 OK**.
- **DELETE** removes an object and returns **204 No Content** with no body.
### Requirements for API routes
- All parameters of endpoints that edit an object must be optional, except those
needed to identify the object, which are required.
- Endpoints returning lists must support pagination (`page` and `limit` query
options) and set the `X-Total-Count` header via `ctx.SetTotalCountHeader(...)`.
+98
View File
@@ -0,0 +1,98 @@
# Frontend development guidelines
This document covers frontend-specific architecture and contribution expectations.
For the general workflow see [CONTRIBUTING.md](../CONTRIBUTING.md), and for building
and testing see [development.md](development.md) and [testing.md](testing.md).
## Background
The frontend uses [Vue 3](https://vuejs.org/), [Fomantic-UI](https://fomantic-ui.com/) (built on jQuery)
and [Tailwind CSS](https://tailwindcss.com/). Pages are rendered with Go HTML templates.
Source files live in:
- `web_src/css/`: CSS styles
- `web_src/js/`: JavaScript and TypeScript
- `web_src/js/components/`: Vue components
- `web_src/js/features/`: feature modules wired up at page load
- `templates/`: Go HTML templates
## Dependencies
Frontend dependencies are managed with [pnpm](https://pnpm.io/). The same rules as
for [backend dependencies](guidelines-backend.md#dependencies) apply, except the
relevant files are `package.json` and `pnpm-lock.yaml`, and new versions must always
reference an existing published version.
## Framework usage
Mixing frameworks arbitrarily makes code hard to maintain. Recommended combinations:
- Vue3
- Vanilla JavaScript
- Fomantic-UI (jQuery), deprecated, we vendored a specific version with a lot of changes.
Avoid combinations such as Vue with Fomantic-UI.
Vue components may reuse Fomantic-UI CSS classes for visual consistency.
Use Go templates for simple or SEO-relevant pages and Vue for complex, interactive pages.
Gitea uses Vue 3 **without** JSX to keep HTML and JavaScript separate.
> [!NOTE]
> Fomantic-UI is not an accessibility-friendly framework. Gitea patches some ARIA
> behavior, but accessibility work is ongoing — prefer semantic HTML and test
> keyboard/screen-reader behavior where you can.
## Gitea-specific conventions
- Keep features in their own files or directories.
- Use kebab-case for HTML `id`s and classes, ideally with 2-3 feature keywords.
- Prefix classes to avoid short-name conflicts between different frameworks.
- Create a new class name when overriding framework styles instead of editing the framework's own classes,
or fix the framework's source to fix all cases.
- Prefer semantic elements such as `<button>` over generic `<div>`s.
- Avoid `!important`; when it is unavoidable, document why.
- Prefix custom DOM events with `ce-`.
## CSS
Prefer Tailwind utility classes with the `tw-` prefix, and the `flex-*` layout
helpers over per-child margins. Gitea also ships a small set of custom helpers:
`gt-` for general helpers and `g-` for framework-level helpers (see
`web_src/css/helpers.css`); use these only when a Tailwind utility does not exist.
Write class attributes as a single readable unit in templates:
```html
<div class="flex-text-inline {{if .IsFoo}}tw-hidden{{end}}"></div>
```
## TypeScript
- Use `import type` for type-only imports.
- Prefer `@ts-expect-error` over `@ts-ignore`.
- Use the `!` non-null assertion (rather than `?.`/`??`) when a value is known to always exist.
- Only mark a function `async` when it actually uses `await` or returns a `Promise`.
Avoid async event listeners; if unavoidable, call `e.preventDefault()` before the
first `await`. For a deliberately un-awaited call, assign it: `const _promise = asyncFoo()`.
## Data fetching
Use the `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` wrappers from
[`web_src/js/modules/fetch.ts`](../web_src/js/modules/fetch.ts).
## DOM attributes
Avoid `node.dataset` because of its camel-casing behavior; use `node.getAttribute`
in new code. Never bind user-provided data directly onto DOM nodes.
## Showing and hiding elements
- In Vue, use `v-if` and `v-show`.
- In Go templates and plain JavaScript, use the `.tw-hidden` class together with the
`showElem()`, `hideElem()`, and `toggleElem()` helpers from
[`web_src/js/utils/dom.ts`](../web_src/js/utils/dom.ts).
## UI component gallery
When running Gitea in development mode, standardized UI components are available at
`/devtest` (for example `http://localhost:3000/devtest`). These pages are also used
by the e2e tests.
+38
View File
@@ -0,0 +1,38 @@
# Refactoring guidelines
This document covers expectations for refactoring work. For the general workflow see
[CONTRIBUTING.md](../CONTRIBUTING.md).
## Background
Gitea is a large, long-lived project. Over time the codebase has accumulated
outdated mechanisms, mixed frameworks, and legacy code that can cause bugs or slow
down new features. Refactoring keeps the codebase maintainable, but it needs to be
done carefully so it improves things without introducing regressions.
## Writing a refactoring PR
- Be forward-looking: address the root cause, not just the immediate symptom.
- Aim to reduce ambiguity and conflicts and to improve maintainability.
- Explain the rationale in the PR description: why the refactor is necessary, how it
resolves the legacy problem, and its advantages and disadvantages.
- Keep the scope tight: preserve existing behavior where feasible and avoid bundling
unrelated changes.
- Break large refactors into intermediate steps across multiple PRs so each one is
easy to review.
- Include tests that verify the behavior stays correct.
- Prefer scheduling non-bugfix refactoring early in a milestone, so any issues
surface well before a release.
- If there is disagreement about a refactor, escalate to the Technical Oversight
Committee (TOC) for a decision.
## Reviewing and merging
- Keep refactoring PRs short-lived (typically no more than 7 days) with quick review
cycles, and merge them promptly so they do not block on unrelated work.
- A non-author core member may approve and merge a refactoring PR after 7 days if the
TOC has raised no objection.
- Accept imperfect intermediate implementations as long as the final result improves
the codebase.
- A temporary regression caused by a necessary refactor is acceptable if it is fixed
promptly afterwards.
+151
View File
@@ -0,0 +1,151 @@
# Testing
Gitea has four kinds of automated tests: backend unit tests, integration tests,
end-to-end (e2e) tests, and migration tests. Local runs default to SQLite, so no
extra services are required to get started.
For prerequisites see [build-setup.md](build-setup.md); for the build workflow see
[development.md](development.md).
## Unit tests
Backend unit tests live in `*_test.go` files next to the code they cover. Set
`GITEA_TEST_LOG_SQL=1` to log all SQL statements executed during the tests.
```bash
make test-backend
```
To run a single backend test, use `go test` directly or the `#` selector:
```bash
go test -run '^TestName$' ./modulepath/
make test-backend#TestName
```
Frontend unit tests run with [Vitest](https://vitest.dev/):
```bash
make test-frontend
# single file:
pnpm exec vitest <path-filter>
```
## Integration tests
Integration tests exercise Gitea against a real database. They live in
`tests/integration/` and require [Git LFS](https://git-lfs.com/) to be installed.
The database is selected with `GITEA_TEST_DATABASE`; an empty value defaults to
SQLite, which needs no external service:
```bash
make test-integration
```
Run a single integration test with the `#` selector:
```bash
make test-integration#TestName
```
If you hit errors such as a mismatched database version or SSH push failures, try a
clean rebuild first:
```bash
make clean build
```
### Running against other databases
Set `GITEA_TEST_DATABASE` together with the matching `TEST_*` connection variables.
The commands below start a throwaway database container (press `Ctrl-C` to stop and
remove it) and then run the tests against it.
#### MySQL
```bash
docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest
```
```bash
GITEA_TEST_DATABASE=mysql TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-integration
```
#### PostgreSQL
PostgreSQL tests also use a MinIO container for object storage:
```bash
docker run -e "POSTGRES_DB=test" -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 --rm --name pgsql postgres:latest
docker run --rm -p 9000:9000 -e MINIO_ROOT_USER=123456 -e MINIO_ROOT_PASSWORD=12345678 --name minio bitnamilegacy/minio:2023.8.31
```
```bash
GITEA_TEST_DATABASE=pgsql TEST_MINIO_ENDPOINT=localhost:9000 TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=postgres TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-integration
```
#### MSSQL
```bash
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest
```
```bash
GITEA_TEST_DATABASE=mssql TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-integration
```
### Running the database test workflow with Gitea Runner
The CI database test jobs can be run locally with
[Gitea Runner](https://gitea.com/gitea/runner). Running every job is
resource-intensive and not recommended:
```bash
gitea-runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest
```
List the available job names, then run a single one:
```bash
gitea-runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -l
gitea-runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -j <job_name>
```
## End-to-end tests
End-to-end tests drive a running Gitea instance with [Playwright](https://playwright.dev/):
```bash
make test-e2e
```
To run a single e2e test file, pass it via `GITEA_TEST_E2E_FLAGS`:
```bash
GITEA_TEST_E2E_FLAGS='<filepath>' make test-e2e
```
Useful environment variables:
| Variable | Description |
| :--- | :--- |
| `GITEA_TEST_E2E_DEBUG` | When set, show the Gitea server output. |
| `GITEA_TEST_E2E_FLAGS` | Additional flags passed to Playwright, e.g. `--ui`. |
| `GITEA_TEST_E2E_TIMEOUT_FACTOR` | Timeout multiplier (default: 4 on CI, 1 locally). |
## Migration tests
If you change a database-persisted struct under `models/` you will usually need a
new migration in `models/migrations/`. Run the migration tests with:
```bash
make test-migration
```
## Continuous integration
CI runs the unit tests, runs the integration tests against every supported database,
and tests migration from several recent Gitea versions. Please submit your pull
request with additional unit and integration tests as appropriate. Prefer unit tests
when the logic can be tested in isolation, and keep local integration and e2e tests
fast (aim for sub-2s runtime).
+1 -1
View File
@@ -166,7 +166,7 @@ func (a *Action) TableIndices() []*schemas.Index {
cudIndex.AddColumn("created_unix", "user_id", "is_deleted")
cuIndex := schemas.NewIndex("c_u", schemas.IndexType)
cuIndex.AddColumn("user_id", "is_deleted")
cuIndex.AddColumn("user_id", "is_deleted", "created_unix")
actUserUserIndex := schemas.NewIndex("au_c_u", schemas.IndexType)
actUserUserIndex.AddColumn("act_user_id", "created_unix", "user_id")
+22 -11
View File
@@ -14,35 +14,46 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/cache"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
const nonceTTL = 5 * time.Minute
func nonceKey(nonce string) string {
return "gpg_nonce:" + nonce
}
// VerifyNonce returns true if nonce is valid (not seen before, not expired)
// and marks it as used
// and marks it as used. It strictly validates that the nonce was generated
// for the exact domain this instance is running on.
func VerifyNonce(nonce string) bool {
if len(nonce) != 64 {
parts := strings.Split(nonce, ":")
if len(parts) < 2 {
return false
}
// first 8 chars are timestamp
tsHex := nonce[:8]
var (
tsHex = parts[0]
domain = parts[1]
)
// Validate timestamp
if len(tsHex) != 8 {
return false
}
ts, err := strconv.ParseInt(tsHex, 16, 64)
if err != nil {
return false
}
const nonceTTL = 5 * time.Minute
now := time.Now().Unix()
age := now - ts
if age < 0 || age > int64(nonceTTL.Seconds()) {
return false // expired or future timestamp
}
key := nonceKey(nonce)
if domain != setting.Domain && domain != setting.AppURL {
return false
}
key := fmt.Sprintf("gpg_nonce:%s", nonce)
_, exists := cache.GetCache().Get(key)
if exists {
return false
+28 -4
View File
@@ -21,6 +21,7 @@ import (
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/pbkdf2"
"xorm.io/builder"
)
//
@@ -104,20 +105,43 @@ func (t *TwoFactor) SetSecret(secretString string) error {
return nil
}
// ValidateTOTP validates the provided passcode.
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
// validateTOTP validates the provided passcode. It does not consume the passcode; all login
// surfaces must go through ValidateAndConsumeTOTP so that a passcode cannot be redeemed twice.
func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
if err != nil {
return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err)
return false, fmt.Errorf("validateTOTP invalid base64: %w", err)
}
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
if err != nil {
return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
return false, fmt.Errorf("validateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
}
secretStr := string(secretBytes)
return totp.Validate(passcode, secretStr), nil
}
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
// invalid passcode as well as for a replay, including the case where a concurrent request with
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
ok, err := t.validateTOTP(passcode)
if err != nil || !ok {
return false, err
}
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
t.LastUsedPasscode = passcode
n, err := db.GetEngine(ctx).ID(t.ID).
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
Cols("last_used_passcode").Update(t)
if err != nil {
return false, err
}
return n == 1, nil
}
// NewTwoFactor creates a new two-factor authentication token.
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
_, err := db.GetEngine(ctx).Insert(t)
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth_test
import (
"testing"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
require.NoError(t, err)
tfa := &auth_model.TwoFactor{UID: 1}
require.NoError(t, tfa.SetSecret(key.Secret()))
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
// first use of a valid passcode succeeds
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.True(t, ok)
// replaying the same passcode is refused, even when still inside the TOTP validity window
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
require.NoError(t, err)
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.False(t, ok)
// an invalid passcode is rejected without consuming anything
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
require.NoError(t, err)
assert.False(t, ok)
}
+2
View File
@@ -415,6 +415,8 @@ func prepareMigrationTasks() []*migration {
newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob),
newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable),
newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam),
newMigration(338, "Expand legacy MSSQL issue/comment long-text columns", v1_27.ExpandIssueAndCommentLongTextFieldsForMSSQL),
newMigration(339, "Extend action c_u index to include created_unix for faster dashboard feed queries", v1_27.AddCreatedUnixToActionUserIsDeletedIndex),
}
return preparedMigrations
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"fmt"
"strings"
"gitea.dev/models/db"
"gitea.dev/models/migrations/base"
"xorm.io/xorm/schemas"
)
type issueWithLongTextContent struct {
Content string `xorm:"LONGTEXT"`
}
func (issueWithLongTextContent) TableName() string {
return "issue"
}
type commentWithLongTextFields struct {
Content string `xorm:"LONGTEXT"`
PatchQuoted string `xorm:"LONGTEXT patch"`
}
func (commentWithLongTextFields) TableName() string {
return "comment"
}
func isMSSQLMaxTextColumn(column *schemas.Column) bool {
if column.Length != -1 {
return false
}
return strings.EqualFold(column.SQLType.Name, schemas.Varchar) || strings.EqualFold(column.SQLType.Name, schemas.NVarchar)
}
func modifyLongTextColumnsForMSSQL(x db.EngineMigration, bean any, columnNames ...string) error {
table, err := x.TableInfo(bean)
if err != nil {
return err
}
for _, columnName := range columnNames {
column := table.GetColumn(columnName)
if column == nil {
return fmt.Errorf("column %s does not exist in table %s", columnName, table.Name)
}
if isMSSQLMaxTextColumn(column) {
continue
}
if err := base.ModifyColumn(x, table.Name, column); err != nil {
return fmt.Errorf("modify %s.%s: %w", table.Name, columnName, err)
}
}
return nil
}
// ExpandIssueAndCommentLongTextFieldsForMSSQL expands legacy MSSQL nvarchar(4000)
// columns to nvarchar(max) so PR push comments and long issue content are not truncated.
func ExpandIssueAndCommentLongTextFieldsForMSSQL(x db.EngineMigration) error {
if x.Dialect().URI().DBType != schemas.MSSQL {
return nil
}
if err := modifyLongTextColumnsForMSSQL(x, new(issueWithLongTextContent), "content"); err != nil {
return err
}
return modifyLongTextColumnsForMSSQL(x, new(commentWithLongTextFields), "content", "patch")
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"strings"
"testing"
"gitea.dev/models/migrations/migrationtest"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/require"
)
type issueBeforeLongTextMSSQLMigration struct {
ID int64 `xorm:"pk autoincr"`
Content string `xorm:"VARCHAR(4000)"`
}
func (issueBeforeLongTextMSSQLMigration) TableName() string {
return "issue"
}
type commentBeforeLongTextMSSQLMigration struct {
ID int64 `xorm:"pk autoincr"`
Content string `xorm:"VARCHAR(4000)"`
Patch string `xorm:"VARCHAR(4000) patch"`
}
func (commentBeforeLongTextMSSQLMigration) TableName() string {
return "comment"
}
func Test_ExpandIssueAndCommentLongTextFieldsForMSSQL(t *testing.T) {
if !setting.Database.Type.IsMSSQL() {
t.Skip("Only MSSQL needs to expand legacy nvarchar(4000) long-text columns")
}
x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(issueBeforeLongTextMSSQLMigration), new(commentBeforeLongTextMSSQLMigration))
defer deferrable()
require.NoError(t, ExpandIssueAndCommentLongTextFieldsForMSSQL(x))
require.NoError(t, ExpandIssueAndCommentLongTextFieldsForMSSQL(x))
longText := strings.Repeat("x", 5000)
_, err := x.Insert(&issueBeforeLongTextMSSQLMigration{Content: longText})
require.NoError(t, err)
_, err = x.Insert(&commentBeforeLongTextMSSQLMigration{Content: longText, Patch: longText})
require.NoError(t, err)
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"context"
"gitea.dev/models/db"
"xorm.io/xorm/schemas"
)
// AddCreatedUnixToActionUserIsDeletedIndex extends the c_u composite index on
// the action table to include created_unix, enabling efficient ORDER BY on the
// dashboard feed query without a full sort of all matching rows.
func AddCreatedUnixToActionUserIsDeletedIndex(x db.EngineMigration) error {
// xorm Sync cannot reliably update an index when another index already
// covers the same columns in a different order (Equal() is order-insensitive).
// Drop the old c_u index explicitly, then recreate it with the new column set.
indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action")
if err != nil {
return err
}
for _, idx := range indexes {
if idx.Name == "c_u" {
if _, err := x.Exec(x.Dialect().DropIndexSQL("action", idx)); err != nil {
return err
}
break
}
}
newIndex := schemas.NewIndex("c_u", schemas.IndexType)
newIndex.AddColumn("user_id", "is_deleted", "created_unix")
if _, err := x.Exec(x.Dialect().CreateIndexSQL("action", newIndex)); err != nil {
return err
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"context"
"testing"
"gitea.dev/models/migrations/migrationtest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm/schemas"
)
type actionBeforeV339 struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"INDEX"`
OpType int
ActUserID int64
RepoID int64
CommentID int64 `xorm:"INDEX"`
IsDeleted bool `xorm:"NOT NULL DEFAULT false"`
RefName string
IsPrivate bool `xorm:"NOT NULL DEFAULT false"`
Content string `xorm:"TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
func (actionBeforeV339) TableName() string { return "action" }
func (actionBeforeV339) TableIndices() []*schemas.Index {
repoIndex := schemas.NewIndex("r_u_d", schemas.IndexType)
repoIndex.AddColumn("repo_id", "user_id", "is_deleted")
actUserIndex := schemas.NewIndex("au_r_c_u_d", schemas.IndexType)
actUserIndex.AddColumn("act_user_id", "repo_id", "created_unix", "user_id", "is_deleted")
cudIndex := schemas.NewIndex("c_u_d", schemas.IndexType)
cudIndex.AddColumn("created_unix", "user_id", "is_deleted")
// old 2-column index, before the migration
cuIndex := schemas.NewIndex("c_u", schemas.IndexType)
cuIndex.AddColumn("user_id", "is_deleted")
actUserUserIndex := schemas.NewIndex("au_c_u", schemas.IndexType)
actUserUserIndex.AddColumn("act_user_id", "created_unix", "user_id")
return []*schemas.Index{actUserIndex, repoIndex, cudIndex, cuIndex, actUserUserIndex}
}
func Test_AddCreatedUnixToActionUserIsDeletedIndex(t *testing.T) {
x, deferable := migrationtest.PrepareTestEnv(t, 0, new(actionBeforeV339))
defer deferable()
if x == nil || t.Failed() {
return
}
indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action")
require.NoError(t, err)
assert.True(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted"}, false), "old c_u index should exist before migration")
assert.False(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted", "created_unix"}, false), "new c_u index should not exist before migration")
require.NoError(t, AddCreatedUnixToActionUserIsDeletedIndex(x))
indexes, err = x.Dialect().GetIndexes(x.DB(), context.Background(), "action")
require.NoError(t, err)
assert.False(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted"}, false), "old 2-column c_u index should be gone after migration")
assert.True(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted", "created_unix"}, false), "new 3-column c_u index must exist after migration")
}
+3
View File
@@ -121,6 +121,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
}
cmd := gitcmd.NewCommand().AddArguments("clone")
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
cmd.AddArguments("-c", "http.followRedirects=false")
if opts.SkipTLSVerify {
cmd.AddArguments("-c", "http.sslVerify=false")
}
+23
View File
@@ -4,7 +4,10 @@
package git
import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
@@ -19,3 +22,23 @@ func TestRepoIsEmpty(t *testing.T) {
assert.NoError(t, err)
assert.True(t, isEmpty)
}
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
func TestCloneRefusesRedirects(t *testing.T) {
var targetHit atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHit.Store(true)
w.WriteHeader(http.StatusNotFound)
}))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
}))
defer redirect.Close()
err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{})
assert.Error(t, err)
assert.False(t, targetHit.Load(), "git must not follow the redirect to the target")
}
+18 -2
View File
@@ -5,9 +5,12 @@ package lfs
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"gitea.dev/modules/util"
)
// DownloadCallback gets called for every requested LFS object to process its content
@@ -23,10 +26,23 @@ type Client interface {
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
}
// NewClient creates a LFS client
func NewClient(endpoint *url.URL, httpTransport *http.Transport) Client {
// newClient creates a LFS client
func newClient(endpoint *url.URL, httpTransport *http.Transport) Client {
if endpoint.Scheme == "file" {
return newFilesystemClient(endpoint)
}
return newHTTPClient(endpoint, httpTransport)
}
// NewClientFromEndpoint creates a LFS client after resolving its endpoint.
func NewClientFromEndpoint(cloneurl, lfsurl string, httpTransport *http.Transport) (Client, error) {
endpoint := DetermineEndpoint(cloneurl, lfsurl)
if endpoint == nil {
source := cloneurl
if lfsurl != "" {
source = lfsurl
}
return nil, fmt.Errorf("unable to determine LFS endpoint from %q", util.SanitizeCredentialURLs(source))
}
return newClient(endpoint, httpTransport), nil
}
+13 -2
View File
@@ -12,10 +12,21 @@ import (
func TestNewClient(t *testing.T) {
u, _ := url.Parse("file:///test")
c := NewClient(u, nil)
c := newClient(u, nil)
assert.IsType(t, &FilesystemClient{}, c)
u, _ = url.Parse("https://test.com/lfs")
c = NewClient(u, nil)
c = newClient(u, nil)
assert.IsType(t, &HTTPClient{}, c)
}
func TestNewClientFromEndpoint(t *testing.T) {
client, err := NewClientFromEndpoint("ssh://git@example.com/owner/repo.git", "", nil)
assert.NoError(t, err)
assert.NotNil(t, client)
client, err = NewClientFromEndpoint("ftp://example.com/owner/repo.git", "", nil)
assert.Nil(t, client)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unable to determine LFS endpoint")
}
+13 -1
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
giturl "gitea.dev/modules/git/url"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
)
@@ -44,15 +45,20 @@ func endpointFromCloneURL(rawurl string) *url.URL {
}
func endpointFromURL(rawurl string) *url.URL {
if rawurl == "" {
return nil
}
if strings.HasPrefix(rawurl, "/") {
return endpointFromLocalPath(rawurl)
}
u, err := url.Parse(rawurl)
gitURL, err := giturl.ParseGitURL(rawurl)
if err != nil {
log.Error("lfs.endpointFromUrl: %v", err)
return nil
}
u := gitURL.URL
switch u.Scheme {
case "http", "https":
@@ -60,6 +66,12 @@ func endpointFromURL(rawurl string) *url.URL {
case "git":
u.Scheme = "https"
return u
case "ssh", "git+ssh":
u.Scheme = "https" // is it possible http?
u.Host = u.Hostname() // remove ssh port if any
u.Path = "/" + strings.TrimPrefix(u.Path, "/")
u.User = nil
return u
case "file":
return u
default:
+18
View File
@@ -64,6 +64,24 @@ func TestDetermineEndpoint(t *testing.T) {
lfsurl: "git://gitlfs.com/repo",
expected: str2url("https://gitlfs.com/repo"),
},
// case 7
{
cloneurl: "ssh://git@git.com/owner/repo.git",
lfsurl: "",
expected: str2url("https://git.com/owner/repo.git/info/lfs"),
},
// case 8
{
cloneurl: "git@git.com:owner/repo.git",
lfsurl: "",
expected: str2url("https://git.com/owner/repo.git/info/lfs"),
},
// case 9
{
cloneurl: "",
lfsurl: "ssh://git@gitlfs.com/owner/repo.git/info/lfs",
expected: str2url("https://gitlfs.com/owner/repo.git/info/lfs"),
},
}
for n, c := range cases {
+8
View File
@@ -2869,6 +2869,14 @@
"org.teams.all_repositories_read_permission_desc": "此团队授予<strong>读取</strong><strong>所有仓库</strong>的访问权限: 成员可以查看和克隆仓库。",
"org.teams.all_repositories_write_permission_desc": "此团队授予<strong>修改</strong><strong>所有仓库</strong>的访问权限: 成员可以查看和推送至仓库。",
"org.teams.all_repositories_admin_permission_desc": "该团队拥有 <strong>管理</strong> <strong>所有仓库</strong>的权限:团队成员可以读取、克隆、推送以及添加其它仓库协作者。",
"org.teams.visibility": "可见性",
"org.teams.visibility_private": "私有",
"org.teams.visibility_private_helper": "仅对团队成员和组织所有者可见。",
"org.teams.visibility_limited": "受限",
"org.teams.visibility_limited_helper": "对组织所有成员可见。",
"org.teams.visibility_public": "公开",
"org.teams.visibility_public_helper": "对任何登录用户可见。",
"org.teams.owners_visibility_fixed": "所有者的团队可见性无法更改。",
"org.teams.invite.title": "您已被邀请加入组织 <strong>%s</strong> 中的团队 <strong>%s</strong>。",
"org.teams.invite.by": "邀请人 %s",
"org.teams.invite.description": "请点击下面的按钮加入团队。",
+16 -1
View File
@@ -505,6 +505,21 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
}
}
// reqOrgVisible requires the organization to be visible to the doer, or a site admin
func reqOrgVisible() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
if ctx.Org.Organization == nil {
setting.PanicInDevOrTesting("reqOrgVisible: unprepared context")
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
return
}
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
ctx.APIErrorNotFound()
return
}
}
}
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
if ctx.IsUserSiteAdmin() {
return 0, true, true
@@ -1727,7 +1742,7 @@ func Routes() *web.Router {
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
})
}, reqOrgVisible())
m.Group("/hooks", func() {
m.Combo("").Get(org.ListHooks).
Post(bind(api.CreateHookOption{}), org.CreateHook)
+3
View File
@@ -1336,6 +1336,9 @@ func MergeUpstream(ctx *context.APIContext) {
} else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err.Error())
return
} else if errors.Is(err, util.ErrPermissionDenied) {
ctx.APIError(http.StatusForbidden, err.Error())
return
}
ctx.APIErrorInternal(err)
return
+27 -19
View File
@@ -40,9 +40,6 @@ type preReceiveContext struct {
canCreatePullRequest bool
checkedCanCreatePullRequest bool
canWriteCode bool
checkedCanWriteCode bool
protectedTags []*git_model.ProtectedTag
gotProtectedTags bool
@@ -50,24 +47,36 @@ type preReceiveContext struct {
opts *private.HookOptions
branchName string
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
canWriteCodeUnitCached *bool
}
// CanWriteCode returns true if pusher can write code
func (ctx *preReceiveContext) CanWriteCode() bool {
if !ctx.checkedCanWriteCode {
if !ctx.loadPusherAndPermission() {
return false
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil {
var canWrite bool
if ctx.loadPusherAndPermission() {
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
}
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
ctx.checkedCanWriteCode = true
ctx.canWriteCodeUnitCached = &canWrite
}
return ctx.canWriteCode
return *ctx.canWriteCodeUnitCached
}
// AssertCanWriteCode returns true if pusher can write code
func (ctx *preReceiveContext) AssertCanWriteCode() bool {
if !ctx.CanWriteCode() {
// canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
if ctx.canWriteCodeUnit() {
return true
}
// then check whether if the pusher is a maintainer who can write the PR author's head repo branch
if !refFullName.IsBranch() {
return false
}
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
}
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
if !ctx.canWriteCodeRef(refFullName) {
if ctx.Written() {
return false
}
@@ -129,7 +138,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
preReceiveFor(ourCtx, refFullName)
default:
ourCtx.AssertCanWriteCode()
ourCtx.assertCanWriteRef(refFullName)
}
if ctx.Written() {
return
@@ -141,9 +150,8 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
branchName := refFullName.BranchName()
ctx.branchName = branchName
if !ctx.AssertCanWriteCode() {
if !ctx.assertCanWriteRef(refFullName) {
return
}
@@ -404,7 +412,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
if !ctx.AssertCanWriteCode() {
if !ctx.assertCanWriteRef(refFullName) {
return
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package private
import (
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/git"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
// together with a protected branch or a tag to escalate into full repository write.
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
require.NoError(t, baseRepo.LoadOwner(t.Context()))
require.NoError(t, headRepo.LoadOwner(t.Context()))
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
// repo owner write access to exactly this head branch and nothing else.
pr := &issues_model.PullRequest{
Issue: &issues_model.Issue{
RepoID: baseRepo.ID,
PosterID: headRepo.OwnerID,
},
HeadRepoID: headRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "granted-branch",
BaseBranch: "master",
AllowMaintainerEdit: true,
}
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
maintainer := baseRepo.Owner
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
require.NoError(t, err)
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{
PrivateContext: mockCtx,
loadedPusher: true,
user: maintainer,
userPerm: headPerm,
}
// The granted branch must be writable...
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
// ...but another branch in the same push must NOT inherit that grant.
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromBranch("master")))
// ...and a tag sharing the granted branch's name must NOT inherit it either: the grant is
// scoped to PR head branches, so a non-branch ref can never match it. (A tag ref already
// yields an empty branch name, so this guards the per-ref evaluation, not the IsBranch check.)
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromTag("granted-branch")))
}
+3 -9
View File
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
return
}
// Validate the passcode with the stored TOTP secret.
ok, err := twofa.ValidateTOTP(form.Passcode)
// Validate the passcode and atomically consume it to prevent reuse/replay.
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if ok && twofa.LastUsedPasscode != form.Passcode {
if ok {
remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
@@ -81,12 +81,6 @@ func TwoFactorPost(ctx *context.Context) {
}
}
twofa.LastUsedPasscode = form.Passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("UserSignIn", err)
return
}
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
handleSignIn(ctx, u, remember)
return
+3 -9
View File
@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
regenerateScratchToken = true
} else {
passcode := ctx.FormString("passcode")
ok, err := twofa.ValidateTOTP(passcode)
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
return
}
if !ok || twofa.LastUsedPasscode == passcode {
if !ok {
ctx.Data["IsResetForm"] = true
ctx.Data["Err_Passcode"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
return
}
twofa.LastUsedPasscode = passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
return
}
}
}
+3
View File
@@ -15,6 +15,9 @@ import (
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
var commits []*git.Commit
var err error
if ctx.Repo.Commit != nil {
+3
View File
@@ -16,6 +16,9 @@ import (
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
fileName := ctx.Repo.TreePath
if len(fileName) == 0 {
return
+3
View File
@@ -15,6 +15,9 @@ import (
// shows tags and/or releases on the repo as RSS / Atom feed
func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
IncludeTags: !isReleasesOnly,
RepoID: ctx.Repo.Repository.ID,
+9
View File
@@ -4,9 +4,18 @@
package feed
import (
auth_model "gitea.dev/models/auth"
"gitea.dev/services/context"
)
// checkRepoFeedTokenScope ensures an API token has repository read scope before a
// feed serves private repository content, mirroring checkDownloadTokenScope for
// downloads. Returns false (and writes the response) when the token is denied.
func checkRepoFeedTokenScope(ctx *context.Context) bool {
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
return !ctx.Written()
}
// RenderBranchFeed render format for branch or file
func RenderBranchFeed(ctx *context.Context, feedType string) {
if ctx.Repo.TreePath == "" {
+3
View File
@@ -16,6 +16,9 @@ import (
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
RequestedRepo: repo,
Actor: ctx.Doer,
+1 -1
View File
@@ -264,7 +264,7 @@ func MergeUpstream(ctx *context.Context) {
branchName := ctx.FormString("branch")
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
ctx.JSONErrorNotFound()
return
} else if pull_service.IsErrMergeConflicts(err) {
+1
View File
@@ -146,6 +146,7 @@ func NewIssue(ctx *context.Context) {
}
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeIssues)
ctx.Data["IsIssuePoster"] = true // the current user will be the poster of the new issue
if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
+12 -29
View File
@@ -242,28 +242,16 @@ func DeleteAccount(ctx *context.Context) {
return
}
ctx.Data["Title"] = ctx.Tr("settings_title")
ctx.Data["PageIsSettingsAccount"] = true
ctx.Data["Email"] = ctx.Doer.Email
if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil {
switch {
case user_model.IsErrUserNotExist(err):
loadAccountData(ctx)
ctx.RenderWithErrDeprecated(ctx.Tr("form.user_not_exist"), tplSettingsAccount, nil)
ctx.JSONError(ctx.Tr("form.user_not_exist"))
case errors.Is(err, smtp.ErrUnsupportedLoginType):
loadAccountData(ctx)
ctx.RenderWithErrDeprecated(ctx.Tr("form.unsupported_login_type"), tplSettingsAccount, nil)
ctx.JSONError(ctx.Tr("form.unsupported_login_type"))
case errors.As(err, &db.ErrUserPasswordNotSet{}):
loadAccountData(ctx)
ctx.RenderWithErrDeprecated(ctx.Tr("form.unset_password"), tplSettingsAccount, nil)
ctx.JSONError(ctx.Tr("form.unset_password"))
case errors.As(err, &db.ErrUserPasswordInvalid{}):
loadAccountData(ctx)
ctx.RenderWithErrDeprecated(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil)
ctx.JSONError(ctx.Tr("form.enterred_invalid_password"))
default:
ctx.ServerError("UserSignIn", err)
}
@@ -272,32 +260,27 @@ func DeleteAccount(ctx *context.Context) {
// admin should not delete themself
if ctx.Doer.IsAdmin {
ctx.Flash.Error(ctx.Tr("form.admin_cannot_delete_self"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
ctx.JSONError(ctx.Tr("form.admin_cannot_delete_self"))
return
}
if err := user.DeleteUser(ctx, ctx.Doer, false); err != nil {
switch {
case repo_model.IsErrUserOwnRepos(err):
ctx.Flash.Error(ctx.Tr("form.still_own_repo"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
ctx.JSONError(ctx.Tr("form.still_own_repo"))
case org_model.IsErrUserHasOrgs(err):
ctx.Flash.Error(ctx.Tr("form.still_has_org"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
ctx.JSONError(ctx.Tr("form.still_has_org"))
case packages_model.IsErrUserOwnPackages(err):
ctx.Flash.Error(ctx.Tr("form.still_own_packages"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
ctx.JSONError(ctx.Tr("form.still_own_packages"))
case user_model.IsErrDeleteLastAdminUser(err):
ctx.Flash.Error(ctx.Tr("auth.last_admin"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
ctx.JSONError(ctx.Tr("auth.last_admin"))
default:
ctx.ServerError("DeleteUser", err)
}
} else {
log.Trace("Account deleted: %s", ctx.Doer.Name)
ctx.Redirect(setting.AppSubURL + "/")
return
}
ctx.JSONRedirect(setting.AppSubURL + "/")
}
func loadAccountData(ctx *context.Context) {
+14 -19
View File
@@ -268,21 +268,17 @@ func DeleteKey(ctx *context.Context) {
switch ctx.FormString("type") {
case "gpg":
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
ctx.NotFound(errors.New("gpg keys setting is not allowed to be visited"))
ctx.JSONError("gpg keys setting is not allowed to be visited")
return
}
if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
if asymkey_model.IsErrGPGLastKey(err) {
ctx.Flash.Error(ctx.Tr("settings.gpg_last_key_delete"))
} else {
ctx.Flash.Error("DeleteGPGKey: " + err.Error())
}
} else {
ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success"))
ctx.JSONError("Failed to delete PGP key")
return
}
ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success"))
case "ssh":
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
ctx.NotFound(errors.New("ssh keys setting is not allowed to be visited"))
ctx.JSONError("ssh keys setting is not allowed to be visited")
return
}
keyID := ctx.FormInt64("id")
@@ -292,24 +288,23 @@ func DeleteKey(ctx *context.Context) {
return
}
if external {
ctx.Flash.Error(ctx.Tr("settings.ssh_externally_managed"))
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
ctx.JSONError(ctx.Tr("settings.ssh_externally_managed"))
return
}
if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, keyID); err != nil {
ctx.Flash.Error("DeletePublicKey: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
ctx.JSONError("Failed to delete SSH key")
return
}
ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
case "principal":
if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
ctx.Flash.Error("DeletePublicKey: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success"))
ctx.JSONError("Failed to delete SSH principal key")
return
}
ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success"))
default:
ctx.Flash.Warning("Function not implemented")
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
ctx.JSONError("unsupported key type")
return
}
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/keys")
}
@@ -132,8 +132,7 @@ func WebauthnDelete(ctx *context.Context) {
return
}
form := web.GetForm(ctx).(*forms.WebauthnDeleteForm)
if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil {
if _, err := auth.DeleteCredential(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
ctx.ServerError("GetWebAuthnCredentialByID", err)
return
}
+1 -1
View File
@@ -584,7 +584,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/webauthn", func() {
m.Post("/request_register", web.Bind(forms.WebauthnRegistrationForm{}), security.WebAuthnRegister)
m.Post("/register", security.WebauthnRegisterPost)
m.Post("/delete", web.Bind(forms.WebauthnDeleteForm{}), security.WebauthnDelete)
m.Post("/delete", security.WebauthnDelete)
})
m.Group("/openid", func() {
m.Post("", web.Bind(forms.AddOpenIDForm{}), security.OpenIDPost)
+2 -1
View File
@@ -177,7 +177,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
}
return err
}
if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil {
// Consume the passcode atomically so a captured OTP cannot be replayed within its validity window.
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
return err
} else if !ok {
return util.NewInvalidArgumentErrorf("invalid provided OTP")
+16 -13
View File
@@ -24,19 +24,22 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
}
// since user only get notifications when he has access to use minimal access mode
if n.Repository != nil {
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
if err != nil {
log.Error("GetIndividualUserRepoPermission failed: %v", err)
return result
}
if perm.HasAnyUnitAccessOrPublicAccess() { // if user has been revoked access to repo, do not show repo info
result.Repository = ToRepo(ctx, n.Repository, perm)
// This permission is not correct and we should not be reporting it
for repository := result.Repository; repository != nil; repository = repository.Parent {
repository.Permissions = nil
}
}
if n.Repository == nil {
return result
}
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
if err != nil {
log.Error("GetIndividualUserRepoPermission failed: %v", err)
return result
}
// if the user has been revoked access to the repo, do not leak repo or subject info
if !perm.HasAnyUnitAccessOrPublicAccess() {
return result
}
result.Repository = ToRepo(ctx, n.Repository, perm)
// This permission is not correct and we should not be reporting it
for repository := result.Repository; repository != nil; repository = repository.Parent {
repository.Permissions = nil
}
// handle Subject
+30
View File
@@ -39,6 +39,36 @@ func TestToNotificationThreadOmitsRepoWhenAccessRevoked(t *testing.T) {
assert.Nil(t, thread.Repository)
}
func TestToNotificationThreadOmitsSubjectWhenAccessRevoked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// repo 2 is private; user 4 has no access to it
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
assert.NoError(t, repo.LoadOwner(ctx))
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4, RepoID: repo.ID})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
n := &activities_model.Notification{
ID: 12345,
UserID: user.ID,
RepoID: repo.ID,
Status: activities_model.NotificationStatusUnread,
Source: activities_model.NotificationSourceIssue,
IssueID: issue.ID,
UpdatedUnix: timeutil.TimeStampNow(),
Issue: issue,
Repository: repo,
User: user,
}
thread := ToNotificationThread(ctx, n)
// must not leak private issue metadata once access is revoked
assert.Nil(t, thread.Repository)
assert.Nil(t, thread.Subject)
}
func TestToNotificationThread(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
-11
View File
@@ -411,17 +411,6 @@ func (f *WebauthnRegistrationForm) Validate(req *http.Request, errs binding.Erro
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// WebauthnDeleteForm for deleting WebAuthn keys
type WebauthnDeleteForm struct {
ID int64 `binding:"Required"`
}
// Validate validates the fields
func (f *WebauthnDeleteForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// PackageSettingForm form for package settings
type PackageSettingForm struct {
Action string
+4 -3
View File
@@ -172,9 +172,10 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
if m.LFS && setting.LFS.StartServer {
log.Trace("SyncMirrors [repo: %-v]: syncing LFS objects...", m.Repo)
endpoint := lfs.DetermineEndpoint(remoteURL.String(), m.LFSEndpoint)
lfsClient := lfs.NewClient(endpoint, migrations.NewMigrationHTTPTransport())
if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, m.Repo, gitRepo, lfsClient); err != nil {
lfsClient, err := lfs.NewClientFromEndpoint(remoteURL.String(), m.LFSEndpoint, migrations.NewMigrationHTTPTransport())
if err != nil {
log.Error("SyncMirrors [repo: %-v]: failed to initialize LFS client: %v", m.Repo.FullName(), err)
} else if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, m.Repo, gitRepo, lfsClient); err != nil {
log.Error("SyncMirrors [repo: %-v]: failed to synchronize LFS objects for repository: %v", m.Repo.FullName(), err)
}
}
+4 -2
View File
@@ -144,8 +144,10 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error {
}
defer gitRepo.Close()
endpoint := lfs.DetermineEndpoint(remoteURL.String(), "")
lfsClient := lfs.NewClient(endpoint, migrations.NewMigrationHTTPTransport())
lfsClient, err := lfs.NewClientFromEndpoint(remoteURL.String(), "", migrations.NewMigrationHTTPTransport())
if err != nil {
return err
}
if err := pushAllLFSObjects(ctx, gitRepo, lfsClient); err != nil {
return util.SanitizeErrorCredentialURLs(err)
}
+13
View File
@@ -8,7 +8,9 @@ import (
"fmt"
issue_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
@@ -26,6 +28,17 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
if err = repo.GetBaseRepo(ctx); err != nil {
return "", err
}
// The doer must still be able to read the base repository's code. Otherwise a fork created
// while the base repo was public could keep pulling commits after it turned private.
basePerm, err := access_model.GetDoerRepoPermission(ctx, repo.BaseRepo, doer)
if err != nil {
return "", err
}
if !basePerm.CanRead(unit.TypeCode) {
return "", util.NewPermissionDeniedErrorf("permission denied to read base repo %d", repo.BaseRepo.ID)
}
divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch)
if err != nil {
return "", err
+4 -2
View File
@@ -159,8 +159,10 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
}
if opts.LFS {
endpoint := lfs.DetermineEndpoint(opts.CloneAddr, opts.LFSEndpoint)
lfsClient := lfs.NewClient(endpoint, httpTransport)
lfsClient, err := lfs.NewClientFromEndpoint(opts.CloneAddr, opts.LFSEndpoint, httpTransport)
if err != nil {
return repo, fmt.Errorf("NewClientFromEndpoint: %w", err)
}
if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, repo, gitRepo, lfsClient); err != nil {
log.Error("Failed to store missing LFS objects for repository: %v", err)
return repo, fmt.Errorf("StoreMissingLfsObjectsInRepository: %w", err)
+7 -9
View File
@@ -34,12 +34,10 @@
</div>
<div class="item-trailing">
{{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}}
<form>
<button class="ui red button delete-button" data-modal-id="remove-team-member"
data-url="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove" data-datauid="{{.ID}}"
data-name="{{.DisplayName}}"
data-data-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
</form>
<button class="ui red button show-modal" data-modal="#remove-team-member"
data-modal-form.action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove?uid={{.ID}}"
data-modal-name="{{.DisplayName}}"
data-modal-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
{{end}}
</div>
</div>
@@ -74,13 +72,13 @@
</div>
</div>
</div>
<div class="ui g-modal-confirm delete modal" id="remove-team-member">
<form class="ui small modal form-fetch-action" method="post" id="remove-team-member">
<div class="header">
{{ctx.Locale.Tr "org.members.remove"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "org.members.remove.detail" (HTMLFormat `<span class="%s"></span>` "name") (HTMLFormat `<span class="%s"></span>` "dataTeamName")}}</p>
<p>{{ctx.Locale.Tr "org.members.remove.detail" (HTMLFormat `<span class="%s"></span>` "name") (HTMLFormat `<span class="%s"></span>` "team-name")}}</p>
</div>
{{template "base/modal_actions_confirm" .}}
</div>
</form>
{{template "base/footer" .}}
+2 -2
View File
@@ -176,7 +176,7 @@
{{else}}
<button class="ui primary button">{{ctx.Locale.Tr "org.teams.update_settings"}}</button>
{{if not .Team.IsOwnerTeam}}
<button class="ui red button delete-button" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button>
<button class="ui red button link-action" data-modal-confirm="#delete-team" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button>
{{end}}
{{end}}
</div>
@@ -187,7 +187,7 @@
</div>
</div>
<div class="ui g-modal-confirm delete modal">
<div class="ui small modal" id="delete-team">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "org.teams.delete_team_title"}}
+3 -3
View File
@@ -200,7 +200,7 @@
</span>
</button>
{{else}}
<button class="btn interact-bg tw-p-2 delete-button delete-branch-button" data-url="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-name="{{.DBBranch.Name}}">
<button class="btn interact-bg tw-p-2 show-modal delete-branch-button tw-text-red" data-modal="#delete-branch-modal" data-modal-form.action="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-modal-name="{{.DBBranch.Name}}">
{{svg "octicon-trash"}}
</button>
{{end}}
@@ -216,7 +216,7 @@
</div>
</div>
<div class="ui g-modal-confirm delete modal">
<form class="ui small modal form-fetch-action" method="post" id="delete-branch-modal">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "repo.branch.delete_html"}} <span class="name"></span>
@@ -225,7 +225,7 @@
<p>{{ctx.Locale.Tr "repo.branch.delete_desc"}}</p>
</div>
{{template "base/modal_actions_confirm" .}}
</div>
</form>
<div class="ui mini modal" id="create-branch-modal">
<div class="header">
@@ -14,14 +14,15 @@ Still needs to figure out:
* Is "GitHub-like development sidebar (`#31899`)" good enough (or better) for your usage?
*/}}
{{if and (not .Issue.IsPull) (not .PageIsComparePull)}}
{{$canChangeRef := or .IsIssuePoster .HasIssuesOrPullsWritePermission}}
<input id="ref_selector" name="ref" type="hidden" value="{{.Reference}}">
<div class="ui dropdown select-branch branch-selector-dropdown ellipsis-text-items {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}}"
<div class="ui dropdown select-branch branch-selector-dropdown ellipsis-text-items {{if not $canChangeRef}}disabled{{end}}"
data-no-results="{{ctx.Locale.Tr "no_results_found"}}"
{{if and .Issue (or .IsIssueWriter .HasIssuesOrPullsWritePermission)}}data-url-update-issueref="{{$.RepoLink}}/issues/{{.Issue.Index}}/ref"{{end}}
{{if and .Issue $canChangeRef}}data-url-update-issueref="{{$.RepoLink}}/issues/{{.Issue.Index}}/ref"{{end}}
>
<div class="ui button branch-dropdown-button">
<span class="text-branch-name gt-ellipsis">{{if .Reference}}{{$.RefEndName}}{{else}}{{ctx.Locale.Tr "repo.issues.no_ref"}}{{end}}</span>
{{if .HasIssuesOrPullsWritePermission}}{{svg "octicon-triangle-down" 14 "dropdown icon"}}{{end}}
{{if $canChangeRef}}{{svg "octicon-triangle-down" 14 "dropdown icon"}}{{end}}
</div>
<div class="menu">
<div class="ui icon search input">
+2 -2
View File
@@ -52,7 +52,7 @@
<button type="button" class="ui button link-action" data-url="{{.Link}}/update-runner?disabled={{not .Runner.IsDisabled}}">
{{if .Runner.IsDisabled}}{{ctx.Locale.Tr "actions.runners.enable_runner"}}{{else}}{{ctx.Locale.Tr "actions.runners.disable_runner"}}{{end}}
</button>
<button class="ui red button delete-button" data-url="{{.Link}}/delete" data-modal="#runner-delete-modal">
<button class="ui red button link-action" data-url="{{.Link}}/delete" data-modal-confirm="#runner-delete-modal">
{{ctx.Locale.Tr "actions.runners.delete_runner"}}</button>
</div>
</form>
@@ -95,7 +95,7 @@
</table>
{{template "base/paginate" .}}
</div>
<div class="ui g-modal-confirm delete modal" id="runner-delete-modal">
<div class="ui small modal" id="runner-delete-modal">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "actions.runners.delete_runner_header"}}
+4 -4
View File
@@ -115,19 +115,19 @@
<p class="text left tw-font-semibold">{{ctx.Locale.Tr "settings.delete_with_all_comments" .UserDeleteWithCommentsMaxTime}}</p>
{{end}}
</div>
<form class="ui form ignore-dirty" id="delete-form" action="{{AppSubUrl}}/user/settings/account/delete" method="post">
<form class="ui form ignore-dirty form-fetch-action" action="{{AppSubUrl}}/user/settings/account/delete" method="post">
{{template "base/disable_form_autofill"}}
<div class="required field {{if .Err_Password}}error{{end}}">
<label for="password-confirmation">{{ctx.Locale.Tr "password"}}</label>
<input id="password-confirmation" name="password" type="password" autocomplete="off" required>
</div>
<div class="field">
<button class="ui red button delete-button" data-modal-id="delete-account" data-type="form" data-form="#delete-form">
<button class="ui red button" data-modal-confirm="#delete-account">
{{ctx.Locale.Tr "settings.confirm_delete_account"}}
</button>
</div>
</form>
<div class="ui g-modal-confirm delete modal" id="delete-account">
<div class="ui small modal" id="delete-account">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_account_title"}}
@@ -141,7 +141,7 @@
{{end}}
</div>
<div class="ui g-modal-confirm delete modal" id="delete-email">
<div class="ui small modal" id="delete-email">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.email_deletion"}}
+2 -2
View File
@@ -40,7 +40,7 @@
</div>
</div>
<div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-token" data-url="{{$.Link}}/delete" data-id="{{.ID}}">
<button class="ui red tiny button link-action" data-modal-confirm="#delete-token" data-url="{{$.Link}}/delete?id={{.ID}}">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_token"}}
</button>
@@ -92,7 +92,7 @@
{{end}} OAuth disabled in m8sh... -->
</div>
<div class="ui g-modal-confirm delete modal" id="delete-token">
<div class="ui small modal" id="delete-token">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.access_token_deletion"}}
+1 -1
View File
@@ -121,7 +121,7 @@
{{end}}
{{end}}
</div>
<div class="ui g-modal-confirm delete modal" id="delete-gpg">
<div class="ui small modal" id="delete-gpg">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.gpg_key_deletion"}}
+2 -2
View File
@@ -26,7 +26,7 @@
</div>
</div>
<div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-principal" data-url="{{$.Link}}/delete?type=principal" data-id="{{.ID}}">
<button class="ui red tiny button link-action" data-modal-confirm="#delete-principal" data-url="{{$.Link}}/delete?type=principal&id={{.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}}
</button>
</div>
@@ -55,7 +55,7 @@
</div>
</div>
<div class="ui g-modal-confirm delete modal" id="delete-principal">
<div class="ui small modal" id="delete-principal">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.ssh_principal_deletion"}}
+2 -2
View File
@@ -56,7 +56,7 @@
</div>
</div>
<div class="item-trailing">
<button class="ui red tiny button delete-button{{if index $.ExternalKeys $index}} disabled{{end}}" data-modal-id="delete-ssh" data-url="{{$.Link}}/delete?type=ssh" data-id="{{.ID}}"{{if index $.ExternalKeys $index}} title="{{ctx.Locale.Tr "settings.ssh_externally_managed"}}"{{end}}>
<button class="ui red tiny button link-action{{if index $.ExternalKeys $index}} disabled{{end}}" data-modal-confirm="#delete-ssh" data-url="{{$.Link}}/delete?type=ssh&id={{.ID}}"{{if index $.ExternalKeys $index}} title="{{ctx.Locale.Tr "settings.ssh_externally_managed"}}"{{end}}>
{{ctx.Locale.Tr "settings.delete_key"}}
</button>
{{if and (not .Verified) (ne $.VerifyingFingerprint .Fingerprint)}}
@@ -104,7 +104,7 @@
{{end}}
{{end}}
</div>
<div class="ui g-modal-confirm delete modal" id="delete-ssh">
<div class="ui small modal" id="delete-ssh">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.ssh_key_deletion"}}
+7 -10
View File
@@ -23,13 +23,10 @@
</div>
</div>
<div class="item-trailing">
<form>
<button class="ui red button delete-button" data-modal-id="leave-organization"
data-url="{{.OrganisationLink}}/members/action/leave" data-datauid="{{$.SignedUser.ID}}"
data-name="{{$.SignedUser.DisplayName}}"
data-data-organization-name="{{.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}
</button>
</form>
<button class="ui red button show-modal" data-modal="#leave-organization"
data-modal-form.action="{{.OrganisationLink}}/members/action/leave?uid={{$.SignedUser.ID}}"
data-modal-organization-name="{{.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}
</button>
</div>
</div>
{{end}}
@@ -41,14 +38,14 @@
</div>
</div>
<div class="ui g-modal-confirm delete modal" id="leave-organization">
<form class="ui small modal form-fetch-action" method="post" id="leave-organization">
<div class="header">
{{ctx.Locale.Tr "org.members.leave"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "org.members.leave.detail" (HTMLFormat `<span class="%s"></span>` "dataOrganizationName")}}</p>
<p>{{ctx.Locale.Tr "org.members.leave.detail" (HTMLFormat `<span class="%s"></span>` "organization-name")}}</p>
</div>
{{template "base/modal_actions_confirm" .}}
</div>
</form>
{{template "user/settings/layout_footer" .}}
@@ -40,7 +40,7 @@
{{end}}
</div>
<div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-account-link" data-url="{{AppSubUrl}}/user/settings/security/account_link" data-id="{{$loginSource.ID}}">
<button class="ui red tiny button link-action" data-modal-confirm="#delete-account-link" data-url="{{AppSubUrl}}/user/settings/security/account_link?id={{$loginSource.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}}
</button>
</div>
@@ -48,7 +48,7 @@
{{end}}
</div>
<div class="ui g-modal-confirm delete modal" id="delete-account-link">
<div class="ui small modal" id="delete-account-link">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.remove_account_link"}}
+2 -2
View File
@@ -29,7 +29,7 @@
</button>
{{end}}
</form>
<button class="ui red tiny button delete-button" data-modal-id="delete-openid" data-url="{{AppSubUrl}}/user/settings/security/openid/delete" data-id="{{.ID}}">
<button class="ui red tiny button link-action" data-modal-confirm="#delete-openid" data-url="{{AppSubUrl}}/user/settings/security/openid/delete?id={{.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}}
</button>
</div>
@@ -48,7 +48,7 @@
</button>
</form>
<div class="ui g-modal-confirm delete modal" id="delete-openid">
<div class="ui small modal" id="delete-openid">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.openid_deletion"}}
+3 -3
View File
@@ -9,9 +9,9 @@
<p>{{ctx.Locale.Tr "settings.regenerate_scratch_token_desc"}}</p>
<button class="ui primary button">{{ctx.Locale.Tr "settings.twofa_scratch_token_regenerate"}}</button>
</form>
<form class="ui form" action="{{AppSubUrl}}/user/settings/security/two_factor/disable" method="post" enctype="multipart/form-data" id="disable-form">
<form class="ui form form-fetch-action" action="{{AppSubUrl}}/user/settings/security/two_factor/disable" method="post">
<p>{{ctx.Locale.Tr "settings.twofa_disable_note"}}</p>
<button class="ui red button delete-button" data-modal-id="disable-twofa" data-type="form" data-form="#disable-form">{{ctx.Locale.Tr "settings.twofa_disable"}}</button>
<button class="ui red button" data-modal-confirm="#disable-twofa">{{ctx.Locale.Tr "settings.twofa_disable"}}</button>
</form>
{{else}}
{{/* The recovery tip is there as a means of encouraging a user to enroll */}}
@@ -22,7 +22,7 @@
</div>
{{end}}
<div class="ui g-modal-confirm delete modal" id="disable-twofa">
<div class="ui small modal" id="disable-twofa">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.twofa_disable"}}
@@ -16,7 +16,7 @@
</div>
</div>
<div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-registration" data-url="{{$.Link}}/webauthn/delete" data-id="{{.ID}}">
<button class="ui red tiny button link-action" data-modal-confirm="#delete-registration" data-url="{{$.Link}}/webauthn/delete?id={{.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}}
</button>
</div>
@@ -30,7 +30,7 @@
</div>
<button id="register-webauthn" class="ui primary button">{{svg "octicon-key"}} {{ctx.Locale.Tr "settings.webauthn_register_key"}}</button>
</div>
<div class="ui g-modal-confirm delete modal" id="delete-registration">
<div class="ui small modal" id="delete-registration">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.webauthn_delete_key"}}
+18
View File
@@ -32,3 +32,21 @@ test('add team member search', async ({page, request}) => {
const result = page.locator('#search-user-box .results .result').first();
await expect(result).toContainText(userName);
});
test('delete team via confirm modal', async ({page, request}) => {
const orgName = `e2e-del-team-${randomString(8)}`;
const teamName = `team-${randomString(8)}`;
await Promise.all([
(async () => {
await apiCreateOrg(request, orgName);
await apiCreateTeam(request, orgName, teamName);
})(),
login(page),
]);
await page.goto(`/org/${orgName}/teams/${teamName}/edit`);
await page.getByRole('button', {name: 'Delete Team'}).click();
await page.getByRole('button', {name: 'Yes'}).click();
await expect(page).toHaveURL(new RegExp(`/org/${orgName}/teams$`));
await expect(page.getByText('The team has been deleted.')).toBeVisible();
});
-94
View File
@@ -1,94 +0,0 @@
# Integration tests
Integration tests can be run with command `make test-integration`.
Environment variable `GITEA_TEST_DATABASE` can be used to specify the database type for testing.
If you encounter some errors like mismatched database version, SSH push errors, etc.,
you can try to perform a clean build by: `make clean build`.
## Run sqlite integration tests
Start tests directly (empty `GITEA_TEST_DATABASE` defaults to sqlite):
```
make test-integration
```
## Run MySQL integration tests
Set up a MySQL database inside docker:
```
docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest #(just ctrl-c to stop db and clean the container)
docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --rm --name elasticsearch elasticsearch:7.6.0 #(in a second terminal, just ctrl-c to stop db and clean the container)
```
Start tests based on the database container:
```
GITEA_TEST_DATABASE=mysql TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-integration
```
## Run pgsql integration tests
Set up a pgsql database inside docker:
```
docker run -e "POSTGRES_DB=test" -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container)
```
Set up minio inside docker:
```
docker run --rm -p 9000:9000 -e MINIO_ROOT_USER=123456 -e MINIO_ROOT_PASSWORD=12345678 --name minio bitnamilegacy/minio:2023.8.31
```
Start tests based on the database container:
```
GITEA_TEST_DATABASE=pgsql TEST_MINIO_ENDPOINT=localhost:9000 TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=postgres TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-integration
```
## Run mssql integration tests
Set up a mssql database inside docker:
```
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest #(just ctrl-c to stop db and clean the container)
```
Start tests based on the database container:
```
GITEA_TEST_DATABASE=mssql TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-integration
```
## Running individual tests
Example command to run GPG test:
```
GITEA_TEST_DATABASE=... make test-integration#GPG
```
## Run Gitea Actions tests via local act_runner
### Run all jobs
```
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest
```
Warning: This file defines many jobs, so it will be resource-intensive and therefore not recommended.
### Run single job
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -j <job_name>
```
You can list all job names via:
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -l
```
+1 -7
View File
@@ -50,14 +50,8 @@ func TestAPIUpdateOrgAvatar(t *testing.T) {
MakeRequest(t, req, http.StatusBadRequest)
// Test what happens if you use a file that is not an image
text, err := os.ReadFile(filepath.Join(setting.GetGiteaTestSourceRoot(), "tests/integration/README.md"))
assert.NoError(t, err)
if err != nil {
assert.FailNow(t, "Unable to open README.md")
}
opts = api.UpdateUserAvatarOption{
Image: base64.StdEncoding.EncodeToString(text),
Image: base64.StdEncoding.EncodeToString([]byte("This is not an image")),
}
req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/avatar", &opts).
+48
View File
@@ -11,6 +11,7 @@ import (
"time"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
org_model "gitea.dev/models/organization"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
@@ -292,3 +293,50 @@ func testAPIDeleteOrgRepos(t *testing.T) {
MakeRequest(t, req, http.StatusNoContent) // The org contains no repositories, so the API should return StatusNoContent
})
}
// TestAPIOrgLabelsVisibility ensures the organization label read endpoints honor
// the organization visibility: labels of a private org must not be disclosed to
// users who cannot see the org (GHSA: unauthorized access to private org labels).
func TestAPIOrgLabelsVisibility(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// privated_org (id 23) is a private organization; user5 is its only member.
privateOrg := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 23})
label := &issues_model.Label{OrgID: privateOrg.ID, Name: "internal-label", Color: "#aabbcc", Description: "private organization label"}
require.NoError(t, issues_model.NewLabel(t.Context(), label))
listURL := fmt.Sprintf("/api/v1/orgs/%s/labels", privateOrg.Name)
getURL := fmt.Sprintf("/api/v1/orgs/%s/labels/%d", privateOrg.Name, label.ID)
t.Run("NonMemberDenied", func(t *testing.T) {
// user2 is not a member of the private org and must not see its labels.
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusNotFound)
})
t.Run("AnonymousDenied", func(t *testing.T) {
MakeRequest(t, NewRequest(t, "GET", listURL), http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", getURL), http.StatusNotFound)
})
t.Run("MemberAllowed", func(t *testing.T) {
token := getUserToken(t, "user5", auth_model.AccessTokenScopeReadOrganization)
resp := MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
labels := DecodeJSON(t, resp, &[]*api.Label{})
assert.Len(t, *labels, 1)
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
})
t.Run("SiteAdminAllowed", func(t *testing.T) {
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization)
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
})
t.Run("PublicOrgStillReadable", func(t *testing.T) {
// org3 (id 3) is a public org with labels; non-members may read them.
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/labels").AddTokenAuth(token), http.StatusOK)
})
}
+1 -7
View File
@@ -54,14 +54,8 @@ func TestAPIUpdateRepoAvatar(t *testing.T) {
MakeRequest(t, req, http.StatusBadRequest)
// Test what happens if you use a file that is not an image
text, err := os.ReadFile(filepath.Join(setting.GetGiteaTestSourceRoot(), "tests/integration/README.md"))
assert.NoError(t, err)
if err != nil {
assert.FailNow(t, "Unable to open README.md")
}
opts = api.UpdateRepoAvatarOption{
Image: base64.StdEncoding.EncodeToString(text),
Image: base64.StdEncoding.EncodeToString([]byte("This is not an image")),
}
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar", repo.OwnerName, repo.Name), &opts).
+6
View File
@@ -51,6 +51,12 @@ func TestAPITwoFactor(t *testing.T) {
AddBasicAuth(user.Name)
req.Header.Set("X-Gitea-OTP", passcode)
MakeRequest(t, req, http.StatusOK)
// the same passcode must not be replayable on the basic-auth surface (RFC 6238 single-use)
req = NewRequest(t, "GET", "/api/v1/user").
AddBasicAuth(user.Name)
req.Header.Set("X-Gitea-OTP", passcode)
MakeRequest(t, req, http.StatusUnauthorized)
}
func TestBasicAuthWithWebAuthn(t *testing.T) {
+1 -7
View File
@@ -50,14 +50,8 @@ func TestAPIUpdateUserAvatar(t *testing.T) {
MakeRequest(t, req, http.StatusBadRequest)
// Test what happens if you use a file that is not an image
text, err := os.ReadFile(filepath.Join(setting.GetGiteaTestSourceRoot(), "tests/integration/README.md"))
assert.NoError(t, err)
if err != nil {
assert.FailNow(t, "Unable to open README.md")
}
opts = api.UpdateUserAvatarOption{
Image: base64.StdEncoding.EncodeToString(text),
Image: base64.StdEncoding.EncodeToString([]byte("This is not an image")),
}
req = NewRequestWithJSON(t, "POST", "/api/v1/user/avatar", &opts).
+4 -4
View File
@@ -38,13 +38,13 @@ func TestViewBranches(t *testing.T) {
}
func TestUndoDeleteBranch(t *testing.T) {
branchAction := func(t *testing.T, button string) (*HTMLDoc, string) {
branchAction := func(t *testing.T, button, attr string) (*HTMLDoc, string) {
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user2/repo1/branches")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
link, exists := htmlDoc.doc.Find(button).Attr("data-url")
link, exists := htmlDoc.doc.Find(button).Attr(attr)
require.True(t, exists, "The template has changed")
linkURL, err := url.Parse(link)
require.NoError(t, err)
@@ -58,12 +58,12 @@ func TestUndoDeleteBranch(t *testing.T) {
}
onGiteaRun(t, func(t *testing.T, u *url.URL) {
htmlDoc, name := branchAction(t, ".delete-branch-button")
htmlDoc, name := branchAction(t, ".delete-branch-button", "data-modal-form.action")
assert.Contains(t,
htmlDoc.doc.Find(".ui.positive.message").Text(),
translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name),
)
htmlDoc, name = branchAction(t, ".restore-branch-button")
htmlDoc, name = branchAction(t, ".restore-branch-button", "data-url")
assert.Contains(t,
htmlDoc.doc.Find(".ui.positive.message").Text(),
translation.NewLocale("en-US").TrString("repo.branch.restore_success", name),
+7 -3
View File
@@ -13,7 +13,10 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
func assertUserDeleted(t *testing.T, userID int64) {
@@ -34,7 +37,8 @@ func TestUserDeleteAccount(t *testing.T) {
session := loginUser(t, "user8")
urlStr := "/user/settings/account/delete?password=" + userPassword
req := NewRequest(t, "POST", urlStr)
session.MakeRequest(t, req, http.StatusSeeOther)
resp := session.MakeRequest(t, req, http.StatusOK)
assert.NotEmpty(t, test.ParseJSONRedirect(resp.Body.Bytes()).Redirect)
assertUserDeleted(t, 8)
unittest.CheckConsistencyFor(t, &user_model.User{})
@@ -46,8 +50,8 @@ func TestUserDeleteAccountStillOwnRepos(t *testing.T) {
session := loginUser(t, "user2")
urlStr := "/user/settings/account/delete?password=" + userPassword
req := NewRequest(t, "POST", urlStr)
session.MakeRequest(t, req, http.StatusSeeOther)
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
// user should not have been deleted, because the user still owns repos
unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
}
+39
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -33,3 +34,41 @@ func TestFeedRepo(t *testing.T) {
assert.NotEmpty(t, rss.Channel.Items[0].PubDate)
})
}
// TestFeedRepoContentTokenScopes ensures repository feed endpoints enforce the
// repository token scope, so a PAT without repository scope cannot read private
// repository commit/activity data through RSS/Atom feeds.
func TestFeedRepoContentTokenScopes(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2/repo2 is a private repository owned by user2
ownerReadToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
urls := []string{
"/user2/repo2.rss",
"/user2/repo2.atom",
"/user2/repo2/rss/branch/master",
"/user2/repo2/atom/branch/master",
"/user2/repo2/rss/branch/master/README.md",
"/user2/repo2/tags.rss",
"/user2/repo2/tags.atom",
"/user2/repo2/releases.rss",
"/user2/repo2/releases.atom",
}
for _, url := range urls {
t.Run(url, func(t *testing.T) {
// feed routes only accept basic auth, so authenticate as the advisory PoC does (user:token)
reqDenied := NewRequest(t, "GET", url)
reqDenied.SetBasicAuth("user2", miscToken)
// a token without repository scope must be denied
MakeRequest(t, reqDenied, http.StatusForbidden)
reqAllowed := NewRequest(t, "GET", url)
reqAllowed.SetBasicAuth("user2", ownerReadToken)
// a token with repository read scope is allowed
MakeRequest(t, reqAllowed, http.StatusOK)
})
}
}
+1
View File
@@ -41,6 +41,7 @@ func (doc *HTMLDoc) Find(selector string) *goquery.Selection {
// AssertHTMLElement check if the element by selector exists or does not exist depending on checkExists
func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string, checkExists T) {
t.Helper()
sel := doc.doc.Find(selector)
switch v := any(checkExists).(type) {
case bool:
+53
View File
@@ -680,6 +680,59 @@ func TestUpdateIssueDeadline(t *testing.T) {
assert.True(t, issueAfter.DeadlineUnix.IsZero())
}
func TestUpdateIssueRefByPoster(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user4 is a non-admin, non-collaborator on user2/repo1.
// They create an issue, making them the poster.
posterSession := loginUser(t, "user4")
issueURL := testNewIssue(t, posterSession, "user2", "repo1", "Poster ref test", "body")
refURL := issueURL + "/ref"
// The poster (non-collaborator) must be able to update the ref.
req := NewRequestWithValues(t, "POST", refURL, map[string]string{"ref": "refs/heads/main"})
posterSession.MakeRequest(t, req, http.StatusOK)
// A different non-collaborator non-poster must be forbidden.
otherSession := loginUser(t, "user5")
req = NewRequestWithValues(t, "POST", refURL, map[string]string{"ref": "refs/heads/main"})
otherSession.MakeRequest(t, req, http.StatusForbidden)
}
func TestIssueRefSelectorEnabledForPoster(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user4 creates an issue in user2/repo1 (user4 has no write permission there).
posterSession := loginUser(t, "user4")
issueURL := testNewIssue(t, posterSession, "user2", "repo1", "Ref selector test", "body")
resp := posterSession.MakeRequest(t, NewRequest(t, "GET", issueURL), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// The branch selector must not carry the "disabled" CSS class for the poster.
sel := htmlDoc.Find(".branch-selector-dropdown")
assert.Equal(t, 1, sel.Length())
assert.False(t, sel.HasClass("disabled"), "branch selector should be enabled for the issue poster")
// The update-ref URL must be present so JS can send the POST request.
_, hasURL := sel.Attr("data-url-update-issueref")
assert.True(t, hasURL, "data-url-update-issueref must be set for the issue poster")
}
func TestIssueRefSelectorEnabledForNewIssue(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user4 (non-collaborator on user2/repo1) must see an enabled ref selector
// when creating a new issue.
session := loginUser(t, "user4")
req := NewRequest(t, "GET", "/user2/repo1/issues/new")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
sel := htmlDoc.Find(".branch-selector-dropdown")
assert.Equal(t, 1, sel.Length())
assert.False(t, sel.HasClass("disabled"), "branch selector should be enabled on the new issue form")
}
func TestIssueReferenceURL(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
@@ -171,5 +171,18 @@ func TestRepoMergeUpstream(t *testing.T) {
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("BasePrivateBlocksSync", func(t *testing.T) {
// add a new commit to the base repo, then make the base repo private
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "secret.txt", "master", "private-content"))
baseRepo.IsPrivate = true
_, err := db.GetEngine(t.Context()).ID(baseRepo.ID).Cols("is_private").Update(baseRepo)
require.NoError(t, err)
// the fork owner can no longer read the base repo, so syncing must be refused
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
Branch: "fork-branch",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
})
})
}
+2 -2
View File
@@ -70,7 +70,7 @@ func TestUserSettingsAccount(t *testing.T) {
AssertHTMLElement(t, doc, "#password", true)
AssertHTMLElement(t, doc, "#email", true)
AssertHTMLElement(t, doc, "#delete-form", true)
AssertHTMLElement(t, doc, `form[action="/user/settings/account/delete"]`, true)
})
t.Run("credentials disabled", func(t *testing.T) {
@@ -87,7 +87,7 @@ func TestUserSettingsAccount(t *testing.T) {
AssertHTMLElement(t, doc, "#password", false)
AssertHTMLElement(t, doc, "#email", false)
AssertHTMLElement(t, doc, "#delete-form", true)
AssertHTMLElement(t, doc, `form[action="/user/settings/account/delete"]`, true)
})
t.Run("deletion disabled", func(t *testing.T) {
-5
View File
@@ -326,11 +326,6 @@
margin: 0 0.25em 0 0;
}
.delete-button,
.delete-button:hover {
color: var(--color-red);
}
/* btn is a plain button without any opinionated styling, it only uses flex for vertical alignment like ".ui.button" in base.css */
.btn {
-69
View File
@@ -1,4 +1,3 @@
import {POST} from '../modules/fetch.ts';
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {camelize} from 'vue';
@@ -13,74 +12,6 @@ export function initGlobalButtonClickOnEnter(): void {
});
}
export function initGlobalDeleteButton(): void {
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
// If there is no form, then the data will be posted to `data-url`.
// TODO: do not use this method in new code. `show-modal` / `link-action(data-modal-confirm)` does far better than this.
// FIXME: all legacy `delete-button` should be refactored to use `show-modal` or `link-action`
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
btn.addEventListener('click', (e) => {
e.preventDefault();
// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
const dataObj = btn.dataset;
const modalId = btn.getAttribute('data-modal-id');
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
// set the modal "display name" by `data-name`
const modalNameEl = modal.querySelector('.name');
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) {
const textEl = modal.querySelector(`.${key}`);
if (textEl) textEl.textContent = value ?? null;
}
}
showFomanticModal(modal, {
closable: false,
onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form')!;
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
form.classList.add('is-loading');
form.submit();
return false; // prevent modal from closing automatically
}
// prepare an AJAX form by data attributes
const postData = new FormData();
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
postData.append(key.slice(4), String(value));
}
if (key === 'id') { // for data-id="..."
postData.append('id', String(value));
}
}
(async () => {
const response = await POST(btn.getAttribute('data-url')!, {data: postData});
if (response.ok) {
const data = await response.json();
window.location.href = data.redirect;
}
})();
modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
return false; // prevent modal from closing automatically
},
});
});
}
}
function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
+5 -2
View File
@@ -1,8 +1,11 @@
export function makeNonce(): string {
const ts = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0');
const arr = new Uint8Array(28);
const arr = new Uint8Array(24);
crypto.getRandomValues(arr);
return ts + Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
const randomHex = Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
return `${ts}:${window.location.host}:${randomHex}`;
}
export function buildSignCommand(nonce: string, keyID?: string): string {
+1 -2
View File
@@ -58,7 +58,7 @@ import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts';
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
import {initGlobalButtonClickOnEnter, initGlobalButtons} from './features/common-button.ts';
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
@@ -84,7 +84,6 @@ const initPerformanceTracer = callInitFunctions([
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
initGlobalInput,
initGlobalShortcut,
initGpgSignup,