Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0169937741 | ||
|
|
3d9efe626d
|
||
|
|
9c82394315 | ||
|
|
de83393487 | ||
|
|
64f3796567 | ||
|
|
240d0efa7e | ||
|
|
68692e19d4 | ||
|
|
c68925152b | ||
|
|
9e84deb969 | ||
|
|
795531cea0 | ||
|
|
0be7543560 |
+15
-35
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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(...)`.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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).
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user