Template
1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo synced 2024-11-28 20:56:11 +01:00

Compare commits

...

59 commits

Author SHA1 Message Date
Marco De Araujo 35c1df0744 Merge branch 'forgejo' into escape_markdown_discord 2024-11-16 16:13:43 -04:00
Earl Warren 569a67327c Merge pull request 'bug: correctly generate oauth2 jwt signing key' (#5986) from gusted/improve-oauth2-jwt into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5986
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
2024-11-16 17:04:31 +00:00
Marco De Araujo aeffa031c0 Merge branch 'forgejo' into escape_markdown_discord 2024-11-16 12:04:32 -04:00
Gusted 146824badc Merge pull request 'feat: improve GetLatestCommitStatusForPairs' (#5983) from gusted/improve-commit-pairs into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5983
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Reviewed-by: Otto <otto@codeberg.org>
2024-11-16 15:54:40 +00:00
Earl Warren eaa66f85f6 Merge pull request '[gitea] week 2024-46 cherry pick (gitea/main -> forgejo)' (#5988) from earl-warren/wcp/2024-46 into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5988
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2024-11-16 15:49:01 +00:00
Earl Warren 969a6ab24a
chore(release-notes): notes for the week 2024-46 weekly cherry pick 2024-11-16 15:25:37 +01:00
Gusted 7d59060dc6
bug: correctly generate oauth2 jwt signing key
- When RS256, RS384, ES384, ES512 was specified as the JWT signing
algorithm they would generate RS512 and ES256 respectively.
- Added unit test.
2024-11-16 15:17:19 +01:00
silverwind 308812a82e
Fix mermaid diagram height when initially hidden (#32457)
In a hidden iframe, `document.body.clientHeight` is not reliable. Use
`IntersectionObserver` to detect the visibility change and update the
height there.

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

<img width="885" alt="image"
src="https://github.com/user-attachments/assets/a95ef6aa-27e7-443f-9d06-400ef27919ae">

(cherry picked from commit b55a31eb6a894feb5508e350ff5e9548b2531bd6)
2024-11-16 15:12:25 +01:00
Zettat123 fc26becba4
Fix broken releases when re-pushing tags (#32435)
Fix #32427

(cherry picked from commit 35bcd667b23de29a7b0d0bf1090fb10961d3aca3)

Conflicts:
	- tests/integration/repo_tag_test.go
	  Resolved by manually copying the added test, and also manually
	  adjusting the imported Go modules.
2024-11-16 15:12:25 +01:00
Lunny Xiao 013cc1dee4
Only query team tables if repository is under org when getting assignees (#32414)
It's unnecessary to query the team table if the repository is not under
organization when getting assignees.

(cherry picked from commit 1887c75c35c1d16372b1dbe2b792e374b558ce1f)
2024-11-16 14:57:11 +01:00
Gusted 6d0f2c1b82 Merge pull request 'Update module google.golang.org/grpc to v1.68.0 (forgejo)' (#5969) from renovate/forgejo-google.golang.org-grpc-1.x into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5969
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2024-11-16 12:25:41 +00:00
Gusted 2cccc02e76
feat: improve GetLatestCommitStatusForPairs
- Simplify the function into a single SQL query. This may or may not
help with a monster query we are seeing in Codeberg that is using 400MiB
and takes 50MiB to simply log the query. The result is now capped to the
actual latest index,
- Add unit test.
2024-11-16 13:23:40 +01:00
Earl Warren 356aa6521b Merge pull request 'fix: extend forgejo_auth_token table (part two)' (#5984) from earl-warren/forgejo:wip-forgejo-auth-token into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5984
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2024-11-16 11:56:02 +00:00
Earl Warren cf323a3d55
fix: extend forgejo_auth_token table (part two)
Add the default value of the purpose field to both the table and the
migration. The table in v9 and v7 backport already have the default
value.

ALTER TABLE `forgejo_auth_token` ADD `purpose` TEXT NOT NULL [] - Cannot add a NOT NULL column with default value NULL
2024-11-16 10:53:46 +01:00
Gusted 6bab3c374c Merge pull request 'Update github.com/grafana/go-json digest to f14426c (forgejo)' (#5980) from renovate/forgejo-github.com-grafana-go-json-digest into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5980
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2024-11-16 03:07:17 +00:00
Gusted 570e8cec9e Merge pull request 'Update dependency tailwindcss to v3.4.15 (forgejo)' (#5966) from renovate/forgejo-tailwindcss-3.x into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5966
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
2024-11-16 02:23:37 +00:00
Michael Kriese bf810fa8d3 Merge pull request 'ci: upload all e2e artifacts' (#5973) from viceice/ci/e2e-artifacts into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5973
2024-11-16 00:34:34 +00:00
Renovate Bot 66dfb2813c Update github.com/grafana/go-json digest to f14426c 2024-11-16 00:03:23 +00:00
Earl Warren 95a8987844 Merge pull request 'chore(release-notes): fix the v9.0.2 links' (#5978) from earl-warren/forgejo:wip-release-notes into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5978
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
2024-11-15 22:48:55 +00:00
Earl Warren 9fd2df6e30
chore(release-notes): fix the v9.0.2 links 2024-11-15 22:59:52 +01:00
Michael Kriese 5406310f3e
ci: upload all e2e artifacts 2024-11-15 15:01:39 +01:00
Michael Kriese b21cc70dd7 Merge pull request 'chore: fix e2e' (#5977) from gusted/fix-e2e into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5977
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
2024-11-15 13:33:50 +00:00
Gusted 4a5d9d4b78
chore: fix e2e
- Regression from #5948
- Use proper permission.
- Remove debug statement
2024-11-15 14:02:16 +01:00
Earl Warren 1e1b162cbe Merge pull request 'fix: 15 November 2024 security fixes batch' (#5974) from earl-warren/forgejo:wip-security-15-11 into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5974
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Reviewed-by: Otto <otto@codeberg.org>
2024-11-15 11:19:50 +00:00
Earl Warren b1bc294955
chore(release-notes): 15 November 2024 security fixes 2024-11-15 11:17:14 +01:00
Michael Kriese 01ab0583f5 Merge pull request 'test: fix e2e tests' (#5968) from viceice/test/e2e-fixes into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5968
2024-11-15 10:16:18 +00:00
Gusted 786dfc7fb8
fix: add ID check for updating push mirror interval
- Ensure that the specified push mirror ID belongs to the requested
repository, otherwise it is possible to modify the intervals of the push
mirrors that do not belong to the requested repository.
- Integration test added.
2024-11-15 10:59:36 +01:00
Gusted 061abe6004
fix: don't show private forks in forks list
- If a repository is forked to a private or limited user/organization,
the fork should not be visible in the list of forks depending on the
doer requesting the list of forks.
- Added integration testing for web and API route.
2024-11-15 10:59:36 +01:00
Gusted 3e3ef76808
fix: require code permissions for branch feed
- The RSS and atom feed for branches exposes details about the code, it
therefore should be guarded by the requirement that the doer has access
to the code of that repository.
- Added integration testing.
2024-11-15 10:59:36 +01:00
Gusted 7067cc7da4
fix: strict matching of allowed content for sanitizer
- _Simply_ add `^$` to regexp that didn't had it yet, this avoids any
content being allowed that simply had the allowed content as a
substring.
- Fix file-preview regex to have `$` instead of `*`.
2024-11-15 10:59:36 +01:00
Gusted e6bbecb02d
fix: disallow basic authorization when security keys are enrolled
- This unifies the security behavior of enrolling security keys with
enrolling TOTP as a 2FA method. When TOTP is enrolled, you cannot use
basic authorization (user:password) to make API request on behalf of the
user, this is now also the case when you enroll security keys.
- The usage of access tokens are the only method to make API requests on
behalf of the user when a 2FA method is enrolled for the user.
- Integration test added.
2024-11-15 10:59:36 +01:00
Gusted b70196653f
fix: anomynous users code search for private/limited user's repository
- Consider private/limited users in the `AccessibleRepositoryCondition`
query, previously this only considered private/limited organization.
This limits the ability for anomynous users to do code search on
private/limited user's repository
- Unit test added.
2024-11-15 10:59:36 +01:00
Gusted 9508aa7713
Improve usage of HMAC output for mailer tokens
- If the incoming mail feature is enabled, tokens are being sent with
outgoing mails. These tokens contains information about what type of
action is allow with such token (such as replying to a certain issue
ID), to verify these tokens the code uses the HMAC-SHA256 construction.
- The output of the HMAC is truncated to 80 bits, because this is
recommended by RFC2104, but RFC2104 actually doesn't recommend this. It
recommends, if truncation should need to take place, it should use
max(80, hash_len/2) of the leftmost bits. For HMAC-SHA256 this works out
to 128 bits instead of the currently used 80 bits.
- Update to token version 2 and disallow any usage of token version 1,
token version 2 are generated with 128 bits of HMAC output.
- Add test to verify the deprecation of token version 1 and a general
MAC check test.
2024-11-15 10:59:36 +01:00
Gusted 1ce33aa38d
fix: extend forgejo_auth_token table
- Add a `purpose` column, this allows the `forgejo_auth_token` table to
be used by other parts of Forgejo, while still enjoying the
no-compromise architecture.
- Remove the 'roll your own crypto' time limited code functions and
migrate them to the `forgejo_auth_token` table. This migration ensures
generated codes can only be used for their purpose and ensure they are
invalidated after their usage by deleting it from the database, this
also should help making auditing of the security code easier, as we're
no longer trying to stuff a lot of data into a HMAC construction.
-Helper functions are rewritten to ensure a safe-by-design approach to
these tokens.
- Add the `forgejo_auth_token` to dbconsistency doctor and add it to the
`deleteUser` function.
- TODO: Add cron job to delete expired authorization tokens.
- Unit and integration tests added.
2024-11-15 10:59:36 +01:00
Michael Kriese 0fa436c373 Merge pull request 'ci: use oci mirror images' (#5963) from viceice/ci/oci-mirror into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5963
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
2024-11-15 08:22:35 +00:00
Michael Kriese 296935b0d7 Merge pull request 'chore: improve preparing tests' (#5948) from gusted/improve-testz into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5948
Reviewed-by: Otto <otto@codeberg.org>
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
2024-11-15 07:35:22 +00:00
Michael Kriese 1c25bbe773
test: fix e2e tests 2024-11-15 08:29:58 +01:00
Michael Kriese c8d97e5594
ci: use oci mirror images 2024-11-15 08:19:50 +01:00
Earl Warren e426a52a87 Merge pull request 'chore(release-notes): update the v9.0.2 & v7.0.11 links' (#5943) from earl-warren/forgejo:wip-release-notes into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5943
2024-11-15 07:11:46 +00:00
Michael Kriese faa796feb9 Merge pull request 'ci: proper job name' (#5964) from viceice/ci/job-name into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5964
Reviewed-by: Antonin Delpeuch <wetneb@noreply.codeberg.org>
2024-11-15 07:02:58 +00:00
Renovate Bot cdc38ace39 Update module google.golang.org/grpc to v1.68.0 2024-11-15 02:03:08 +00:00
Renovate Bot 4043377377 Update dependency tailwindcss to v3.4.15 2024-11-15 00:09:10 +00:00
Michael Kriese 19c9e0a0c2
ci: proper job name 2024-11-15 00:48:45 +01:00
Earl Warren ef9a0c8d3d Merge pull request 'Update module code.forgejo.org/forgejo/act to v1.22.0 (forgejo)' (#5949) from renovate/forgejo-code.forgejo.org-forgejo-act-1.x into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5949
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
2024-11-14 23:28:23 +00:00
Otto d1ad4dd561 Merge pull request 'Highlight user mention in comments and commit messages' (#5899) from 0ko/forgejo:mention-highlight into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5899
Reviewed-by: Otto <otto@codeberg.org>
2024-11-14 17:46:03 +00:00
Otto b92863b024 Merge pull request 'ci: use tmpfs for service storage' (#5958) from viceice/ci/use-tmpfs into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5958
Reviewed-by: Otto <otto@codeberg.org>
2024-11-14 17:44:22 +00:00
Michael Kriese 91fda7ee81 Merge pull request 'test: use sqlite in-memory db for integration' (#5956) from viceice/test/integration/use-sqlite-in-memory-db into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5956
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Reviewed-by: Otto <otto@codeberg.org>
2024-11-14 17:14:39 +00:00
Michael Kriese 8a4407ef72
ci: use tmpfs for service storage 2024-11-14 17:27:48 +01:00
Michael Kriese a8beeff422 Merge pull request 'ci: disable mysql binlog' (#5957) from viceice/ci/mysql/no-bin-log into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5957
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2024-11-14 16:26:48 +00:00
Michael Kriese eda83cc7ed
ci: disable mysql binlog 2024-11-14 16:39:34 +01:00
Michael Kriese aea3c7d6e8
test: use memory for integration and journal for migration 2024-11-14 15:38:06 +01:00
Michael Kriese 24028747d3
test: use sqlite in-memory db for integration 2024-11-14 15:38:06 +01:00
Otto Richter 019e38a746 chore(ci): Upload screenshots on test failure 2024-11-14 14:12:31 +01:00
Otto Richter 1f7a648057 tests(e2e): mention highlights in commit messages 2024-11-14 14:12:23 +01:00
Otto Richter c17b4bdaeb tests(e2e): Separate accessibility and form checks
- automatically test for light and dark themes
2024-11-14 14:08:12 +01:00
0ko 634519e891 feat(ui): highlight user mention in comments and commit messages 2024-11-14 14:08:12 +01:00
Gusted d1520cf08d
chore: improve preparing tests
- Only prepare repositories once.
- Move the repositories to temporary directories (these should usually be stored in
memory) which are recreated for each test to avoid persistentance
between tests. Doing some dirty profiling suggests that the preparing
test functions from 140-100ms to 70-40ms
2024-11-14 10:07:52 +01:00
Renovate Bot 8206d509fc Update module code.forgejo.org/forgejo/act to v1.22.0 2024-11-14 02:03:09 +00:00
Earl Warren a5ba7cadf7
chore(release-notes): update the v9.0.2 & v7.0.11 links 2024-11-13 23:23:47 +01:00
77 changed files with 1694 additions and 511 deletions

View file

@ -56,14 +56,15 @@ jobs:
image: 'code.forgejo.org/oci/node:20-bookworm' image: 'code.forgejo.org/oci/node:20-bookworm'
services: services:
elasticsearch: elasticsearch:
image: docker.io/bitnami/elasticsearch:7 image: code.forgejo.org/oci/bitnami/elasticsearch:7
options: --tmpfs /bitnami/elasticsearch/data
env: env:
discovery.type: single-node discovery.type: single-node
ES_JAVA_OPTS: "-Xms512m -Xmx512m" ES_JAVA_OPTS: "-Xms512m -Xmx512m"
minio: minio:
image: docker.io/bitnami/minio:2024.8.17 image: code.forgejo.org/oci/bitnami/minio:2024.8.17
options: >- options: >-
--hostname gitea.minio --hostname gitea.minio --tmpfs /bitnami/minio/data
env: env:
MINIO_DOMAIN: minio MINIO_DOMAIN: minio
MINIO_ROOT_USER: 123456 MINIO_ROOT_USER: 123456
@ -121,27 +122,35 @@ jobs:
USE_REPO_TEST_DIR: 1 USE_REPO_TEST_DIR: 1
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
CHANGED_FILES: ${{steps.changed-files.outputs.all_changed_files}} CHANGED_FILES: ${{steps.changed-files.outputs.all_changed_files}}
- name: Upload test artifacts on failure
if: failure()
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: test-artifacts.zip
path: tests/e2e/test-artifacts/
retention-days: 3
test-remote-cacher: test-remote-cacher:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing' if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: docker runs-on: docker
needs: [backend-checks, frontend-checks, test-unit] needs: [backend-checks, frontend-checks, test-unit]
container: container:
image: 'code.forgejo.org/oci/node:20-bookworm' image: 'code.forgejo.org/oci/node:20-bookworm'
name: ${{ format('test-remote-cacher ({0})', matrix.cacher.name) }}
strategy: strategy:
matrix: matrix:
cacher: cacher:
# redis - name: redis
- image: docker.io/bitnami/redis:7.2 image: code.forgejo.org/oci/bitnami/redis:7.2
port: 6379 options: --tmpfs /bitnami/redis/data
# redict - name: redict
- image: registry.redict.io/redict:7.3.0-scratch image: registry.redict.io/redict:7.3.0-scratch
port: 6379 options: --tmpfs /data
# valkey - name: valkey
- image: docker.io/bitnami/valkey:7.2 image: code.forgejo.org/oci/bitnami/valkey:7.2
port: 6379 options: --tmpfs /bitnami/redis/data
# garnet - name: garnet
- image: ghcr.io/microsoft/garnet-alpine:1.0.14 image: ghcr.io/microsoft/garnet-alpine:1.0.14
port: 6379 options: --tmpfs /data
services: services:
cacher: cacher:
image: ${{ matrix.cacher.image }} image: ${{ matrix.cacher.image }}
@ -169,14 +178,15 @@ jobs:
image: 'code.forgejo.org/oci/node:20-bookworm' image: 'code.forgejo.org/oci/node:20-bookworm'
services: services:
mysql: mysql:
image: 'docker.io/bitnami/mysql:8.4' image: 'code.forgejo.org/oci/bitnami/mysql:8.4'
env: env:
ALLOW_EMPTY_PASSWORD: yes ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: testgitea MYSQL_DATABASE: testgitea
# #
# See also https://codeberg.org/forgejo/forgejo/issues/976 # See also https://codeberg.org/forgejo/forgejo/issues/976
# #
MYSQL_EXTRA_FLAGS: --innodb-adaptive-flushing=OFF --innodb-buffer-pool-size=4G --innodb-log-buffer-size=128M --innodb-flush-log-at-trx-commit=0 --innodb-flush-log-at-timeout=30 --innodb-flush-method=nosync --innodb-fsync-threshold=1000000000 MYSQL_EXTRA_FLAGS: --innodb-adaptive-flushing=OFF --innodb-buffer-pool-size=4G --innodb-log-buffer-size=128M --innodb-flush-log-at-trx-commit=0 --innodb-flush-log-at-timeout=30 --innodb-flush-method=nosync --innodb-fsync-threshold=1000000000 --disable-log-bin
options: --tmpfs /bitnami/mysql/data
steps: steps:
- uses: https://code.forgejo.org/actions/checkout@v4 - uses: https://code.forgejo.org/actions/checkout@v4
- uses: ./.forgejo/workflows-composite/setup-env - uses: ./.forgejo/workflows-composite/setup-env
@ -198,17 +208,19 @@ jobs:
image: 'code.forgejo.org/oci/node:20-bookworm' image: 'code.forgejo.org/oci/node:20-bookworm'
services: services:
minio: minio:
image: docker.io/bitnami/minio:2024.8.17 image: code.forgejo.org/oci/bitnami/minio:2024.8.17
env: env:
MINIO_ROOT_USER: 123456 MINIO_ROOT_USER: 123456
MINIO_ROOT_PASSWORD: 12345678 MINIO_ROOT_PASSWORD: 12345678
options: --tmpfs /bitnami/minio/data
ldap: ldap:
image: docker.io/gitea/test-openldap:latest image: code.forgejo.org/oci/test-openldap:latest
pgsql: pgsql:
image: 'code.forgejo.org/oci/postgres:15' image: 'code.forgejo.org/oci/postgres:15'
env: env:
POSTGRES_DB: test POSTGRES_DB: test
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
options: --tmpfs /var/lib/postgresql/data
steps: steps:
- uses: https://code.forgejo.org/actions/checkout@v4 - uses: https://code.forgejo.org/actions/checkout@v4
- uses: ./.forgejo/workflows-composite/setup-env - uses: ./.forgejo/workflows-composite/setup-env

View file

@ -6,6 +6,10 @@ A [patch or minor release](https://semver.org/spec/v2.0.0.html) (e.g. upgrading
The release notes of each release [are available in the corresponding milestone](https://codeberg.org/forgejo/forgejo/milestones), starting with [Forgejo 7.0.7](https://codeberg.org/forgejo/forgejo/milestone/7683) and [Forgejo 8.0.1](https://codeberg.org/forgejo/forgejo/milestone/7682). The release notes of each release [are available in the corresponding milestone](https://codeberg.org/forgejo/forgejo/milestones), starting with [Forgejo 7.0.7](https://codeberg.org/forgejo/forgejo/milestone/7683) and [Forgejo 8.0.1](https://codeberg.org/forgejo/forgejo/milestone/7682).
## 9.0.2
The Forgejo v9.0.2 release notes are [available in the v9.0.2 milestone](https://codeberg.org/forgejo/forgejo/milestone/8610).
## 9.0.1 ## 9.0.1
The Forgejo v9.0.1 release notes are [available in the v9.0.1 milestone](https://codeberg.org/forgejo/forgejo/milestone/8544). The Forgejo v9.0.1 release notes are [available in the v9.0.1 milestone](https://codeberg.org/forgejo/forgejo/milestone/8544).
@ -163,6 +167,10 @@ A [companion blog post](https://forgejo.org/2024-07-release-v8-0/) provides addi
- [PR](https://codeberg.org/forgejo/forgejo/pulls/2937): <!--number 2937 --><!--number--><!--description -->31 March updates<!--description--> - [PR](https://codeberg.org/forgejo/forgejo/pulls/2937): <!--number 2937 --><!--number--><!--description -->31 March updates<!--description-->
<!--end release-notes-assistant--> <!--end release-notes-assistant-->
## 7.0.11
The Forgejo v7.0.11 release notes are [available in the v7.0.11 milestone](https://codeberg.org/forgejo/forgejo/milestone/8609).
## 7.0.10 ## 7.0.10
The Forgejo v7.0.10 release notes are [available in the v7.0.10 milestone](https://codeberg.org/forgejo/forgejo/milestone/8286). The Forgejo v7.0.10 release notes are [available in the v7.0.10 milestone](https://codeberg.org/forgejo/forgejo/milestone/8286).

8
go.mod
View file

@ -107,7 +107,7 @@ require (
golang.org/x/sys v0.27.0 golang.org/x/sys v0.27.0
golang.org/x/text v0.20.0 golang.org/x/text v0.20.0
golang.org/x/tools v0.26.0 golang.org/x/tools v0.26.0
google.golang.org/grpc v1.67.1 google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.35.1 google.golang.org/protobuf v1.35.1
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/ini.v1 v1.67.0 gopkg.in/ini.v1 v1.67.0
@ -283,7 +283,7 @@ require (
golang.org/x/mod v0.21.0 // indirect golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.9.0 // indirect golang.org/x/sync v0.9.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
@ -293,8 +293,8 @@ replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1
replace github.com/shurcooL/vfsgen => github.com/lunny/vfsgen v0.0.0-20220105142115-2c99e1ffdfa0 replace github.com/shurcooL/vfsgen => github.com/lunny/vfsgen v0.0.0-20220105142115-2c99e1ffdfa0
replace github.com/nektos/act => code.forgejo.org/forgejo/act v1.21.5 replace github.com/nektos/act => code.forgejo.org/forgejo/act v1.22.0
replace github.com/mholt/archiver/v3 => code.forgejo.org/forgejo/archiver/v3 v3.5.1 replace github.com/mholt/archiver/v3 => code.forgejo.org/forgejo/archiver/v3 v3.5.1
replace github.com/goccy/go-json => github.com/grafana/go-json v0.0.0-20241106155216-71a03f133f5c replace github.com/goccy/go-json => github.com/grafana/go-json v0.0.0-20241115232854-f14426c40ff2

16
go.sum
View file

@ -4,8 +4,8 @@ code.forgejo.org/f3/gof3/v3 v3.7.0 h1:ZfuCP8CGm8ZJbWmL+V0pUu3E0X4FCAA7GfRDy/y5/K
code.forgejo.org/f3/gof3/v3 v3.7.0/go.mod h1:oNhOeqD4DZYjVcNjQXIOdDX9b/1tqxi9ITLS8H9/Csw= code.forgejo.org/f3/gof3/v3 v3.7.0/go.mod h1:oNhOeqD4DZYjVcNjQXIOdDX9b/1tqxi9ITLS8H9/Csw=
code.forgejo.org/forgejo-contrib/go-libravatar v0.0.0-20191008002943-06d1c002b251 h1:HTZl3CBk3ABNYtFI6TPLvJgGKFIhKT5CBk0sbOtkDKU= code.forgejo.org/forgejo-contrib/go-libravatar v0.0.0-20191008002943-06d1c002b251 h1:HTZl3CBk3ABNYtFI6TPLvJgGKFIhKT5CBk0sbOtkDKU=
code.forgejo.org/forgejo-contrib/go-libravatar v0.0.0-20191008002943-06d1c002b251/go.mod h1:PphB88CPbx601QrWPMZATeorACeVmQlyv3u+uUMbSaM= code.forgejo.org/forgejo-contrib/go-libravatar v0.0.0-20191008002943-06d1c002b251/go.mod h1:PphB88CPbx601QrWPMZATeorACeVmQlyv3u+uUMbSaM=
code.forgejo.org/forgejo/act v1.21.5 h1:rWI+bhClocogdNwjRrM836rZYY7JBcHY3VUAwkYqEtw= code.forgejo.org/forgejo/act v1.22.0 h1:NbUf0+vQ48+ddwe4zVkINqnxKYl/to+NUvW7iisPA60=
code.forgejo.org/forgejo/act v1.21.5/go.mod h1:+PcvJ9iv+NTFeJSh79ra9Jbk9l0vvyA9D9me5/dbxYM= code.forgejo.org/forgejo/act v1.22.0/go.mod h1:+PcvJ9iv+NTFeJSh79ra9Jbk9l0vvyA9D9me5/dbxYM=
code.forgejo.org/forgejo/archiver/v3 v3.5.1 h1:UmmbA7D5550uf71SQjarmrn6yKwOGxtEjb3jaYYtmSE= code.forgejo.org/forgejo/archiver/v3 v3.5.1 h1:UmmbA7D5550uf71SQjarmrn6yKwOGxtEjb3jaYYtmSE=
code.forgejo.org/forgejo/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= code.forgejo.org/forgejo/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
code.forgejo.org/forgejo/reply v1.0.2 h1:dMhQCHV6/O3L5CLWNTol+dNzDAuyCK88z4J/lCdgFuQ= code.forgejo.org/forgejo/reply v1.0.2 h1:dMhQCHV6/O3L5CLWNTol+dNzDAuyCK88z4J/lCdgFuQ=
@ -381,8 +381,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/grafana/go-json v0.0.0-20241106155216-71a03f133f5c h1:yKBKEC347YZpgii1KazRCfxHsTaxMqWZzoivM1OTT50= github.com/grafana/go-json v0.0.0-20241115232854-f14426c40ff2 h1:8xGrYqQ1GM4aaMk7pNDfecBdL/VGhEbpvvGBoqO6BIY=
github.com/grafana/go-json v0.0.0-20241106155216-71a03f133f5c/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/grafana/go-json v0.0.0-20241115232854-f14426c40ff2/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
@ -843,10 +843,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

View file

@ -15,12 +15,31 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
type AuthorizationPurpose string
var (
// Used to store long term authorization tokens.
LongTermAuthorization AuthorizationPurpose = "long_term_authorization"
// Used to activate a user account.
UserActivation AuthorizationPurpose = "user_activation"
// Used to reset the password.
PasswordReset AuthorizationPurpose = "password_reset"
)
// Used to activate the specified email address for a user.
func EmailActivation(email string) AuthorizationPurpose {
return AuthorizationPurpose("email_activation:" + email)
}
// AuthorizationToken represents a authorization token to a user. // AuthorizationToken represents a authorization token to a user.
type AuthorizationToken struct { type AuthorizationToken struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
UID int64 `xorm:"INDEX"` UID int64 `xorm:"INDEX"`
LookupKey string `xorm:"INDEX UNIQUE"` LookupKey string `xorm:"INDEX UNIQUE"`
HashedValidator string HashedValidator string
Purpose AuthorizationPurpose `xorm:"NOT NULL DEFAULT 'long_term_authorization'"`
Expiry timeutil.TimeStamp Expiry timeutil.TimeStamp
} }
@ -41,7 +60,7 @@ func (authToken *AuthorizationToken) IsExpired() bool {
// GenerateAuthToken generates a new authentication token for the given user. // GenerateAuthToken generates a new authentication token for the given user.
// It returns the lookup key and validator values that should be passed to the // It returns the lookup key and validator values that should be passed to the
// user via a long-term cookie. // user via a long-term cookie.
func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeStamp) (lookupKey, validator string, err error) { func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeStamp, purpose AuthorizationPurpose) (lookupKey, validator string, err error) {
// Request 64 random bytes. The first 32 bytes will be used for the lookupKey // Request 64 random bytes. The first 32 bytes will be used for the lookupKey
// and the other 32 bytes will be used for the validator. // and the other 32 bytes will be used for the validator.
rBytes, err := util.CryptoRandomBytes(64) rBytes, err := util.CryptoRandomBytes(64)
@ -56,14 +75,15 @@ func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeSt
Expiry: expiry, Expiry: expiry,
LookupKey: lookupKey, LookupKey: lookupKey,
HashedValidator: HashValidator(rBytes[32:]), HashedValidator: HashValidator(rBytes[32:]),
Purpose: purpose,
}) })
return lookupKey, validator, err return lookupKey, validator, err
} }
// FindAuthToken will find a authorization token via the lookup key. // FindAuthToken will find a authorization token via the lookup key.
func FindAuthToken(ctx context.Context, lookupKey string) (*AuthorizationToken, error) { func FindAuthToken(ctx context.Context, lookupKey string, purpose AuthorizationPurpose) (*AuthorizationToken, error) {
var authToken AuthorizationToken var authToken AuthorizationToken
has, err := db.GetEngine(ctx).Where("lookup_key = ?", lookupKey).Get(&authToken) has, err := db.GetEngine(ctx).Where("lookup_key = ? AND purpose = ?", lookupKey, purpose).Get(&authToken)
if err != nil { if err != nil {
return nil, err return nil, err
} else if !has { } else if !has {

View file

@ -7,6 +7,7 @@
target_url: https://example.com/builds/ target_url: https://example.com/builds/
description: My awesome CI-service description: My awesome CI-service
context: ci/awesomeness context: ci/awesomeness
context_hash: c65f4d64a3b14a3eced0c9b36799e66e1bd5ced7
creator_id: 2 creator_id: 2
- -
@ -18,6 +19,7 @@
target_url: https://example.com/coverage/ target_url: https://example.com/coverage/
description: My awesome Coverage service description: My awesome Coverage service
context: cov/awesomeness context: cov/awesomeness
context_hash: 3929ac7bccd3fa1bf9b38ddedb77973b1b9a8cfe
creator_id: 2 creator_id: 2
- -
@ -29,6 +31,7 @@
target_url: https://example.com/coverage/ target_url: https://example.com/coverage/
description: My awesome Coverage service description: My awesome Coverage service
context: cov/awesomeness context: cov/awesomeness
context_hash: 3929ac7bccd3fa1bf9b38ddedb77973b1b9a8cfe
creator_id: 2 creator_id: 2
- -
@ -40,6 +43,7 @@
target_url: https://example.com/builds/ target_url: https://example.com/builds/
description: My awesome CI-service description: My awesome CI-service
context: ci/awesomeness context: ci/awesomeness
context_hash: c65f4d64a3b14a3eced0c9b36799e66e1bd5ced7
creator_id: 2 creator_id: 2
- -
@ -51,15 +55,41 @@
target_url: https://example.com/builds/ target_url: https://example.com/builds/
description: My awesome deploy service description: My awesome deploy service
context: deploy/awesomeness context: deploy/awesomeness
context_hash: ae9547713a6665fc4261d0756904932085a41cf2
creator_id: 2 creator_id: 2
- -
id: 6 id: 6
index: 6 index: 1
repo_id: 62 repo_id: 62
state: "failure" state: "failure"
sha: "774f93df12d14931ea93259ae93418da4482fcc1" sha: "774f93df12d14931ea93259ae93418da4482fcc1"
target_url: "/user2/test_workflows/actions" target_url: "/user2/test_workflows/actions"
description: My awesome deploy service description: My awesome deploy service
context: deploy/awesomeness context: deploy/awesomeness
context_hash: ae9547713a6665fc4261d0756904932085a41cf2
creator_id: 2
-
id: 7
index: 6
repo_id: 1
state: "pending"
sha: "1234123412341234123412341234123412341234"
target_url: https://example.com/builds/
description: My awesome deploy service
context: deploy/awesomeness
context_hash: ae9547713a6665fc4261d0756904932085a41cf2
creator_id: 2
-
id: 8
index: 2
repo_id: 62
state: "error"
sha: "774f93df12d14931ea93259ae93418da4482fcc1"
target_url: "/user2/test_workflows/actions"
description: "My awesome deploy service - v2"
context: deploy/awesomeness
context_hash: ae9547713a6665fc4261d0756904932085a41cf2
creator_id: 2 creator_id: 2

View file

@ -84,6 +84,8 @@ var migrations = []*Migration{
NewMigration("Add `legacy` to `web_authn_credential` table", AddLegacyToWebAuthnCredential), NewMigration("Add `legacy` to `web_authn_credential` table", AddLegacyToWebAuthnCredential),
// v23 -> v24 // v23 -> v24
NewMigration("Add `delete_branch_after_merge` to `auto_merge` table", AddDeleteBranchAfterMergeToAutoMerge), NewMigration("Add `delete_branch_after_merge` to `auto_merge` table", AddDeleteBranchAfterMergeToAutoMerge),
// v24 -> v25
NewMigration("Add `purpose` column to `forgejo_auth_token` table", AddPurposeToForgejoAuthToken),
} }
// GetCurrentDBVersion returns the current Forgejo database version. // GetCurrentDBVersion returns the current Forgejo database version.

View file

@ -0,0 +1,19 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forgejo_migrations //nolint:revive
import "xorm.io/xorm"
func AddPurposeToForgejoAuthToken(x *xorm.Engine) error {
type ForgejoAuthToken struct {
ID int64 `xorm:"pk autoincr"`
Purpose string `xorm:"NOT NULL DEFAULT 'long_term_authorization'"`
}
if err := x.Sync(new(ForgejoAuthToken)); err != nil {
return err
}
_, err := x.Exec("UPDATE `forgejo_auth_token` SET purpose = 'long_term_authorization' WHERE purpose = ''")
return err
}

View file

@ -288,27 +288,18 @@ func GetLatestCommitStatus(ctx context.Context, repoID int64, sha string, listOp
// GetLatestCommitStatusForPairs returns all statuses with a unique context for a given list of repo-sha pairs // GetLatestCommitStatusForPairs returns all statuses with a unique context for a given list of repo-sha pairs
func GetLatestCommitStatusForPairs(ctx context.Context, repoSHAs []RepoSHA) (map[int64][]*CommitStatus, error) { func GetLatestCommitStatusForPairs(ctx context.Context, repoSHAs []RepoSHA) (map[int64][]*CommitStatus, error) {
type result struct { results := []*CommitStatus{}
Index int64
RepoID int64
SHA string
}
results := make([]result, 0, len(repoSHAs))
getBase := func() *xorm.Session {
return db.GetEngine(ctx).Table(&CommitStatus{})
}
// Create a disjunction of conditions for each repoID and SHA pair // Create a disjunction of conditions for each repoID and SHA pair
conds := make([]builder.Cond, 0, len(repoSHAs)) conds := make([]builder.Cond, 0, len(repoSHAs))
for _, repoSHA := range repoSHAs { for _, repoSHA := range repoSHAs {
conds = append(conds, builder.Eq{"repo_id": repoSHA.RepoID, "sha": repoSHA.SHA}) conds = append(conds, builder.Eq{"repo_id": repoSHA.RepoID, "sha": repoSHA.SHA})
} }
sess := getBase().Where(builder.Or(conds...)).
Select("max( `index` ) as `index`, repo_id, sha").
GroupBy("context_hash, repo_id, sha").OrderBy("max( `index` ) desc")
sess := db.GetEngine(ctx).Table(&CommitStatus{}).
Select("MAX(`index`) AS `index`, *").
Where(builder.Or(conds...)).
GroupBy("context_hash, repo_id, sha").OrderBy("MAX(`index`) DESC")
err := sess.Find(&results) err := sess.Find(&results)
if err != nil { if err != nil {
return nil, err return nil, err
@ -316,28 +307,10 @@ func GetLatestCommitStatusForPairs(ctx context.Context, repoSHAs []RepoSHA) (map
repoStatuses := make(map[int64][]*CommitStatus) repoStatuses := make(map[int64][]*CommitStatus)
if len(results) > 0 {
statuses := make([]*CommitStatus, 0, len(results))
conds = make([]builder.Cond, 0, len(results))
for _, result := range results {
cond := builder.Eq{
"`index`": result.Index,
"repo_id": result.RepoID,
"sha": result.SHA,
}
conds = append(conds, cond)
}
err = getBase().Where(builder.Or(conds...)).Find(&statuses)
if err != nil {
return nil, err
}
// Group the statuses by repo ID // Group the statuses by repo ID
for _, status := range statuses { for _, status := range results {
repoStatuses[status.RepoID] = append(repoStatuses[status.RepoID], status) repoStatuses[status.RepoID] = append(repoStatuses[status.RepoID], status)
} }
}
return repoStatuses, nil return repoStatuses, nil
} }

View file

@ -35,8 +35,8 @@ func TestGetCommitStatuses(t *testing.T) {
SHA: sha1, SHA: sha1,
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 5, int(maxResults)) assert.EqualValues(t, 6, maxResults)
assert.Len(t, statuses, 5) assert.Len(t, statuses, 6)
assert.Equal(t, "ci/awesomeness", statuses[0].Context) assert.Equal(t, "ci/awesomeness", statuses[0].Context)
assert.Equal(t, structs.CommitStatusPending, statuses[0].State) assert.Equal(t, structs.CommitStatusPending, statuses[0].State)
@ -58,13 +58,17 @@ func TestGetCommitStatuses(t *testing.T) {
assert.Equal(t, structs.CommitStatusError, statuses[4].State) assert.Equal(t, structs.CommitStatusError, statuses[4].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].APIURL(db.DefaultContext)) assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].APIURL(db.DefaultContext))
assert.Equal(t, "deploy/awesomeness", statuses[5].Context)
assert.Equal(t, structs.CommitStatusPending, statuses[5].State)
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[5].APIURL(db.DefaultContext))
statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](db.DefaultContext, &git_model.CommitStatusOptions{ statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](db.DefaultContext, &git_model.CommitStatusOptions{
ListOptions: db.ListOptions{Page: 2, PageSize: 50}, ListOptions: db.ListOptions{Page: 2, PageSize: 50},
RepoID: repo1.ID, RepoID: repo1.ID,
SHA: sha1, SHA: sha1,
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 5, int(maxResults)) assert.EqualValues(t, 6, maxResults)
assert.Empty(t, statuses) assert.Empty(t, statuses)
} }
@ -265,3 +269,148 @@ func TestCommitStatusesHideActionsURL(t *testing.T) {
assert.Empty(t, statuses[0].TargetURL) assert.Empty(t, statuses[0].TargetURL)
assert.Equal(t, "https://mycicd.org/1", statuses[1].TargetURL) assert.Equal(t, "https://mycicd.org/1", statuses[1].TargetURL)
} }
func TestGetLatestCommitStatusForPairs(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("All", func(t *testing.T) {
pairs, err := git_model.GetLatestCommitStatusForPairs(db.DefaultContext, nil)
require.NoError(t, err)
assert.EqualValues(t, map[int64][]*git_model.CommitStatus{
1: {
{
ID: 7,
Index: 6,
RepoID: 1,
State: structs.CommitStatusPending,
SHA: "1234123412341234123412341234123412341234",
TargetURL: "https://example.com/builds/",
Description: "My awesome deploy service",
ContextHash: "ae9547713a6665fc4261d0756904932085a41cf2",
Context: "deploy/awesomeness",
CreatorID: 2,
},
{
ID: 4,
Index: 4,
State: structs.CommitStatusFailure,
TargetURL: "https://example.com/builds/",
Description: "My awesome CI-service",
Context: "ci/awesomeness",
CreatorID: 2,
RepoID: 1,
SHA: "1234123412341234123412341234123412341234",
ContextHash: "c65f4d64a3b14a3eced0c9b36799e66e1bd5ced7",
},
{
ID: 3,
Index: 3,
State: structs.CommitStatusSuccess,
TargetURL: "https://example.com/coverage/",
Description: "My awesome Coverage service",
Context: "cov/awesomeness",
CreatorID: 2,
RepoID: 1,
SHA: "1234123412341234123412341234123412341234",
ContextHash: "3929ac7bccd3fa1bf9b38ddedb77973b1b9a8cfe",
},
},
62: {
{
ID: 8,
Index: 2,
RepoID: 62,
State: structs.CommitStatusError,
TargetURL: "/user2/test_workflows/actions",
Description: "My awesome deploy service - v2",
Context: "deploy/awesomeness",
SHA: "774f93df12d14931ea93259ae93418da4482fcc1",
ContextHash: "ae9547713a6665fc4261d0756904932085a41cf2",
CreatorID: 2,
},
},
}, pairs)
})
t.Run("Repo 1", func(t *testing.T) {
pairs, err := git_model.GetLatestCommitStatusForPairs(db.DefaultContext, []git_model.RepoSHA{{1, "1234123412341234123412341234123412341234"}})
require.NoError(t, err)
assert.EqualValues(t, map[int64][]*git_model.CommitStatus{
1: {
{
ID: 7,
Index: 6,
RepoID: 1,
State: structs.CommitStatusPending,
SHA: "1234123412341234123412341234123412341234",
TargetURL: "https://example.com/builds/",
Description: "My awesome deploy service",
ContextHash: "ae9547713a6665fc4261d0756904932085a41cf2",
Context: "deploy/awesomeness",
CreatorID: 2,
},
{
ID: 4,
Index: 4,
State: structs.CommitStatusFailure,
TargetURL: "https://example.com/builds/",
Description: "My awesome CI-service",
Context: "ci/awesomeness",
CreatorID: 2,
RepoID: 1,
SHA: "1234123412341234123412341234123412341234",
ContextHash: "c65f4d64a3b14a3eced0c9b36799e66e1bd5ced7",
},
{
ID: 3,
Index: 3,
State: structs.CommitStatusSuccess,
TargetURL: "https://example.com/coverage/",
Description: "My awesome Coverage service",
Context: "cov/awesomeness",
CreatorID: 2,
RepoID: 1,
SHA: "1234123412341234123412341234123412341234",
ContextHash: "3929ac7bccd3fa1bf9b38ddedb77973b1b9a8cfe",
},
},
}, pairs)
})
t.Run("Repo 62", func(t *testing.T) {
pairs, err := git_model.GetLatestCommitStatusForPairs(db.DefaultContext, []git_model.RepoSHA{{62, "774f93df12d14931ea93259ae93418da4482fcc1"}})
require.NoError(t, err)
assert.EqualValues(t, map[int64][]*git_model.CommitStatus{
62: {
{
ID: 8,
Index: 2,
RepoID: 62,
State: structs.CommitStatusError,
TargetURL: "/user2/test_workflows/actions",
Description: "My awesome deploy service - v2",
Context: "deploy/awesomeness",
SHA: "774f93df12d14931ea93259ae93418da4482fcc1",
ContextHash: "ae9547713a6665fc4261d0756904932085a41cf2",
CreatorID: 2,
},
},
}, pairs)
})
t.Run("Repo 62 nonexistant sha", func(t *testing.T) {
pairs, err := git_model.GetLatestCommitStatusForPairs(db.DefaultContext, []git_model.RepoSHA{{62, "774f93df12d14931ea93259ae93418da4482fcc"}})
require.NoError(t, err)
assert.EqualValues(t, map[int64][]*git_model.CommitStatus{}, pairs)
})
t.Run("SHA with non existant repo id", func(t *testing.T) {
pairs, err := git_model.GetLatestCommitStatusForPairs(db.DefaultContext, []git_model.RepoSHA{{1, "774f93df12d14931ea93259ae93418da4482fcc1"}})
require.NoError(t, err)
assert.EqualValues(t, map[int64][]*git_model.CommitStatus{}, pairs)
})
}

View file

@ -0,0 +1,30 @@
-
id: 1001
owner_id: 33
owner_name: user33
lower_name: repo1001
name: repo1001
default_branch: main
num_watches: 0
num_stars: 0
num_forks: 0
num_issues: 0
num_closed_issues: 0
num_pulls: 0
num_closed_pulls: 0
num_milestones: 0
num_closed_milestones: 0
num_projects: 0
num_closed_projects: 0
is_private: false
is_empty: false
is_archived: false
is_mirror: false
status: 0
is_fork: false
fork_id: 0
is_template: false
template_id: 0
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false

View file

@ -7,6 +7,7 @@ import (
"context" "context"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"xorm.io/builder" "xorm.io/builder"
@ -54,9 +55,9 @@ func GetUserFork(ctx context.Context, repoID, userID int64) (*Repository, error)
return &forkedRepo, nil return &forkedRepo, nil
} }
// GetForks returns all the forks of the repository // GetForks returns all the forks of the repository that are visible to the user.
func GetForks(ctx context.Context, repo *Repository, listOptions db.ListOptions) ([]*Repository, error) { func GetForks(ctx context.Context, repo *Repository, user *user_model.User, listOptions db.ListOptions) ([]*Repository, int64, error) {
sess := db.GetEngine(ctx) sess := db.GetEngine(ctx).Where(AccessibleRepositoryCondition(user, unit.TypeInvalid))
var forks []*Repository var forks []*Repository
if listOptions.Page == 0 { if listOptions.Page == 0 {
@ -66,7 +67,8 @@ func GetForks(ctx context.Context, repo *Repository, listOptions db.ListOptions)
sess = db.SetSessionPagination(sess, &listOptions) sess = db.SetSessionPagination(sess, &listOptions)
} }
return forks, sess.Find(&forks, &Repository{ForkID: repo.ID}) count, err := sess.FindAndCount(&forks, &Repository{ForkID: repo.ID})
return forks, count, err
} }
// IncrementRepoForkNum increment repository fork number // IncrementRepoForkNum increment repository fork number

View file

@ -641,12 +641,9 @@ func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) bu
// 1. Be able to see all non-private repositories that either: // 1. Be able to see all non-private repositories that either:
cond = cond.Or(builder.And( cond = cond.Or(builder.And(
builder.Eq{"`repository`.is_private": false}, builder.Eq{"`repository`.is_private": false},
// 2. Aren't in an private organisation or limited organisation if we're not logged in // 2. Aren't in an private organisation/user or limited organisation/user if the doer is not logged in.
builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where( builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where(
builder.And( builder.In("visibility", orgVisibilityLimit)))))
builder.Eq{"type": user_model.UserTypeOrganization},
builder.In("visibility", orgVisibilityLimit)),
))))
} }
if user != nil { if user != nil {

View file

@ -4,13 +4,18 @@
package repo_test package repo_test
import ( import (
"path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -403,3 +408,43 @@ func TestSearchRepositoryByTopicName(t *testing.T) {
}) })
} }
} }
func TestSearchRepositoryIDsByCondition(t *testing.T) {
defer unittest.OverrideFixtures(
unittest.FixturesOptions{
Dir: filepath.Join(setting.AppWorkPath, "models/fixtures/"),
Base: setting.AppWorkPath,
Dirs: []string{"models/repo/TestSearchRepositoryIDsByCondition/"},
},
)()
require.NoError(t, unittest.PrepareTestDatabase())
// Sanity check of the database
limitedUser := unittest.AssertExistsAndLoadBean(t, &user.User{ID: 33, Visibility: structs.VisibleTypeLimited})
unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1001, OwnerID: limitedUser.ID})
testCases := []struct {
user *user.User
repoIDs []int64
}{
{
user: nil,
repoIDs: []int64{1, 4, 8, 9, 10, 11, 12, 14, 17, 18, 21, 23, 25, 27, 29, 32, 33, 34, 35, 36, 37, 42, 44, 45, 46, 47, 48, 49, 50, 51, 53, 57, 58, 60, 61, 62, 1059},
},
{
user: unittest.AssertExistsAndLoadBean(t, &user.User{ID: 4}),
repoIDs: []int64{1, 3, 4, 8, 9, 10, 11, 12, 14, 17, 18, 21, 23, 25, 27, 29, 32, 33, 34, 35, 36, 37, 38, 40, 42, 44, 45, 46, 47, 48, 49, 50, 51, 53, 57, 58, 60, 61, 62, 1001, 1059},
},
{
user: unittest.AssertExistsAndLoadBean(t, &user.User{ID: 5}),
repoIDs: []int64{1, 4, 8, 9, 10, 11, 12, 14, 17, 18, 21, 23, 25, 27, 29, 32, 33, 34, 35, 36, 37, 38, 40, 42, 44, 45, 46, 47, 48, 49, 50, 51, 53, 57, 58, 60, 61, 62, 1001, 1059},
},
}
for _, testCase := range testCases {
repoIDs, err := repo_model.FindUserCodeAccessibleRepoIDs(db.DefaultContext, testCase.user)
require.NoError(t, err)
slices.Sort(repoIDs)
assert.EqualValues(t, testCase.repoIDs, repoIDs)
}
}

View file

@ -75,6 +75,10 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us
return nil, err return nil, err
} }
uniqueUserIDs := make(container.Set[int64])
uniqueUserIDs.AddMultiple(userIDs...)
if repo.Owner.IsOrganization() {
additionalUserIDs := make([]int64, 0, 10) additionalUserIDs := make([]int64, 0, 10)
if err = e.Table("team_user"). if err = e.Table("team_user").
Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id"). Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id").
@ -86,15 +90,13 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us
Find(&additionalUserIDs); err != nil { Find(&additionalUserIDs); err != nil {
return nil, err return nil, err
} }
uniqueUserIDs := make(container.Set[int64])
uniqueUserIDs.AddMultiple(userIDs...)
uniqueUserIDs.AddMultiple(additionalUserIDs...) uniqueUserIDs.AddMultiple(additionalUserIDs...)
}
// Leave a seat for owner itself to append later, but if owner is an organization // Leave a seat for owner itself to append later, but if owner is an organization
// and just waste 1 unit is cheaper than re-allocate memory once. // and just waste 1 unit is cheaper than re-allocate memory once.
users := make([]*user_model.User, 0, len(uniqueUserIDs)+1) users := make([]*user_model.User, 0, len(uniqueUserIDs)+1)
if len(userIDs) > 0 { if len(uniqueUserIDs) > 0 {
if err = e.In("id", uniqueUserIDs.Values()). if err = e.In("id", uniqueUserIDs.Values()).
Where(builder.Eq{"`user`.is_active": true}). Where(builder.Eq{"`user`.is_active": true}).
OrderBy(user_model.GetOrderByName()). OrderBy(user_model.GetOrderByName()).

View file

@ -8,10 +8,8 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -246,23 +244,6 @@ func updateActivation(ctx context.Context, email *EmailAddress, activate bool) e
return UpdateUserCols(ctx, user, "rands") return UpdateUserCols(ctx, user, "rands")
} }
// VerifyActiveEmailCode verifies active email code when active account
func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress {
if user := GetVerifyUser(ctx, code); user != nil {
// time limit code
prefix := code[:base.TimeLimitCodeLength]
data := fmt.Sprintf("%d%s%s%s%s", user.ID, email, user.LowerName, user.Passwd, user.Rands)
if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) {
emailAddress := &EmailAddress{UID: user.ID, Email: email}
if has, _ := db.GetEngine(ctx).Get(emailAddress); has {
return emailAddress
}
}
}
return nil
}
// SearchEmailOrderBy is used to sort the results from SearchEmails() // SearchEmailOrderBy is used to sort the results from SearchEmails()
type SearchEmailOrderBy string type SearchEmailOrderBy string

View file

@ -7,7 +7,9 @@ package user
import ( import (
"context" "context"
"crypto/subtle"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"net/mail" "net/mail"
"net/url" "net/url"
@ -318,15 +320,14 @@ func (u *User) OrganisationLink() string {
return setting.AppSubURL + "/org/" + url.PathEscape(u.Name) return setting.AppSubURL + "/org/" + url.PathEscape(u.Name)
} }
// GenerateEmailActivateCode generates an activate code based on user information and given e-mail. // GenerateEmailAuthorizationCode generates an activation code based for the user for the specified purpose.
func (u *User) GenerateEmailActivateCode(email string) string { // The standard expiry is ActiveCodeLives minutes.
code := base.CreateTimeLimitCode( func (u *User) GenerateEmailAuthorizationCode(ctx context.Context, purpose auth.AuthorizationPurpose) (string, error) {
fmt.Sprintf("%d%s%s%s%s", u.ID, email, u.LowerName, u.Passwd, u.Rands), lookup, validator, err := auth.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(setting.Service.ActiveCodeLives)*60), purpose)
setting.Service.ActiveCodeLives, time.Now(), nil) if err != nil {
return "", err
// Add tail hex username }
code += hex.EncodeToString([]byte(u.LowerName)) return lookup + ":" + validator, nil
return code
} }
// GetUserFollowers returns range of user's followers. // GetUserFollowers returns range of user's followers.
@ -838,35 +839,50 @@ func countUsers(ctx context.Context, opts *CountUserFilter) int64 {
return count return count
} }
// GetVerifyUser get user by verify code // VerifyUserActiveCode verifies that the code is valid for the given purpose for this user.
func GetVerifyUser(ctx context.Context, code string) (user *User) { // If delete is specified, the token will be deleted.
if len(code) <= base.TimeLimitCodeLength { func VerifyUserAuthorizationToken(ctx context.Context, code string, purpose auth.AuthorizationPurpose, delete bool) (*User, error) {
return nil lookupKey, validator, found := strings.Cut(code, ":")
if !found {
return nil, nil
} }
// use tail hex username query user authToken, err := auth.FindAuthToken(ctx, lookupKey, purpose)
hexStr := code[base.TimeLimitCodeLength:] if err != nil {
if b, err := hex.DecodeString(hexStr); err == nil { if errors.Is(err, util.ErrNotExist) {
if user, err = GetUserByName(ctx, string(b)); user != nil { return nil, nil
return user
} }
log.Error("user.getVerifyUser: %v", err) return nil, err
} }
return nil if authToken.IsExpired() {
return nil, auth.DeleteAuthToken(ctx, authToken)
} }
// VerifyUserActiveCode verifies active code when active account rawValidator, err := hex.DecodeString(validator)
func VerifyUserActiveCode(ctx context.Context, code string) (user *User) { if err != nil {
if user = GetVerifyUser(ctx, code); user != nil { return nil, err
// time limit code }
prefix := code[:base.TimeLimitCodeLength]
data := fmt.Sprintf("%d%s%s%s%s", user.ID, user.Email, user.LowerName, user.Passwd, user.Rands) if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 {
if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { return nil, errors.New("validator doesn't match")
return user }
u, err := GetUserByID(ctx, authToken.UID)
if err != nil {
if IsErrUserNotExist(err) {
return nil, nil
}
return nil, err
}
if delete {
if err := auth.DeleteAuthToken(ctx, authToken); err != nil {
return nil, err
} }
} }
return nil
return u, nil
} }
// ValidateUser check if user is valid to insert / update into database // ValidateUser check if user is valid to insert / update into database

View file

@ -7,6 +7,7 @@ package user_test
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
@ -21,7 +22,9 @@ import (
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/validation"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -700,3 +703,66 @@ func TestDisabledUserFeatures(t *testing.T) {
assert.True(t, user_model.IsFeatureDisabledWithLoginType(user, f)) assert.True(t, user_model.IsFeatureDisabledWithLoginType(user, f))
} }
} }
func TestGenerateEmailAuthorizationCode(t *testing.T) {
defer test.MockVariableValue(&setting.Service.ActiveCodeLives, 2)()
require.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
code, err := user.GenerateEmailAuthorizationCode(db.DefaultContext, auth.UserActivation)
require.NoError(t, err)
lookupKey, validator, ok := strings.Cut(code, ":")
assert.True(t, ok)
rawValidator, err := hex.DecodeString(validator)
require.NoError(t, err)
authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation)
require.NoError(t, err)
assert.False(t, authToken.IsExpired())
assert.EqualValues(t, authToken.HashedValidator, auth.HashValidator(rawValidator))
authToken.Expiry = authToken.Expiry.Add(-int64(setting.Service.ActiveCodeLives) * 60)
assert.True(t, authToken.IsExpired())
}
func TestVerifyUserAuthorizationToken(t *testing.T) {
defer test.MockVariableValue(&setting.Service.ActiveCodeLives, 2)()
require.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
code, err := user.GenerateEmailAuthorizationCode(db.DefaultContext, auth.UserActivation)
require.NoError(t, err)
lookupKey, _, ok := strings.Cut(code, ":")
assert.True(t, ok)
t.Run("Wrong purpose", func(t *testing.T) {
u, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.PasswordReset, false)
require.NoError(t, err)
assert.Nil(t, u)
})
t.Run("No delete", func(t *testing.T) {
u, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation, false)
require.NoError(t, err)
assert.EqualValues(t, user.ID, u.ID)
authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation)
require.NoError(t, err)
assert.NotNil(t, authToken)
})
t.Run("Delete", func(t *testing.T) {
u, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation, true)
require.NoError(t, err)
assert.EqualValues(t, user.ID, u.ID)
authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation)
require.ErrorIs(t, err, util.ErrNotExist)
assert.Nil(t, authToken)
})
}

View file

@ -4,26 +4,20 @@
package base package base
import ( import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256" "crypto/sha256"
"crypto/subtle"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"hash"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"time"
"unicode/utf8" "unicode/utf8"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
) )
@ -54,66 +48,6 @@ func BasicAuthDecode(encoded string) (string, string, error) {
return "", "", errors.New("invalid basic authentication") return "", "", errors.New("invalid basic authentication")
} }
// VerifyTimeLimitCode verify time limit code
func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) bool {
if len(code) <= 18 {
return false
}
startTimeStr := code[:12]
aliveTimeStr := code[12:18]
aliveTime, _ := strconv.Atoi(aliveTimeStr) // no need to check err, if anything wrong, the following code check will fail soon
// check code
retCode := CreateTimeLimitCode(data, aliveTime, startTimeStr, nil)
if subtle.ConstantTimeCompare([]byte(retCode), []byte(code)) != 1 {
retCode = CreateTimeLimitCode(data, aliveTime, startTimeStr, sha1.New()) // TODO: this is only for the support of legacy codes, remove this in/after 1.23
if subtle.ConstantTimeCompare([]byte(retCode), []byte(code)) != 1 {
return false
}
}
// check time is expired or not: startTime <= now && now < startTime + minutes
startTime, _ := time.ParseInLocation("200601021504", startTimeStr, time.Local)
return (startTime.Before(now) || startTime.Equal(now)) && now.Before(startTime.Add(time.Minute*time.Duration(minutes)))
}
// TimeLimitCodeLength default value for time limit code
const TimeLimitCodeLength = 12 + 6 + 40
// CreateTimeLimitCode create a time-limited code.
// Format: 12 length date time string + 6 minutes string (not used) + 40 hash string, some other code depends on this fixed length
// If h is nil, then use the default hmac hash.
func CreateTimeLimitCode[T time.Time | string](data string, minutes int, startTimeGeneric T, h hash.Hash) string {
const format = "200601021504"
var start time.Time
var startTimeAny any = startTimeGeneric
if t, ok := startTimeAny.(time.Time); ok {
start = t
} else {
var err error
start, err = time.ParseInLocation(format, startTimeAny.(string), time.Local)
if err != nil {
return "" // return an invalid code because the "parse" failed
}
}
startStr := start.Format(format)
end := start.Add(time.Minute * time.Duration(minutes))
if h == nil {
h = hmac.New(sha1.New, setting.GetGeneralTokenSigningSecret())
}
_, _ = fmt.Fprintf(h, "%s%s%s%s%d", data, hex.EncodeToString(setting.GetGeneralTokenSigningSecret()), startStr, end.Format(format), minutes)
encoded := hex.EncodeToString(h.Sum(nil))
code := fmt.Sprintf("%s%06d%s", startStr, minutes, encoded)
if len(code) != TimeLimitCodeLength {
panic("there is a hard requirement for the length of time-limited code") // it shouldn't happen
}
return code
}
// FileSize calculates the file size and generate user-friendly string. // FileSize calculates the file size and generate user-friendly string.
func FileSize(s int64) string { func FileSize(s int64) string {
return humanize.IBytes(uint64(s)) return humanize.IBytes(uint64(s))

View file

@ -4,13 +4,7 @@
package base package base
import ( import (
"crypto/sha1"
"fmt"
"testing" "testing"
"time"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -46,57 +40,6 @@ func TestBasicAuthDecode(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
func TestVerifyTimeLimitCode(t *testing.T) {
defer test.MockVariableValue(&setting.InstallLock, true)()
initGeneralSecret := func(secret string) {
setting.InstallLock = true
setting.CfgProvider, _ = setting.NewConfigProviderFromData(fmt.Sprintf(`
[oauth2]
JWT_SECRET = %s
`, secret))
setting.LoadCommonSettings()
}
initGeneralSecret("KZb_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko")
now := time.Now()
t.Run("TestGenericParameter", func(t *testing.T) {
time2000 := time.Date(2000, 1, 2, 3, 4, 5, 0, time.Local)
assert.Equal(t, "2000010203040000026fa5221b2731b7cf80b1b506f5e39e38c115fee5", CreateTimeLimitCode("test-sha1", 2, time2000, sha1.New()))
assert.Equal(t, "2000010203040000026fa5221b2731b7cf80b1b506f5e39e38c115fee5", CreateTimeLimitCode("test-sha1", 2, "200001020304", sha1.New()))
assert.Equal(t, "2000010203040000024842227a2f87041ff82025199c0187410a9297bf", CreateTimeLimitCode("test-hmac", 2, time2000, nil))
assert.Equal(t, "2000010203040000024842227a2f87041ff82025199c0187410a9297bf", CreateTimeLimitCode("test-hmac", 2, "200001020304", nil))
})
t.Run("TestInvalidCode", func(t *testing.T) {
assert.False(t, VerifyTimeLimitCode(now, "data", 2, ""))
assert.False(t, VerifyTimeLimitCode(now, "data", 2, "invalid code"))
})
t.Run("TestCreateAndVerify", func(t *testing.T) {
code := CreateTimeLimitCode("data", 2, now, nil)
assert.False(t, VerifyTimeLimitCode(now.Add(-time.Minute), "data", 2, code)) // not started yet
assert.True(t, VerifyTimeLimitCode(now, "data", 2, code))
assert.True(t, VerifyTimeLimitCode(now.Add(time.Minute), "data", 2, code))
assert.False(t, VerifyTimeLimitCode(now.Add(time.Minute), "DATA", 2, code)) // invalid data
assert.False(t, VerifyTimeLimitCode(now.Add(2*time.Minute), "data", 2, code)) // expired
})
t.Run("TestDifferentSecret", func(t *testing.T) {
// use another secret to ensure the code is invalid for different secret
verifyDataCode := func(c string) bool {
return VerifyTimeLimitCode(now, "data", 2, c)
}
code1 := CreateTimeLimitCode("data", 2, now, sha1.New())
code2 := CreateTimeLimitCode("data", 2, now, nil)
assert.True(t, verifyDataCode(code1))
assert.True(t, verifyDataCode(code2))
initGeneralSecret("000_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko")
assert.False(t, verifyDataCode(code1))
assert.False(t, verifyDataCode(code2))
})
}
func TestFileSize(t *testing.T) { func TestFileSize(t *testing.T) {
var size int64 = 512 var size int64 = 512
assert.Equal(t, "512 B", FileSize(size)) assert.Equal(t, "512 B", FileSize(size))

View file

@ -97,7 +97,7 @@ func createDefaultPolicy() *bluemonday.Policy {
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(ref-issue( ref-external-issue)?|mention)$`)).OnElements("a") policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(ref-issue( ref-external-issue)?|mention)$`)).OnElements("a")
// Allow classes for task lists // Allow classes for task lists
policy.AllowAttrs("class").Matching(regexp.MustCompile(`task-list-item`)).OnElements("li") policy.AllowAttrs("class").Matching(regexp.MustCompile(`^task-list-item$`)).OnElements("li")
// Allow classes for org mode list item status. // Allow classes for org mode list item status.
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(unchecked|checked|indeterminate)$`)).OnElements("li") policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(unchecked|checked|indeterminate)$`)).OnElements("li")
@ -106,7 +106,7 @@ func createDefaultPolicy() *bluemonday.Policy {
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^icon(\s+[\p{L}\p{N}_-]+)+$`)).OnElements("i") policy.AllowAttrs("class").Matching(regexp.MustCompile(`^icon(\s+[\p{L}\p{N}_-]+)+$`)).OnElements("i")
// Allow classes for emojis // Allow classes for emojis
policy.AllowAttrs("class").Matching(regexp.MustCompile(`emoji`)).OnElements("img") policy.AllowAttrs("class").Matching(regexp.MustCompile(`^emoji$`)).OnElements("img")
// Allow icons, emojis, chroma syntax and keyword markup on span // Allow icons, emojis, chroma syntax and keyword markup on span
policy.AllowAttrs("class").Matching(regexp.MustCompile(`^((icon(\s+[\p{L}\p{N}_-]+)+)|(emoji)|(language-math display)|(language-math inline))$|^([a-z][a-z0-9]{0,2})$|^` + keywordClass + `$`)).OnElements("span") policy.AllowAttrs("class").Matching(regexp.MustCompile(`^((icon(\s+[\p{L}\p{N}_-]+)+)|(emoji)|(language-math display)|(language-math inline))$|^([a-z][a-z0-9]{0,2})$|^` + keywordClass + `$`)).OnElements("span")
@ -123,13 +123,13 @@ func createDefaultPolicy() *bluemonday.Policy {
policy.AllowAttrs("class").Matching(regexp.MustCompile("^header$")).OnElements("div") policy.AllowAttrs("class").Matching(regexp.MustCompile("^header$")).OnElements("div")
policy.AllowAttrs("data-line-number").Matching(regexp.MustCompile("^[0-9]+$")).OnElements("span") policy.AllowAttrs("data-line-number").Matching(regexp.MustCompile("^[0-9]+$")).OnElements("span")
policy.AllowAttrs("class").Matching(regexp.MustCompile("^text small grey$")).OnElements("span") policy.AllowAttrs("class").Matching(regexp.MustCompile("^text small grey$")).OnElements("span")
policy.AllowAttrs("class").Matching(regexp.MustCompile("^file-preview*")).OnElements("table") policy.AllowAttrs("class").Matching(regexp.MustCompile("^file-preview$")).OnElements("table")
policy.AllowAttrs("class").Matching(regexp.MustCompile("^lines-escape$")).OnElements("td") policy.AllowAttrs("class").Matching(regexp.MustCompile("^lines-escape$")).OnElements("td")
policy.AllowAttrs("class").Matching(regexp.MustCompile("^toggle-escape-button btn interact-bg$")).OnElements("button") policy.AllowAttrs("class").Matching(regexp.MustCompile("^toggle-escape-button btn interact-bg$")).OnElements("button")
policy.AllowAttrs("title").OnElements("button") policy.AllowAttrs("title").OnElements("button")
policy.AllowAttrs("class").Matching(regexp.MustCompile("^ambiguous-code-point$")).OnElements("span") policy.AllowAttrs("class").Matching(regexp.MustCompile("^ambiguous-code-point$")).OnElements("span")
policy.AllowAttrs("data-tooltip-content").OnElements("span") policy.AllowAttrs("data-tooltip-content").OnElements("span")
policy.AllowAttrs("class").Matching(regexp.MustCompile("muted|(text black)")).OnElements("a") policy.AllowAttrs("class").Matching(regexp.MustCompile("^muted|(text black)$")).OnElements("a")
policy.AllowAttrs("class").Matching(regexp.MustCompile("^ui warning message tw-text-left$")).OnElements("div") policy.AllowAttrs("class").Matching(regexp.MustCompile("^ui warning message tw-text-left$")).OnElements("div")
// Allow generally safe attributes // Allow generally safe attributes

30
package-lock.json generated
View file

@ -43,7 +43,7 @@
"pretty-ms": "9.0.0", "pretty-ms": "9.0.0",
"sortablejs": "1.15.3", "sortablejs": "1.15.3",
"swagger-ui-dist": "5.17.14", "swagger-ui-dist": "5.17.14",
"tailwindcss": "3.4.13", "tailwindcss": "3.4.15",
"throttle-debounce": "5.0.0", "throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",
@ -15389,33 +15389,33 @@
} }
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.13", "version": "3.4.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.15.tgz",
"integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", "integrity": "sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
"chokidar": "^3.5.3", "chokidar": "^3.6.0",
"didyoumean": "^1.2.2", "didyoumean": "^1.2.2",
"dlv": "^1.1.3", "dlv": "^1.1.3",
"fast-glob": "^3.3.0", "fast-glob": "^3.3.2",
"glob-parent": "^6.0.2", "glob-parent": "^6.0.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
"jiti": "^1.21.0", "jiti": "^1.21.6",
"lilconfig": "^2.1.0", "lilconfig": "^2.1.0",
"micromatch": "^4.0.5", "micromatch": "^4.0.8",
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"picocolors": "^1.0.0", "picocolors": "^1.1.1",
"postcss": "^8.4.23", "postcss": "^8.4.47",
"postcss-import": "^15.1.0", "postcss-import": "^15.1.0",
"postcss-js": "^4.0.1", "postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.1", "postcss-load-config": "^4.0.2",
"postcss-nested": "^6.0.1", "postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.0.11", "postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.2", "resolve": "^1.22.8",
"sucrase": "^3.32.0" "sucrase": "^3.35.0"
}, },
"bin": { "bin": {
"tailwind": "lib/cli.js", "tailwind": "lib/cli.js",

View file

@ -42,7 +42,7 @@
"pretty-ms": "9.0.0", "pretty-ms": "9.0.0",
"sortablejs": "1.15.3", "sortablejs": "1.15.3",
"swagger-ui-dist": "5.17.14", "swagger-ui-dist": "5.17.14",
"tailwindcss": "3.4.13", "tailwindcss": "3.4.15",
"throttle-debounce": "5.0.0", "throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",

8
release-notes/5974.md Normal file
View file

@ -0,0 +1,8 @@
fix: [commit](https://codeberg.org/forgejo/forgejo/commit/1ce33aa38d1d258d14523ff2c7c2dbf339f22b74) it was possible to use a token sent via email for secondary email validation to reset the password instead. In other words, a token sent for a given action (registration, password reset or secondary email validation) could be used to perform a different action. It is no longer possible to use a token for an action that is different from its original purpose.
fix: [commit](https://codeberg.org/forgejo/forgejo/commit/061abe60045212acf8c3f5c49b5cc758b4cbcde9) a fork of a public repository would show in the list of forks, even if its owner was not a public user or organization. Such a fork is now hidden from the list of forks of the public repository.
fix: [commit](https://codeberg.org/forgejo/forgejo/commit/3e3ef76808100cb1c853378733d0f6a910324ac6) the members of an organization team with read access to a repository (e.g. to read issues) but no read access to the code could read the RSS or atom feeds which include the commit activity. Reading the RSS or atom feeds is now denied unless the team has read permissions on the code.
fix: [commit](https://codeberg.org/forgejo/forgejo/commit/9508aa7713632ed40124a933d91d5766cf2369c2) the tokens used when [replying by email to issues or pull requests](https://forgejo.org/docs/v9.0/user/incoming/) were weaker than the [rfc2104 recommendations](https://datatracker.ietf.org/doc/html/rfc2104#section-5). The tokens are now truncated to 128 bits instead of 80 bits. It is no longer possible to reply to emails sent before the upgrade because the weaker tokens are invalid.
fix: [commit](https://codeberg.org/forgejo/forgejo/commit/786dfc7fb81ee76d4292ca5fcb33e6ea7bdccc29) a registered user could modify the update frequency of any push mirror (e.g. every 4h instead of every 8h). They are now only able to do that if they have administrative permissions on the repository.
fix: [commit](https://codeberg.org/forgejo/forgejo/commit/e6bbecb02d47730d3cc630d419fe27ef2fb5cb39) it was possible to use basic authorization (i.e. user:password) for requests to the API even when security keys were enrolled for a user. It is no longer possible, an application token must be used instead.
fix: [commit](https://codeberg.org/forgejo/forgejo/commit/7067cc7da4f144cc8a2fd2ae6e5307e0465ace7f) some markup sanitation rules were not as strong as they could be (e.g. allowing `emoji somethingelse` as well as `emoji`). The rules are now stricter and do not allow for such cases.
fix: [commit](https://codeberg.org/forgejo/forgejo/commit/b70196653f9d7d3b9d4e72d114e5cc6f472988c4) when Forgejo is configured to enable instance wide search (e.g. with [bleve](https://blevesearch.com/)), results found in the repositories of private or limited users were displayed to anonymous visitors. The results found in private or limited organizations were not displayed. The search results found in the repositories of private or limited user are no longer displayed to anonymous visitors.

1
release-notes/5988.md Normal file
View file

@ -0,0 +1 @@
fix: [commit](https://codeberg.org/forgejo/forgejo/commit/fc26becba4b08877a726f2e7e453992310245fe5) when a tag was removed and a release existed for that tag, it would be broken. The release is no longer broken the tag can be added again.

View file

@ -56,7 +56,7 @@ func ListForks(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx)) forks, total, err := repo_model.GetForks(ctx, ctx.Repo.Repository, ctx.Doer, utils.GetListOptions(ctx))
if err != nil { if err != nil {
ctx.Error(http.StatusInternalServerError, "GetForks", err) ctx.Error(http.StatusInternalServerError, "GetForks", err)
return return
@ -71,7 +71,7 @@ func ListForks(ctx *context.APIContext) {
apiForks[i] = convert.ToRepo(ctx, fork, permission) apiForks[i] = convert.ToRepo(ctx, fork, permission)
} }
ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumForks)) ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, apiForks) ctx.JSON(http.StatusOK, apiForks)
} }

View file

@ -5,8 +5,6 @@
package auth package auth
import ( import (
"crypto/subtle"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -63,38 +61,11 @@ func autoSignIn(ctx *context.Context) (bool, error) {
return false, nil return false, nil
} }
lookupKey, validator, found := strings.Cut(authCookie, ":") u, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorization, false)
if !found {
return false, nil
}
authToken, err := auth.FindAuthToken(ctx, lookupKey)
if err != nil { if err != nil {
if errors.Is(err, util.ErrNotExist) { return false, fmt.Errorf("VerifyUserAuthorizationToken: %w", err)
return false, nil
}
return false, err
}
if authToken.IsExpired() {
err = auth.DeleteAuthToken(ctx, authToken)
return false, err
}
rawValidator, err := hex.DecodeString(validator)
if err != nil {
return false, err
}
if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 {
return false, nil
}
u, err := user_model.GetUserByID(ctx, authToken.UID)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
return false, fmt.Errorf("GetUserByID: %w", err)
} }
if u == nil {
return false, nil return false, nil
} }
@ -633,7 +604,10 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
return false return false
} }
mailer.SendActivateAccountMail(ctx.Locale, u) if err := mailer.SendActivateAccountMail(ctx, u); err != nil {
ctx.ServerError("SendActivateAccountMail", err)
return false
}
ctx.Data["IsSendRegisterMail"] = true ctx.Data["IsSendRegisterMail"] = true
ctx.Data["Email"] = u.Email ctx.Data["Email"] = u.Email
@ -674,7 +648,10 @@ func Activate(ctx *context.Context) {
ctx.Data["ResendLimited"] = true ctx.Data["ResendLimited"] = true
} else { } else {
ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer) if err := mailer.SendActivateAccountMail(ctx, ctx.Doer); err != nil {
ctx.ServerError("SendActivateAccountMail", err)
return
}
if err := ctx.Cache.Put(cacheKey+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { if err := ctx.Cache.Put(cacheKey+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
log.Error("Set cache(MailResendLimit) fail: %v", err) log.Error("Set cache(MailResendLimit) fail: %v", err)
@ -687,7 +664,12 @@ func Activate(ctx *context.Context) {
return return
} }
user := user_model.VerifyUserActiveCode(ctx, code) user, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation, false)
if err != nil {
ctx.ServerError("VerifyUserAuthorizationToken", err)
return
}
// if code is wrong // if code is wrong
if user == nil { if user == nil {
ctx.Data["IsCodeInvalid"] = true ctx.Data["IsCodeInvalid"] = true
@ -751,7 +733,12 @@ func ActivatePost(ctx *context.Context) {
return return
} }
user := user_model.VerifyUserActiveCode(ctx, code) user, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation, true)
if err != nil {
ctx.ServerError("VerifyUserAuthorizationToken", err)
return
}
// if code is wrong // if code is wrong
if user == nil { if user == nil {
ctx.Data["IsCodeInvalid"] = true ctx.Data["IsCodeInvalid"] = true
@ -835,8 +822,22 @@ func ActivateEmail(ctx *context.Context) {
code := ctx.FormString("code") code := ctx.FormString("code")
emailStr := ctx.FormString("email") emailStr := ctx.FormString("email")
// Verify code. u, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.EmailActivation(emailStr), true)
if email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); email != nil { if err != nil {
ctx.ServerError("VerifyUserAuthorizationToken", err)
return
}
if u == nil {
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
return
}
email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID)
if err != nil {
ctx.ServerError("GetEmailAddressOfUser", err)
return
}
if err := user_model.ActivateEmail(ctx, email); err != nil { if err := user_model.ActivateEmail(ctx, email); err != nil {
ctx.ServerError("ActivateEmail", err) ctx.ServerError("ActivateEmail", err)
return return
@ -845,13 +846,8 @@ func ActivateEmail(ctx *context.Context) {
log.Trace("Email activated: %s", email.Email) log.Trace("Email activated: %s", email.Email)
ctx.Flash.Success(ctx.Tr("settings.add_email_success")) ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
if u, err := user_model.GetUserByID(ctx, email.UID); err != nil {
log.Warn("GetUserByID: %d", email.UID)
} else {
// Allow user to validate more emails // Allow user to validate more emails
_ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName) _ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName)
}
}
// FIXME: e-mail verification does not require the user to be logged in, // FIXME: e-mail verification does not require the user to be logged in,
// so this could be redirecting to the login page. // so this could be redirecting to the login page.

View file

@ -86,7 +86,10 @@ func ForgotPasswdPost(ctx *context.Context) {
return return
} }
mailer.SendResetPasswordMail(u) if err := mailer.SendResetPasswordMail(ctx, u); err != nil {
ctx.ServerError("SendResetPasswordMail", err)
return
}
if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
log.Error("Set cache(MailResendLimit) fail: %v", err) log.Error("Set cache(MailResendLimit) fail: %v", err)
@ -97,7 +100,7 @@ func ForgotPasswdPost(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplForgotPassword) ctx.HTML(http.StatusOK, tplForgotPassword)
} }
func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFactor) { func commonResetPassword(ctx *context.Context, shouldDeleteToken bool) (*user_model.User, *auth.TwoFactor) {
code := ctx.FormString("code") code := ctx.FormString("code")
ctx.Data["Title"] = ctx.Tr("auth.reset_password") ctx.Data["Title"] = ctx.Tr("auth.reset_password")
@ -113,7 +116,12 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto
} }
// Fail early, don't frustrate the user // Fail early, don't frustrate the user
u := user_model.VerifyUserActiveCode(ctx, code) u, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.PasswordReset, shouldDeleteToken)
if err != nil {
ctx.ServerError("VerifyUserAuthorizationToken", err)
return nil, nil
}
if u == nil { if u == nil {
ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true)
return nil, nil return nil, nil
@ -145,7 +153,7 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto
func ResetPasswd(ctx *context.Context) { func ResetPasswd(ctx *context.Context) {
ctx.Data["IsResetForm"] = true ctx.Data["IsResetForm"] = true
commonResetPassword(ctx) commonResetPassword(ctx, false)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -155,7 +163,7 @@ func ResetPasswd(ctx *context.Context) {
// ResetPasswdPost response from account recovery request // ResetPasswdPost response from account recovery request
func ResetPasswdPost(ctx *context.Context) { func ResetPasswdPost(ctx *context.Context) {
u, twofa := commonResetPassword(ctx) u, twofa := commonResetPassword(ctx, true)
if ctx.Written() { if ctx.Written() {
return return
} }

View file

@ -566,21 +566,19 @@ func SettingsPost(ctx *context.Context) {
// as an error on the UI for this action // as an error on the UI for this action
ctx.Data["Err_RepoName"] = nil ctx.Data["Err_RepoName"] = nil
m, err := selectPushMirrorByForm(ctx, form, repo)
if err != nil {
ctx.NotFound("", nil)
return
}
interval, err := time.ParseDuration(form.PushMirrorInterval) interval, err := time.ParseDuration(form.PushMirrorInterval)
if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) {
ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &forms.RepoSettingForm{}) ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &forms.RepoSettingForm{})
return return
} }
id, err := strconv.ParseInt(form.PushMirrorID, 10, 64) m.Interval = interval
if err != nil {
ctx.ServerError("UpdatePushMirrorIntervalPushMirrorID", err)
return
}
m := &repo_model.PushMirror{
ID: id,
Interval: interval,
}
if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil { if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil {
ctx.ServerError("UpdatePushMirrorInterval", err) ctx.ServerError("UpdatePushMirrorInterval", err)
return return

View file

@ -1232,11 +1232,8 @@ func Forks(ctx *context.Context) {
page = 1 page = 1
} }
pager := context.NewPagination(ctx.Repo.Repository.NumForks, setting.MaxForksPerPage, page, 5) forks, total, err := repo_model.GetForks(ctx, ctx.Repo.Repository, ctx.Doer, db.ListOptions{
ctx.Data["Page"] = pager Page: page,
forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, db.ListOptions{
Page: pager.Paginater.Current(),
PageSize: setting.MaxForksPerPage, PageSize: setting.MaxForksPerPage,
}) })
if err != nil { if err != nil {
@ -1244,6 +1241,9 @@ func Forks(ctx *context.Context) {
return return
} }
pager := context.NewPagination(int(total), setting.MaxForksPerPage, page, 5)
ctx.Data["Page"] = pager
for _, fork := range forks { for _, fork := range forks {
if err = fork.LoadOwner(ctx); err != nil { if err = fork.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err) ctx.ServerError("LoadOwner", err)

View file

@ -155,9 +155,15 @@ func EmailPost(ctx *context.Context) {
return return
} }
// Only fired when the primary email is inactive (Wrong state) // Only fired when the primary email is inactive (Wrong state)
mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer) if err := mailer.SendActivateAccountMail(ctx, ctx.Doer); err != nil {
ctx.ServerError("SendActivateAccountMail", err)
return
}
} else { } else {
mailer.SendActivateEmailMail(ctx.Doer, email.Email) if err := mailer.SendActivateEmailMail(ctx, ctx.Doer, email.Email); err != nil {
ctx.ServerError("SendActivateEmailMail", err)
return
}
} }
address = email.Email address = email.Email
@ -218,7 +224,10 @@ func EmailPost(ctx *context.Context) {
// Send confirmation email // Send confirmation email
if setting.Service.RegisterEmailConfirm { if setting.Service.RegisterEmailConfirm {
mailer.SendActivateEmailMail(ctx.Doer, form.Email) if err := mailer.SendActivateEmailMail(ctx, ctx.Doer, form.Email); err != nil {
ctx.ServerError("SendActivateEmailMail", err)
return
}
if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
log.Error("Set cache(MailResendLimit) fail: %v", err) log.Error("Set cache(MailResendLimit) fail: %v", err)
} }

View file

@ -1562,8 +1562,10 @@ func registerRoutes(m *web.Route) {
m.Get("/cherry-pick/{sha:([a-f0-9]{4,64})$}", repo.SetEditorconfigIfExists, repo.CherryPick) m.Get("/cherry-pick/{sha:([a-f0-9]{4,64})$}", repo.SetEditorconfigIfExists, repo.CherryPick)
}, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader) }, repo.MustBeNotEmpty, context.RepoRef(), reqRepoCodeReader)
m.Get("/rss/branch/*", repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefBranch), feedEnabled, feed.RenderBranchFeed("rss")) m.Group("", func() {
m.Get("/atom/branch/*", repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefBranch), feedEnabled, feed.RenderBranchFeed("atom")) m.Get("/rss/branch/*", feed.RenderBranchFeed("rss"))
m.Get("/atom/branch/*", feed.RenderBranchFeed("atom"))
}, repo.MustBeNotEmpty, context.RepoRefByType(context.RepoRefBranch), reqRepoCodeReader, feedEnabled)
m.Group("/src", func() { m.Group("/src", func() {
m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.Home) m.Get("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.Home)

View file

@ -5,6 +5,7 @@
package auth package auth
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
@ -132,6 +133,16 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore
return nil, err return nil, err
} }
hashWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(req.Context(), u.ID)
if err != nil {
log.Error("HasWebAuthnRegistrationsByUID: %v", err)
return nil, err
}
if hashWebAuthn {
return nil, errors.New("Basic authorization is not allowed while having security keys enrolled")
}
if skipper, ok := source.Cfg.(LocalTwoFASkipper); !ok || !skipper.IsSkipLocalTwoFA() { if skipper, ok := source.Cfg.(LocalTwoFASkipper); !ok || !skipper.IsSkipLocalTwoFA() {
if err := validateTOTP(req, u); err != nil { if err := validateTOTP(req, u); err != nil {
return nil, err return nil, err

View file

@ -347,12 +347,30 @@ func loadOrCreateAsymmetricKey() (any, error) {
key, err := func() (any, error) { key, err := func() (any, error) {
switch { switch {
case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"): case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"):
return rsa.GenerateKey(rand.Reader, 4096) var bits int
switch setting.OAuth2.JWTSigningAlgorithm {
case "RS256":
bits = 2048
case "RS384":
bits = 3072
case "RS512":
bits = 4096
}
return rsa.GenerateKey(rand.Reader, bits)
case setting.OAuth2.JWTSigningAlgorithm == "EdDSA": case setting.OAuth2.JWTSigningAlgorithm == "EdDSA":
_, pk, err := ed25519.GenerateKey(rand.Reader) _, pk, err := ed25519.GenerateKey(rand.Reader)
return pk, err return pk, err
default: default:
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) var curve elliptic.Curve
switch setting.OAuth2.JWTSigningAlgorithm {
case "ES256":
curve = elliptic.P256()
case "ES384":
curve = elliptic.P384()
case "ES512":
curve = elliptic.P521()
}
return ecdsa.GenerateKey(curve, rand.Reader)
} }
}() }()
if err != nil { if err != nil {

View file

@ -0,0 +1,116 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package oauth2
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"testing"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadOrCreateAsymmetricKey(t *testing.T) {
loadKey := func(t *testing.T) any {
t.Helper()
loadOrCreateAsymmetricKey()
fileContent, err := os.ReadFile(setting.OAuth2.JWTSigningPrivateKeyFile)
require.NoError(t, err)
block, _ := pem.Decode(fileContent)
assert.NotNil(t, block)
assert.EqualValues(t, "PRIVATE KEY", block.Type)
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
require.NoError(t, err)
return parsedKey
}
t.Run("RSA-2048", func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-rsa-2048.priv"))()
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "RS256")()
parsedKey := loadKey(t)
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
assert.EqualValues(t, 2048, rsaPrivateKey.N.BitLen())
t.Run("Load key with differ specified algorithm", func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "EdDSA")()
parsedKey := loadKey(t)
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
assert.EqualValues(t, 2048, rsaPrivateKey.N.BitLen())
})
})
t.Run("RSA-3072", func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-rsa-3072.priv"))()
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "RS384")()
parsedKey := loadKey(t)
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
assert.EqualValues(t, 3072, rsaPrivateKey.N.BitLen())
})
t.Run("RSA-4096", func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-rsa-4096.priv"))()
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "RS512")()
parsedKey := loadKey(t)
rsaPrivateKey := parsedKey.(*rsa.PrivateKey)
assert.EqualValues(t, 4096, rsaPrivateKey.N.BitLen())
})
t.Run("ECDSA-256", func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-ecdsa-256.priv"))()
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "ES256")()
parsedKey := loadKey(t)
ecdsaPrivateKey := parsedKey.(*ecdsa.PrivateKey)
assert.EqualValues(t, 256, ecdsaPrivateKey.Params().BitSize)
})
t.Run("ECDSA-384", func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-ecdsa-384.priv"))()
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "ES384")()
parsedKey := loadKey(t)
ecdsaPrivateKey := parsedKey.(*ecdsa.PrivateKey)
assert.EqualValues(t, 384, ecdsaPrivateKey.Params().BitSize)
})
t.Run("ECDSA-512", func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-ecdsa-512.priv"))()
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "ES512")()
parsedKey := loadKey(t)
ecdsaPrivateKey := parsedKey.(*ecdsa.PrivateKey)
assert.EqualValues(t, 521, ecdsaPrivateKey.Params().BitSize)
})
t.Run("EdDSA", func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.JWTSigningPrivateKeyFile, filepath.Join(t.TempDir(), "jwt-eddsa.priv"))()
defer test.MockVariableValue(&setting.OAuth2.JWTSigningAlgorithm, "EdDSA")()
parsedKey := loadKey(t)
assert.NotNil(t, parsedKey.(ed25519.PrivateKey))
})
}

View file

@ -47,7 +47,7 @@ func (ctx *Context) GetSiteCookie(name string) string {
// SetLTACookie will generate a LTA token and add it as an cookie. // SetLTACookie will generate a LTA token and add it as an cookie.
func (ctx *Context) SetLTACookie(u *user_model.User) error { func (ctx *Context) SetLTACookie(u *user_model.User) error {
days := 86400 * setting.LogInRememberDays days := 86400 * setting.LogInRememberDays
lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days))) lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days)), auth_model.LongTermAuthorization)
if err != nil { if err != nil {
return err return err
} }

View file

@ -243,6 +243,9 @@ func checkDBConsistency(ctx context.Context, logger log.Logger, autofix bool) er
// find archive download count without existing release // find archive download count without existing release
genericOrphanCheck("Archive download count without existing Release", genericOrphanCheck("Archive download count without existing Release",
"repo_archive_download_count", "release", "repo_archive_download_count.release_id=release.id"), "repo_archive_download_count", "release", "repo_archive_download_count.release_id=release.id"),
// find authorization tokens without existing user
genericOrphanCheck("Authorization token without existing User",
"forgejo_auth_token", "user", "forgejo_auth_token.uid=user.id"),
) )
for _, c := range consistencyChecks { for _, c := range consistencyChecks {

View file

@ -70,7 +70,7 @@ func SendTestMail(email string) error {
} }
// sendUserMail sends a mail to the user // sendUserMail sends a mail to the user
func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, subject, info string) { func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, subject, info string) error {
locale := translation.NewLocale(language) locale := translation.NewLocale(language)
data := map[string]any{ data := map[string]any{
"locale": locale, "locale": locale,
@ -84,47 +84,66 @@ func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, s
var content bytes.Buffer var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil { if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
log.Error("Template: %v", err) return err
return
} }
msg := NewMessage(u.EmailTo(), subject, content.String()) msg := NewMessage(u.EmailTo(), subject, content.String())
msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info) msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
SendAsync(msg) SendAsync(msg)
return nil
} }
// SendActivateAccountMail sends an activation mail to the user (new user registration) // SendActivateAccountMail sends an activation mail to the user (new user registration)
func SendActivateAccountMail(locale translation.Locale, u *user_model.User) { func SendActivateAccountMail(ctx context.Context, u *user_model.User) error {
if setting.MailService == nil { if setting.MailService == nil {
// No mail service configured // No mail service configured
return return nil
} }
sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.activate_account"), "activate account")
locale := translation.NewLocale(u.Language)
code, err := u.GenerateEmailAuthorizationCode(ctx, auth_model.UserActivation)
if err != nil {
return err
}
return sendUserMail(locale.Language(), u, mailAuthActivate, code, locale.TrString("mail.activate_account"), "activate account")
} }
// SendResetPasswordMail sends a password reset mail to the user // SendResetPasswordMail sends a password reset mail to the user
func SendResetPasswordMail(u *user_model.User) { func SendResetPasswordMail(ctx context.Context, u *user_model.User) error {
if setting.MailService == nil { if setting.MailService == nil {
// No mail service configured // No mail service configured
return return nil
} }
locale := translation.NewLocale(u.Language) locale := translation.NewLocale(u.Language)
sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.reset_password"), "recover account") code, err := u.GenerateEmailAuthorizationCode(ctx, auth_model.PasswordReset)
if err != nil {
return err
}
return sendUserMail(u.Language, u, mailAuthResetPassword, code, locale.TrString("mail.reset_password"), "recover account")
} }
// SendActivateEmailMail sends confirmation email to confirm new email address // SendActivateEmailMail sends confirmation email to confirm new email address
func SendActivateEmailMail(u *user_model.User, email string) { func SendActivateEmailMail(ctx context.Context, u *user_model.User, email string) error {
if setting.MailService == nil { if setting.MailService == nil {
// No mail service configured // No mail service configured
return return nil
} }
locale := translation.NewLocale(u.Language) locale := translation.NewLocale(u.Language)
code, err := u.GenerateEmailAuthorizationCode(ctx, auth_model.EmailActivation(email))
if err != nil {
return err
}
data := map[string]any{ data := map[string]any{
"locale": locale, "locale": locale,
"DisplayName": u.DisplayName(), "DisplayName": u.DisplayName(),
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
"Code": u.GenerateEmailActivateCode(email), "Code": code,
"Email": email, "Email": email,
"Language": locale.Language(), "Language": locale.Language(),
} }
@ -132,14 +151,14 @@ func SendActivateEmailMail(u *user_model.User, email string) {
var content bytes.Buffer var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
log.Error("Template: %v", err) return err
return
} }
msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String()) msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String())
msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID) msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
SendAsync(msg) SendAsync(msg)
return nil
} }
// SendRegisterNotifyMail triggers a notify e-mail by admin created a account. // SendRegisterNotifyMail triggers a notify e-mail by admin created a account.

View file

@ -22,9 +22,16 @@ import (
// //
// The payload is verifiable by the generated HMAC using the user secret. It contains: // The payload is verifiable by the generated HMAC using the user secret. It contains:
// | Timestamp | Action/Handler Type | Action/Handler Data | // | Timestamp | Action/Handler Type | Action/Handler Data |
//
//
// Version changelog
//
// v1 -> v2:
// Use 128 instead of 80 bits of the HMAC-SHA256 output.
const ( const (
tokenVersion1 byte = 1 tokenVersion1 byte = 1
tokenVersion2 byte = 2
tokenLifetimeInYears int = 1 tokenLifetimeInYears int = 1
) )
@ -70,7 +77,7 @@ func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, er
return "", err return "", err
} }
return encodingWithoutPadding.EncodeToString(append([]byte{tokenVersion1}, packagedData...)), nil return encodingWithoutPadding.EncodeToString(append([]byte{tokenVersion2}, packagedData...)), nil
} }
// ExtractToken extracts the action/user tuple from the token and verifies the content // ExtractToken extracts the action/user tuple from the token and verifies the content
@ -84,7 +91,7 @@ func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.U
return UnknownHandlerType, nil, nil, &ErrToken{"no data"} return UnknownHandlerType, nil, nil, &ErrToken{"no data"}
} }
if data[0] != tokenVersion1 { if data[0] != tokenVersion2 {
return UnknownHandlerType, nil, nil, &ErrToken{fmt.Sprintf("unsupported token version: %v", data[0])} return UnknownHandlerType, nil, nil, &ErrToken{fmt.Sprintf("unsupported token version: %v", data[0])}
} }
@ -124,5 +131,8 @@ func generateHmac(secret, payload []byte) []byte {
mac.Write(payload) mac.Write(payload)
hmac := mac.Sum(nil) hmac := mac.Sum(nil)
return hmac[:10] // RFC2104 recommends not using less then 80 bits // RFC2104 section 5 recommends that if you do HMAC truncation, you should use
// the max(80, hash_len/2) of the leftmost bits.
// For SHA256 this works out to using 128 of the leftmost bits.
return hmac[:16]
} }

View file

@ -309,6 +309,7 @@ func pushUpdateAddTags(ctx context.Context, repo *repo_model.Repository, gitRepo
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID, RepoID: repo.ID,
TagNames: tags, TagNames: tags,
IncludeDrafts: true,
IncludeTags: true, IncludeTags: true,
}) })
if err != nil { if err != nil {
@ -394,14 +395,18 @@ func pushUpdateAddTags(ctx context.Context, repo *repo_model.Repository, gitRepo
} }
newReleases = append(newReleases, rel) newReleases = append(newReleases, rel)
} else { } else {
rel.Title = parts[0]
rel.Note = note
rel.Sha1 = commit.ID.String() rel.Sha1 = commit.ID.String()
rel.CreatedUnix = timeutil.TimeStamp(createdAt.Unix()) rel.CreatedUnix = timeutil.TimeStamp(createdAt.Unix())
rel.NumCommits = commitsCount rel.NumCommits = commitsCount
if rel.IsTag && author != nil { if rel.IsTag {
rel.Title = parts[0]
rel.Note = note
if author != nil {
rel.PublisherID = author.ID rel.PublisherID = author.ID
} }
} else {
rel.IsDraft = false
}
if err = repo_model.UpdateRelease(ctx, rel); err != nil { if err = repo_model.UpdateRelease(ctx, rel); err != nil {
return fmt.Errorf("Update: %w", err) return fmt.Errorf("Update: %w", err)
} }

View file

@ -96,6 +96,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
&user_model.BlockedUser{BlockID: u.ID}, &user_model.BlockedUser{BlockID: u.ID},
&user_model.BlockedUser{UserID: u.ID}, &user_model.BlockedUser{UserID: u.ID},
&actions_model.ActionRunnerToken{OwnerID: u.ID}, &actions_model.ActionRunnerToken{OwnerID: u.ID},
&auth_model.AuthorizationToken{UID: u.ID},
); err != nil { ); err != nil {
return fmt.Errorf("deleteBeans: %w", err) return fmt.Errorf("deleteBeans: %w", err)
} }

View file

@ -26,6 +26,7 @@
.ui.secondary.menu .dropdown.item > .menu { margin-top: 0; } .ui.secondary.menu .dropdown.item > .menu { margin-top: 0; }
</style> </style>
</noscript> </noscript>
{{template "shared/user/mention_highlight" .}}
{{template "base/head_opengraph" .}} {{template "base/head_opengraph" .}}
{{template "base/head_style" .}} {{template "base/head_style" .}}
{{template "custom/header" .}} {{template "custom/header" .}}

View file

@ -0,0 +1,14 @@
{{if .IsSigned}}
<style>
.comment,
.commit-summary,
.commit-body {
.mention[href="{{AppSubUrl}}/{{.SignedUser.Name}}" i] {
background-color: var(--color-primary-alpha-30);
color: var(--color-primary-dark-2);
border-radius: 5px;
padding: 1px 4px;
}
}
</style>
{{end}}

View file

@ -20,7 +20,6 @@ const workflow_trigger_notification_text = 'This workflow has a workflow_dispatc
test('workflow dispatch present', async ({browser}, workerInfo) => { test('workflow dispatch present', async ({browser}, workerInfo) => {
const context = await load_logged_in_context(browser, workerInfo, 'user2'); const context = await load_logged_in_context(browser, workerInfo, 'user2');
/** @type {import('@playwright/test').Page} */
const page = await context.newPage(); const page = await context.newPage();
await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
@ -40,7 +39,6 @@ test('workflow dispatch error: missing inputs', async ({browser}, workerInfo) =>
test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383'); test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
const context = await load_logged_in_context(browser, workerInfo, 'user2'); const context = await load_logged_in_context(browser, workerInfo, 'user2');
/** @type {import('@playwright/test').Page} */
const page = await context.newPage(); const page = await context.newPage();
await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
@ -62,14 +60,13 @@ test('workflow dispatch success', async ({browser}, workerInfo) => {
test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383'); test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
const context = await load_logged_in_context(browser, workerInfo, 'user2'); const context = await load_logged_in_context(browser, workerInfo, 'user2');
/** @type {import('@playwright/test').Page} */
const page = await context.newPage(); const page = await context.newPage();
await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
await page.locator('#workflow_dispatch_dropdown>button').click(); await page.locator('#workflow_dispatch_dropdown>button').click();
await page.type('input[name="inputs[string2]"]', 'abc'); await page.fill('input[name="inputs[string2]"]', 'abc');
await page.locator('#workflow-dispatch-submit').click(); await page.locator('#workflow-dispatch-submit').click();
await expect(page.getByText('Workflow run was successfully requested.')).toBeVisible(); await expect(page.getByText('Workflow run was successfully requested.')).toBeVisible();

View file

@ -5,7 +5,6 @@ package e2e
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -23,14 +22,31 @@ import (
// first entry represents filename // first entry represents filename
// the following entries define the full file content over time // the following entries define the full file content over time
type FileChanges [][]string type FileChanges struct {
Filename string
CommitMsg string
Versions []string
}
// put your Git repo declarations in here // put your Git repo declarations in here
// feel free to amend the helper function below or use the raw variant directly // feel free to amend the helper function below or use the raw variant directly
func DeclareGitRepos(t *testing.T) func() { func DeclareGitRepos(t *testing.T) func() {
cleanupFunctions := []func(){ cleanupFunctions := []func(){
newRepo(t, 2, "diff-test", FileChanges{ newRepo(t, 2, "diff-test", []FileChanges{{
{"testfile", "hello", "hallo", "hola", "native", "ubuntu-latest", "- runs-on: ubuntu-latest", "- runs-on: debian-latest"}, Filename: "testfile",
Versions: []string{"hello", "hallo", "hola", "native", "ubuntu-latest", "- runs-on: ubuntu-latest", "- runs-on: debian-latest"},
}}),
newRepo(t, 2, "mentions-highlighted", []FileChanges{
{
Filename: "history1.md",
Versions: []string{""},
CommitMsg: "A commit message which mentions @user2 in the title\nand has some additional text which mentions @user1",
},
{
Filename: "history2.md",
Versions: []string{""},
CommitMsg: "Another commit which mentions @user1 in the title\nand @user2 in the text",
},
}), }),
// add your repo declarations here // add your repo declarations here
} }
@ -42,7 +58,7 @@ func DeclareGitRepos(t *testing.T) func() {
} }
} }
func newRepo(t *testing.T, userID int64, repoName string, fileChanges FileChanges) func() { func newRepo(t *testing.T, userID int64, repoName string, fileChanges []FileChanges) func() {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
somerepo, _, cleanupFunc := tests.CreateDeclarativeRepo(t, user, repoName, somerepo, _, cleanupFunc := tests.CreateDeclarativeRepo(t, user, repoName,
[]unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues}, nil, []unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues}, nil,
@ -50,19 +66,25 @@ func newRepo(t *testing.T, userID int64, repoName string, fileChanges FileChange
) )
for _, file := range fileChanges { for _, file := range fileChanges {
changeLen := len(file) for i, version := range file.Versions {
for i := 1; i < changeLen; i++ { operation := "update"
operation := "create" if i == 0 {
if i != 1 { operation = "create"
operation = "update"
} }
// default to unique commit messages
commitMsg := file.CommitMsg
if commitMsg == "" {
commitMsg = fmt.Sprintf("Patch: %s-%d", file.Filename, i+1)
}
resp, err := files_service.ChangeRepoFiles(git.DefaultContext, somerepo, user, &files_service.ChangeRepoFilesOptions{ resp, err := files_service.ChangeRepoFiles(git.DefaultContext, somerepo, user, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{{ Files: []*files_service.ChangeRepoFile{{
Operation: operation, Operation: operation,
TreePath: file[0], TreePath: file.Filename,
ContentReader: strings.NewReader(file[i]), ContentReader: strings.NewReader(version),
}}, }},
Message: fmt.Sprintf("Patch: %s-%s", file[0], strconv.Itoa(i)), Message: commitMsg,
OldBranch: "main", OldBranch: "main",
NewBranch: "main", NewBranch: "main",
Author: &files_service.IdentityOptions{ Author: &files_service.IdentityOptions{

View file

@ -21,10 +21,10 @@ test('Load Homepage', async ({page}) => {
test('Register Form', async ({page}, workerInfo) => { test('Register Form', async ({page}, workerInfo) => {
const response = await page.goto('/user/sign_up'); const response = await page.goto('/user/sign_up');
expect(response?.status()).toBe(200); // Status OK expect(response?.status()).toBe(200); // Status OK
await page.type('input[name=user_name]', `e2e-test-${workerInfo.workerIndex}`); await page.fill('input[name=user_name]', `e2e-test-${workerInfo.workerIndex}`);
await page.type('input[name=email]', `e2e-test-${workerInfo.workerIndex}@test.com`); await page.fill('input[name=email]', `e2e-test-${workerInfo.workerIndex}@test.com`);
await page.type('input[name=password]', 'test123test123'); await page.fill('input[name=password]', 'test123test123');
await page.type('input[name=retype]', 'test123test123'); await page.fill('input[name=retype]', 'test123test123');
await page.click('form button.ui.primary.button:visible'); await page.click('form button.ui.primary.button:visible');
// Make sure we routed to the home page. Else login failed. // Make sure we routed to the home page. Else login failed.
expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`); expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`);

View file

@ -4,19 +4,18 @@
// web_src/js/features/repo-issue** // web_src/js/features/repo-issue**
// @watch end // @watch end
import {expect} from '@playwright/test'; /* eslint playwright/expect-expect: ["error", { "assertFunctionNames": ["check_wip"] }] */
import {expect, type Page} from '@playwright/test';
import {test, login_user, login} from './utils_e2e.ts'; import {test, login_user, login} from './utils_e2e.ts';
test.beforeAll(async ({browser}, workerInfo) => { test.beforeAll(async ({browser}, workerInfo) => {
await login_user(browser, workerInfo, 'user2'); await login_user(browser, workerInfo, 'user2');
}); });
/* eslint-disable playwright/expect-expect */
// some tests are reported to have no assertions,
// which is not correct, because they use the global helper function
test.describe('Pull: Toggle WIP', () => { test.describe('Pull: Toggle WIP', () => {
const prTitle = 'pull5'; const prTitle = 'pull5';
async function toggle_wip_to({page}, should) { async function toggle_wip_to({page}, should: boolean) {
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
if (should) { if (should) {
await page.getByText('Still in progress?').click(); await page.getByText('Still in progress?').click();
@ -25,7 +24,7 @@ test.describe('Pull: Toggle WIP', () => {
} }
} }
async function check_wip({page}, is) { async function check_wip({page}, is: boolean) {
const elemTitle = 'h1'; const elemTitle = 'h1';
const stateLabel = '.issue-state-label'; const stateLabel = '.issue-state-label';
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
@ -96,12 +95,11 @@ test.describe('Pull: Toggle WIP', () => {
await expect(page.locator('h1')).toContainText(maxLenStr); await expect(page.locator('h1')).toContainText(maxLenStr);
}); });
}); });
/* eslint-enable playwright/expect-expect */
test('Issue: Labels', async ({browser}, workerInfo) => { test('Issue: Labels', async ({browser}, workerInfo) => {
test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636'); test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
async function submitLabels({page}) { async function submitLabels({page}: {page: Page}) {
const submitted = page.waitForResponse('/user2/repo1/issues/labels'); const submitted = page.waitForResponse('/user2/repo1/issues/labels');
await page.locator('textarea').first().click(); // close via unrelated element await page.locator('textarea').first().click(); // close via unrelated element
await submitted; await submitted;
@ -199,7 +197,7 @@ test('New Issue: Assignees', async ({browser}, workerInfo) => {
// Assign other user (with searchbox) // Assign other user (with searchbox)
await page.locator('.select-assignees.dropdown').click(); await page.locator('.select-assignees.dropdown').click();
await page.type('.select-assignees .menu .search input', 'user4'); await page.fill('.select-assignees .menu .search input', 'user4');
await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user2'})).toBeHidden(); await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user2'})).toBeHidden();
await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user4'})).toBeVisible(); await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user4'})).toBeVisible();
await page.locator('.select-assignees .menu .item').filter({hasText: 'user4'}).click(); await page.locator('.select-assignees .menu .item').filter({hasText: 'user4'}).click();

View file

@ -29,7 +29,7 @@ test('markdown indentation', async ({browser}, workerInfo) => {
// Indent, then unindent first line // Indent, then unindent first line
await textarea.focus(); await textarea.focus();
await textarea.evaluate((it) => it.setSelectionRange(0, 0)); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(0, 0));
await indent.click(); await indent.click();
await expect(textarea).toHaveValue(`${tab}* first\n* second\n* third\n* last`); await expect(textarea).toHaveValue(`${tab}* first\n* second\n* third\n* last`);
await unindent.click(); await unindent.click();
@ -45,7 +45,7 @@ test('markdown indentation', async ({browser}, workerInfo) => {
// Subsequently, select a chunk of 2nd and 3rd line and indent both, preserving the cursor position in relation to text // Subsequently, select a chunk of 2nd and 3rd line and indent both, preserving the cursor position in relation to text
await textarea.focus(); await textarea.focus();
await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('hird'))); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('hird')));
await indent.click(); await indent.click();
const lines23 = `* first\n${tab}${tab}* second\n${tab}* third\n* last`; const lines23 = `* first\n${tab}${tab}* second\n${tab}* third\n* last`;
await expect(textarea).toHaveValue(lines23); await expect(textarea).toHaveValue(lines23);
@ -60,7 +60,7 @@ test('markdown indentation', async ({browser}, workerInfo) => {
// Indent and unindent with cursor at the end of the line // Indent and unindent with cursor at the end of the line
await textarea.focus(); await textarea.focus();
await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond'))); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond')));
await textarea.press('End'); await textarea.press('End');
await indent.click(); await indent.click();
await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`); await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`);
@ -69,7 +69,7 @@ test('markdown indentation', async ({browser}, workerInfo) => {
// Check that Tab does work after input // Check that Tab does work after input
await textarea.focus(); await textarea.focus();
await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.length, it.value.length));
await textarea.press('Shift+Enter'); // Avoid triggering the prefix continuation feature await textarea.press('Shift+Enter'); // Avoid triggering the prefix continuation feature
await textarea.pressSequentially('* least'); await textarea.pressSequentially('* least');
await indent.click(); await indent.click();
@ -78,7 +78,7 @@ test('markdown indentation', async ({browser}, workerInfo) => {
// Check that partial indents are cleared // Check that partial indents are cleared
await textarea.focus(); await textarea.focus();
await textarea.fill(initText); await textarea.fill(initText);
await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('* second'), it.value.indexOf('* second'))); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.indexOf('* second'), it.value.indexOf('* second')));
await textarea.pressSequentially(' '); await textarea.pressSequentially(' ');
await unindent.click(); await unindent.click();
await expect(textarea).toHaveValue(initText); await expect(textarea).toHaveValue(initText);
@ -99,7 +99,7 @@ test('markdown list continuation', async ({browser}, workerInfo) => {
await textarea.fill(initText); await textarea.fill(initText);
// Test continuation of '* ' prefix // Test continuation of '* ' prefix
await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond'))); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond')));
await textarea.press('End'); await textarea.press('End');
await textarea.press('Enter'); await textarea.press('Enter');
await textarea.pressSequentially('middle'); await textarea.pressSequentially('middle');
@ -112,7 +112,7 @@ test('markdown list continuation', async ({browser}, workerInfo) => {
await expect(textarea).toHaveValue(`* first\n* second\n${tab}* middle\n${tab}* muddle\n* third\n* last`); await expect(textarea).toHaveValue(`* first\n* second\n${tab}* middle\n${tab}* muddle\n* third\n* last`);
// Test breaking in the middle of a line // Test breaking in the middle of a line
await textarea.evaluate((it) => it.setSelectionRange(it.value.lastIndexOf('ddle'), it.value.lastIndexOf('ddle'))); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.lastIndexOf('ddle'), it.value.lastIndexOf('ddle')));
await textarea.pressSequentially('tate'); await textarea.pressSequentially('tate');
await textarea.press('Enter'); await textarea.press('Enter');
await textarea.pressSequentially('me'); await textarea.pressSequentially('me');
@ -120,7 +120,7 @@ test('markdown list continuation', async ({browser}, workerInfo) => {
// Test not triggering when Shift held // Test not triggering when Shift held
await textarea.fill(initText); await textarea.fill(initText);
await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.length, it.value.length));
await textarea.press('Shift+Enter'); await textarea.press('Shift+Enter');
await textarea.press('Enter'); await textarea.press('Enter');
await textarea.pressSequentially('...but not least'); await textarea.pressSequentially('...but not least');
@ -128,28 +128,28 @@ test('markdown list continuation', async ({browser}, workerInfo) => {
// Test continuation of ordered list // Test continuation of ordered list
await textarea.fill(`1. one\n2. two`); await textarea.fill(`1. one\n2. two`);
await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.length, it.value.length));
await textarea.press('Enter'); await textarea.press('Enter');
await textarea.pressSequentially('three'); await textarea.pressSequentially('three');
await expect(textarea).toHaveValue(`1. one\n2. two\n3. three`); await expect(textarea).toHaveValue(`1. one\n2. two\n3. three`);
// Test continuation of alternative ordered list syntax // Test continuation of alternative ordered list syntax
await textarea.fill(`1) one\n2) two`); await textarea.fill(`1) one\n2) two`);
await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.length, it.value.length));
await textarea.press('Enter'); await textarea.press('Enter');
await textarea.pressSequentially('three'); await textarea.pressSequentially('three');
await expect(textarea).toHaveValue(`1) one\n2) two\n3) three`); await expect(textarea).toHaveValue(`1) one\n2) two\n3) three`);
// Test continuation of blockquote // Test continuation of blockquote
await textarea.fill(`> knowledge is power`); await textarea.fill(`> knowledge is power`);
await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.length, it.value.length));
await textarea.press('Enter'); await textarea.press('Enter');
await textarea.pressSequentially('france is bacon'); await textarea.pressSequentially('france is bacon');
await expect(textarea).toHaveValue(`> knowledge is power\n> france is bacon`); await expect(textarea).toHaveValue(`> knowledge is power\n> france is bacon`);
// Test continuation of checklists // Test continuation of checklists
await textarea.fill(`- [ ] have a problem\n- [x] create a solution`); await textarea.fill(`- [ ] have a problem\n- [x] create a solution`);
await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.length, it.value.length));
await textarea.press('Enter'); await textarea.press('Enter');
await textarea.pressSequentially('write a test'); await textarea.pressSequentially('write a test');
await expect(textarea).toHaveValue(`- [ ] have a problem\n- [x] create a solution\n- [ ] write a test`); await expect(textarea).toHaveValue(`- [ ] have a problem\n- [x] create a solution\n- [ ] write a test`);
@ -174,7 +174,7 @@ test('markdown list continuation', async ({browser}, workerInfo) => {
]; ];
for (const prefix of prefixes) { for (const prefix of prefixes) {
await textarea.fill(`${prefix}one`); await textarea.fill(`${prefix}one`);
await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); await textarea.evaluate((it:HTMLTextAreaElement) => it.setSelectionRange(it.value.length, it.value.length));
await textarea.press('Enter'); await textarea.press('Enter');
await textarea.pressSequentially('two'); await textarea.pressSequentially('two');
await expect(textarea).toHaveValue(`${prefix}one\n${prefix}two`); await expect(textarea).toHaveValue(`${prefix}one\n${prefix}two`);

View file

@ -3,14 +3,14 @@
// routers/web/repo/issue.go // routers/web/repo/issue.go
// @watch end // @watch end
import {expect} from '@playwright/test'; import {expect, type Locator} from '@playwright/test';
import {test, login_user, load_logged_in_context} from './utils_e2e.ts'; import {test, login_user, load_logged_in_context} from './utils_e2e.ts';
test.beforeAll(async ({browser}, workerInfo) => { test.beforeAll(async ({browser}, workerInfo) => {
await login_user(browser, workerInfo, 'user2'); await login_user(browser, workerInfo, 'user2');
}); });
const assertReactionCounts = (comment, counts) => const assertReactionCounts = (comment: Locator, counts: unknown) =>
expect(async () => { expect(async () => {
await expect(comment.locator('.reactions')).toBeVisible(); await expect(comment.locator('.reactions')).toBeVisible();
@ -29,7 +29,7 @@ const assertReactionCounts = (comment, counts) =>
return expect(reactions).toStrictEqual(counts); return expect(reactions).toStrictEqual(counts);
}).toPass(); }).toPass();
async function toggleReaction(menu, reaction) { async function toggleReaction(menu: Locator, reaction: string) {
await menu.evaluateAll((menus) => menus[0].focus()); await menu.evaluateAll((menus) => menus[0].focus());
await menu.locator('.add-reaction').click(); await menu.locator('.add-reaction').click();
await menu.locator(`[role=menuitem][data-reaction-content="${reaction}"]`).click(); await menu.locator(`[role=menuitem][data-reaction-content="${reaction}"]`).click();

View file

@ -4,10 +4,15 @@
// services/gitdiff/** // services/gitdiff/**
// @watch end // @watch end
import {expect} from '@playwright/test'; import {expect, type Page} from '@playwright/test';
import {test} from './utils_e2e.ts'; import {test, login_user, login} from './utils_e2e.ts';
import {accessibilityCheck} from './shared/accessibility.ts';
async function assertSelectedLines(page, nums) { test.beforeAll(async ({browser}, workerInfo) => {
await login_user(browser, workerInfo, 'user2');
});
async function assertSelectedLines(page: Page, nums: string[]) {
const pageAssertions = async () => { const pageAssertions = async () => {
expect( expect(
await Promise.all((await page.locator('tr.active [data-line-number]').all()).map((line) => line.getAttribute('data-line-number'))), await Promise.all((await page.locator('tr.active [data-line-number]').all()).map((line) => line.getAttribute('data-line-number'))),
@ -75,3 +80,19 @@ test('Readable diff', async ({page}, workerInfo) => {
} }
} }
}); });
test('Username highlighted in commits', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user2/mentions-highlighted/commits/branch/main');
// check first commit
await page.getByRole('link', {name: 'A commit message which'}).click();
await expect(page.getByRole('link', {name: '@user2'})).toHaveCSS('background-color', /(.*)/);
await expect(page.getByRole('link', {name: '@user1'})).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await accessibilityCheck({page}, ['.commit-header'], [], []);
// check second commit
await page.goto('/user2/mentions-highlighted/commits/branch/main');
await page.locator('tbody').getByRole('link', {name: 'Another commit which mentions'}).click();
await expect(page.getByRole('link', {name: '@user2'})).toHaveCSS('background-color', /(.*)/);
await expect(page.getByRole('link', {name: '@user1'})).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await accessibilityCheck({page}, ['.commit-header'], [], []);
});

View file

@ -0,0 +1,35 @@
import {expect, type Page} from '@playwright/test';
import {AxeBuilder} from '@axe-core/playwright';
export async function accessibilityCheck({page}: {page: Page}, includes: string[], excludes: string[], disabledRules: string[]) {
// contrast of inline links is still a global issue in Forgejo
disabledRules.push('link-in-text-block');
let accessibilityScanner = new AxeBuilder({page})
.disableRules(disabledRules);
// passing the whole array seems to be not supported,
// iterating has the nice side-effectof skipping this if the array is empty
for (const incl of includes) {
// passing the whole array seems to be not supported
accessibilityScanner = accessibilityScanner.include(incl);
}
for (const excl of excludes) {
accessibilityScanner = accessibilityScanner.exclude(excl);
}
// scan the page both in dark and light theme
let accessibilityScanResults = await accessibilityScanner.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
await page.emulateMedia({colorScheme: 'dark'});
// in https://codeberg.org/forgejo/forgejo/pulls/5899 there have been
// some weird failures related to contrast scanning,
// reporting for colours that haven't been used and no trace in the
// screenshots.
// Since this was only happening with some browsers and not always,
// my bet is on a transition effect on dark/light mode switch.
// Waiting a little seems to work around this.
await page.waitForTimeout(100); // eslint-disable-line playwright/no-wait-for-timeout
accessibilityScanResults = await accessibilityScanner.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
await page.emulateMedia({colorScheme: 'light'});
}

View file

@ -1,17 +1,14 @@
import {expect, type Page} from '@playwright/test'; import {expect, type Page} from '@playwright/test';
import {AxeBuilder} from '@axe-core/playwright'; import {accessibilityCheck} from './accessibility.ts';
export async function validate_form({page}: {page: Page}, scope: 'form' | 'fieldset' = 'form') { export async function validate_form({page}: {page: Page}, scope: 'form' | 'fieldset' = 'form') {
const accessibilityScanResults = await new AxeBuilder({page}) const excludedElements = [
// disable checking for link style - should be fixed, but not now
.disableRules('link-in-text-block')
.include(scope)
// exclude automated tooltips from accessibility scan, remove when fixed // exclude automated tooltips from accessibility scan, remove when fixed
.exclude('span[data-tooltip-content') 'span[data-tooltip-content',
// exclude weird non-semantic HTML disabled content // exclude weird non-semantic HTML disabled content
.exclude('.disabled') '.disabled',
.analyze(); ];
expect(accessibilityScanResults.violations).toEqual([]); await accessibilityCheck({page}, [scope], excludedElements, []);
// assert CSS properties that needed to be overriden for forms (ensure they remain active) // assert CSS properties that needed to be overriden for forms (ensure they remain active)
const boxes = page.getByRole('checkbox').or(page.getByRole('radio')); const boxes = page.getByRole('checkbox').or(page.getByRole('radio'));

View file

@ -33,8 +33,8 @@ export async function login_user(browser: Browser, workerInfo: TestInfo, user: s
expect(response?.status()).toBe(200); // Status OK expect(response?.status()).toBe(200); // Status OK
// Fill out form // Fill out form
await page.type('input[name=user_name]', user); await page.fill('input[name=user_name]', user);
await page.type('input[name=password]', LOGIN_PASSWORD); await page.fill('input[name=password]', LOGIN_PASSWORD);
await page.click('form button.ui.primary.button:visible'); await page.click('form button.ui.primary.button:visible');
await page.waitForLoadState(); await page.waitForLoadState();
@ -48,15 +48,13 @@ export async function login_user(browser: Browser, workerInfo: TestInfo, user: s
} }
export async function load_logged_in_context(browser: Browser, workerInfo: TestInfo, user: string) { export async function load_logged_in_context(browser: Browser, workerInfo: TestInfo, user: string) {
let context;
try { try {
context = await test_context(browser, {storageState: `${ARTIFACTS_PATH}/state-${user}-${workerInfo.workerIndex}.json`}); return await test_context(browser, {storageState: `${ARTIFACTS_PATH}/state-${user}-${workerInfo.workerIndex}.json`});
} catch (err) { } catch (err) {
if (err.code === 'ENOENT') { if (err.code === 'ENOENT') {
throw new Error(`Could not find state for '${user}'. Did you call login_user(browser, workerInfo, '${user}') in test.beforeAll()?`); throw new Error(`Could not find state for '${user}'. Did you call login_user(browser, workerInfo, '${user}') in test.beforeAll()?`);
} }
} }
return context;
} }
export async function login({browser}: {browser: Browser}, workerInfo: TestInfo) { export async function login({browser}: {browser: Browser}, workerInfo: TestInfo) {

View file

@ -8,6 +8,8 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"regexp"
"testing" "testing"
"time" "time"
@ -17,6 +19,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var rootPathRe = regexp.MustCompile("\\[repository\\]\nROOT\\s=\\s.*")
func onForgejoRunTB(t testing.TB, callback func(testing.TB, *url.URL), prepare ...bool) { func onForgejoRunTB(t testing.TB, callback func(testing.TB, *url.URL), prepare ...bool) {
if len(prepare) == 0 || prepare[0] { if len(prepare) == 0 || prepare[0] {
defer tests.PrepareTestEnv(t, 1)() defer tests.PrepareTestEnv(t, 1)()
@ -37,7 +41,13 @@ func onForgejoRunTB(t testing.TB, callback func(testing.TB, *url.URL), prepare .
require.NoError(t, err) require.NoError(t, err)
u.Host = listener.Addr().String() u.Host = listener.Addr().String()
// Override repository root in config.
conf, err := os.ReadFile(setting.CustomConf)
require.NoError(t, err)
require.NoError(t, os.WriteFile(setting.CustomConf, rootPathRe.ReplaceAll(conf, []byte("[repository]\nROOT = "+setting.RepoRootPath)), 0o644))
defer func() { defer func() {
require.NoError(t, os.WriteFile(setting.CustomConf, conf, 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
s.Shutdown(ctx) s.Shutdown(ctx)
cancel() cancel()

View file

@ -30,7 +30,6 @@ test('WebAuthn register & login flow', async ({browser, request}, workerInfo) =>
transport: 'usb', transport: 'usb',
automaticPresenceSimulation: true, automaticPresenceSimulation: true,
isUserVerified: true, isUserVerified: true,
backupEligibility: true, // TODO: this doesn't seem to be available?!
}, },
}); });

View file

@ -109,4 +109,24 @@ func TestFeed(t *testing.T) {
}) })
}) })
}) })
t.Run("View permission", func(t *testing.T) {
t.Run("Anomynous", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/org3/repo3/rss/branch/master")
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("No code permission", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user8")
req := NewRequest(t, "GET", "/org3/repo3/rss/branch/master")
session.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("With code permission", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user9")
req := NewRequest(t, "GET", "/org3/repo3/rss/branch/master")
session.MakeRequest(t, req, http.StatusOK)
})
})
} }

View file

@ -17,6 +17,8 @@ import (
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
) )
func TestAPIForkAsAdminIgnoringLimits(t *testing.T) { func TestAPIForkAsAdminIgnoringLimits(t *testing.T) {
@ -106,3 +108,44 @@ func TestAPIDisabledForkRepo(t *testing.T) {
session.MakeRequest(t, req, http.StatusNotFound) session.MakeRequest(t, req, http.StatusNotFound)
}) })
} }
func TestAPIForkListPrivateRepo(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user5")
token := getTokenForLoggedInUser(t, session,
auth_model.AccessTokenScopeWriteRepository,
auth_model.AccessTokenScopeWriteOrganization)
org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23, Visibility: api.VisibleTypePrivate})
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{
Organization: &org23.Name,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusAccepted)
t.Run("Anomynous", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks")
resp := MakeRequest(t, req, http.StatusOK)
var forks []*api.Repository
DecodeJSON(t, resp, &forks)
assert.Empty(t, forks)
assert.EqualValues(t, "0", resp.Header().Get("X-Total-Count"))
})
t.Run("Logged in", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var forks []*api.Repository
DecodeJSON(t, resp, &forks)
assert.Len(t, forks, 1)
assert.EqualValues(t, "1", resp.Header().Get("X-Total-Count"))
})
}

View file

@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -58,3 +59,24 @@ func TestAPITwoFactor(t *testing.T) {
req.Header.Set("X-Forgejo-OTP", passcode) req.Header.Set("X-Forgejo-OTP", passcode)
MakeRequest(t, req, http.StatusOK) MakeRequest(t, req, http.StatusOK)
} }
func TestAPIWebAuthn(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32})
unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID})
req := NewRequest(t, "GET", "/api/v1/user")
req.SetBasicAuth(user.Name, "notpassword")
resp := MakeRequest(t, req, http.StatusUnauthorized)
type userResponse struct {
Message string `json:"message"`
}
var userParsed userResponse
DecodeJSON(t, resp, &userParsed)
assert.EqualValues(t, "Basic authorization is not allowed while having security keys enrolled", userParsed.Message)
}

View file

@ -9,6 +9,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
@ -206,11 +207,11 @@ func TestAPIListWikiPages(t *testing.T) {
} }
func TestAPINewWikiPage(t *testing.T) { func TestAPINewWikiPage(t *testing.T) {
defer tests.PrepareTestEnv(t)()
for _, title := range []string{ for _, title := range []string{
"New page", "New page",
"&&&&", "&&&&",
} { } {
defer tests.PrepareTestEnv(t)()
username := "user2" username := "user2"
session := loginUser(t, username) session := loginUser(t, username)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
@ -386,8 +387,7 @@ func TestAPIListPageRevisions(t *testing.T) {
} }
func TestAPIWikiNonMasterBranch(t *testing.T) { func TestAPIWikiNonMasterBranch(t *testing.T) {
defer tests.PrepareTestEnv(t)() onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{ repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{
WikiBranch: optional.Some("main"), WikiBranch: optional.Some("main"),
@ -408,4 +408,5 @@ func TestAPIWikiNonMasterBranch(t *testing.T) {
MakeRequest(t, req, http.StatusOK) MakeRequest(t, req, http.StatusOK)
}) })
} }
})
} }

View file

@ -84,7 +84,7 @@ func TestLTACookie(t *testing.T) {
assert.True(t, found) assert.True(t, found)
rawValidator, err := hex.DecodeString(validator) rawValidator, err := hex.DecodeString(validator)
require.NoError(t, err) require.NoError(t, err)
unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{LookupKey: lookupKey, HashedValidator: auth.HashValidator(rawValidator), UID: user.ID}) unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{LookupKey: lookupKey, HashedValidator: auth.HashValidator(rawValidator), UID: user.ID, Purpose: auth.LongTermAuthorization})
// Check if the LTA cookie it provides authentication. // Check if the LTA cookie it provides authentication.
// If LTA cookie provides authentication /user/login shouldn't return status 200. // If LTA cookie provides authentication /user/login shouldn't return status 200.
@ -143,7 +143,7 @@ func TestLTAExpiry(t *testing.T) {
assert.True(t, found) assert.True(t, found)
// Ensure it's not expired. // Ensure it's not expired.
lta := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey}) lta := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey, Purpose: auth.LongTermAuthorization})
assert.False(t, lta.IsExpired()) assert.False(t, lta.IsExpired())
// Manually stub LTA's expiry. // Manually stub LTA's expiry.
@ -151,7 +151,7 @@ func TestLTAExpiry(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Ensure it's expired. // Ensure it's expired.
lta = unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey}) lta = unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey, Purpose: auth.LongTermAuthorization})
assert.True(t, lta.IsExpired()) assert.True(t, lta.IsExpired())
// Should return 200 OK, because LTA doesn't provide authorization anymore. // Should return 200 OK, because LTA doesn't provide authorization anymore.
@ -160,5 +160,5 @@ func TestLTAExpiry(t *testing.T) {
session.MakeRequest(t, req, http.StatusOK) session.MakeRequest(t, req, http.StatusOK)
// Ensure it's deleted. // Ensure it's deleted.
unittest.AssertNotExistsBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey}) unittest.AssertNotExistsBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey, Purpose: auth.LongTermAuthorization})
} }

View file

@ -6,9 +6,12 @@ package integration
import ( import (
"net/http" "net/http"
"testing" "testing"
"code.gitea.io/gitea/tests"
) )
func TestEasyMDESwitch(t *testing.T) { func TestEasyMDESwitch(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2") session := loginUser(t, "user2")
testEasyMDESwitch(t, session, "user2/glob/issues/1", false) testEasyMDESwitch(t, session, "user2/glob/issues/1", false)
testEasyMDESwitch(t, session, "user2/glob/issues/new", false) testEasyMDESwitch(t, session, "user2/glob/issues/new", false)

View file

@ -0,0 +1,21 @@
-
id: 1001
org_id: 3
lower_name: no_code
name: no_code
authorize: 1 # read
num_repos: 1
num_members: 1
includes_all_repositories: false
can_create_org_repo: false
-
id: 1002
org_id: 3
lower_name: read_code
name: no_code
authorize: 1 # read
num_repos: 1
num_members: 1
includes_all_repositories: false
can_create_org_repo: false

View file

@ -0,0 +1,11 @@
-
id: 1001
org_id: 3
team_id: 1001
repo_id: 3
-
id: 1002
org_id: 3
team_id: 1002
repo_id: 3

View file

@ -0,0 +1,83 @@
-
id: 1001
team_id: 1001
type: 1
access_mode: 0
-
id: 1002
team_id: 1001
type: 2
access_mode: 1
-
id: 1003
team_id: 1001
type: 3
access_mode: 1
-
id: 1004
team_id: 1001
type: 4
access_mode: 1
-
id: 1005
team_id: 1001
type: 5
access_mode: 1
-
id: 1006
team_id: 1001
type: 6
access_mode: 1
-
id: 1007
team_id: 1001
type: 7
access_mode: 1
-
id: 1008
team_id: 1002
type: 1
access_mode: 1
-
id: 1009
team_id: 1002
type: 2
access_mode: 1
-
id: 1010
team_id: 1002
type: 3
access_mode: 1
-
id: 1011
team_id: 1002
type: 4
access_mode: 1
-
id: 1012
team_id: 1002
type: 5
access_mode: 1
-
id: 1013
team_id: 1002
type: 6
access_mode: 1
-
id: 1014
team_id: 1002
type: 7
access_mode: 1

View file

@ -0,0 +1,11 @@
-
id: 1001
org_id: 3
team_id: 1001
uid: 8
-
id: 1002
org_id: 3
team_id: 1002
uid: 9

View file

@ -13,7 +13,6 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -33,8 +32,6 @@ func assertFileEqual(t *testing.T, p string, content []byte) {
func TestRepoCloneWiki(t *testing.T) { func TestRepoCloneWiki(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
defer tests.PrepareTestEnv(t)()
dstPath := t.TempDir() dstPath := t.TempDir()
r := fmt.Sprintf("%suser2/repo1.wiki.git", u.String()) r := fmt.Sprintf("%suser2/repo1.wiki.git", u.String())

View file

@ -12,6 +12,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"testing" "testing"
"time" "time"
@ -59,6 +60,8 @@ func createSSHUrl(gitPath string, u *url.URL) *url.URL {
return &u2 return &u2
} }
var rootPathRe = regexp.MustCompile("\\[repository\\]\nROOT\\s=\\s.*")
func onGiteaRun[T testing.TB](t T, callback func(T, *url.URL)) { func onGiteaRun[T testing.TB](t T, callback func(T, *url.URL)) {
defer tests.PrepareTestEnv(t, 1)() defer tests.PrepareTestEnv(t, 1)()
s := http.Server{ s := http.Server{
@ -77,7 +80,13 @@ func onGiteaRun[T testing.TB](t T, callback func(T, *url.URL)) {
require.NoError(t, err) require.NoError(t, err)
u.Host = listener.Addr().String() u.Host = listener.Addr().String()
// Override repository root in config.
conf, err := os.ReadFile(setting.CustomConf)
require.NoError(t, err)
require.NoError(t, os.WriteFile(setting.CustomConf, rootPathRe.ReplaceAll(conf, []byte("[repository]\nROOT = "+setting.RepoRootPath)), 0o600))
defer func() { defer func() {
require.NoError(t, os.WriteFile(setting.CustomConf, conf, 0o600))
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
s.Shutdown(ctx) s.Shutdown(ctx)
cancel() cancel()

View file

@ -4,6 +4,7 @@
package integration package integration
import ( import (
"encoding/base32"
"io" "io"
"net" "net"
"net/smtp" "net/smtp"
@ -75,6 +76,51 @@ func TestIncomingEmail(t *testing.T) {
assert.Equal(t, payload, p) assert.Equal(t, payload, p)
}) })
tokenEncoding := base32.StdEncoding.WithPadding(base32.NoPadding)
t.Run("Deprecated token version", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
payload := []byte{1, 2, 3, 4, 5}
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
require.NoError(t, err)
assert.NotEmpty(t, token)
// Set the token to version 1.
unencodedToken, err := tokenEncoding.DecodeString(token)
require.NoError(t, err)
unencodedToken[0] = 1
token = tokenEncoding.EncodeToString(unencodedToken)
ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token)
require.ErrorContains(t, err, "unsupported token version: 1")
assert.Equal(t, token_service.UnknownHandlerType, ht)
assert.Nil(t, u)
assert.Nil(t, p)
})
t.Run("MAC check", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
payload := []byte{1, 2, 3, 4, 5}
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
require.NoError(t, err)
assert.NotEmpty(t, token)
// Modify the MAC.
unencodedToken, err := tokenEncoding.DecodeString(token)
require.NoError(t, err)
unencodedToken[len(unencodedToken)-1] ^= 0x01
token = tokenEncoding.EncodeToString(unencodedToken)
ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token)
require.ErrorContains(t, err, "verification failed")
assert.Equal(t, token_service.UnknownHandlerType, ht)
assert.Nil(t, u)
assert.Nil(t, p)
})
t.Run("Handler", func(t *testing.T) { t.Run("Handler", func(t *testing.T) {
t.Run("Reply", func(t *testing.T) { t.Run("Reply", func(t *testing.T) {
checkReply := func(t *testing.T, payload []byte, issue *issues_model.Issue, commentType issues_model.CommentType) { checkReply := func(t *testing.T, payload []byte, issue *issues_model.Issue, commentType issues_model.CommentType) {

View file

@ -0,0 +1,21 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestHeadMentionCSS(t *testing.T) {
userSession := loginUser(t, "user2")
resp := userSession.MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
assert.Contains(t, resp.Body.String(), `.mention[href="/user2" i]`)
guestSession := emptyTestSession(t)
resp = guestSession.MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
assert.NotContains(t, resp.Body.String(), `.mention[href="`)
}

View file

@ -323,3 +323,82 @@ func TestSSHPushMirror(t *testing.T) {
}) })
}) })
} }
func TestPushMirrorSettings(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
defer test.MockVariableValue(&setting.Mirror.Enabled, true)()
require.NoError(t, migrations.Init())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
srcRepo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
assert.False(t, srcRepo.HasWiki())
sess := loginUser(t, user.Name)
pushToRepo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{
Name: optional.Some("push-mirror-test"),
AutoInit: optional.Some(false),
EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}),
})
defer f()
t.Run("Adding", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo2.FullName()), map[string]string{
"_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo2.FullName())),
"action": "push-mirror-add",
"push_mirror_address": u.String() + pushToRepo.FullName(),
"push_mirror_interval": "0",
})
sess.MakeRequest(t, req, http.StatusSeeOther)
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
"_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
"action": "push-mirror-add",
"push_mirror_address": u.String() + pushToRepo.FullName(),
"push_mirror_interval": "0",
})
sess.MakeRequest(t, req, http.StatusSeeOther)
flashCookie := sess.GetCookie(gitea_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.Contains(t, flashCookie.Value, "success")
})
mirrors, _, err := repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{})
require.NoError(t, err)
assert.Len(t, mirrors, 1)
mirrorID := mirrors[0].ID
mirrors, _, err = repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo2.ID, db.ListOptions{})
require.NoError(t, err)
assert.Len(t, mirrors, 1)
t.Run("Interval", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{ID: mirrorID - 1})
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
"_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
"action": "push-mirror-update",
"push_mirror_id": strconv.FormatInt(mirrorID-1, 10),
"push_mirror_interval": "10m0s",
})
sess.MakeRequest(t, req, http.StatusNotFound)
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
"_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
"action": "push-mirror-update",
"push_mirror_id": strconv.FormatInt(mirrorID, 10),
"push_mirror_interval": "10m0s",
})
sess.MakeRequest(t, req, http.StatusSeeOther)
flashCookie := sess.GetCookie(gitea_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.Contains(t, flashCookie.Value, "success")
})
})
}

View file

@ -10,6 +10,7 @@ import (
"strings" "strings"
"testing" "testing"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
@ -293,8 +294,10 @@ func TestOrgTeamEmailInviteRedirectsNewUserWithActivation(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
session.jar.SetCookies(baseURL, cr.Cookies()) session.jar.SetCookies(baseURL, cr.Cookies())
activateURL := fmt.Sprintf("/user/activate?code=%s", user.GenerateEmailActivateCode("doesnotexist@example.com")) code, err := user.GenerateEmailAuthorizationCode(db.DefaultContext, auth.UserActivation)
req = NewRequestWithValues(t, "POST", activateURL, map[string]string{ require.NoError(t, err)
req = NewRequestWithValues(t, "POST", "/user/activate?code="+url.QueryEscape(code), map[string]string{
"password": "examplePassword!1", "password": "examplePassword!1",
}) })

View file

@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
@ -238,3 +239,34 @@ func TestRepoForkToOrg(t *testing.T) {
}) })
}) })
} }
func TestForkListPrivateRepo(t *testing.T) {
forkItemSelector := ".tw-flex.tw-items-center.tw-py-2"
onGiteaRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user5")
org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23, Visibility: structs.VisibleTypePrivate})
testRepoFork(t, session, "user2", "repo1", org23.Name, "repo1")
t.Run("Anomynous", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/forks")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertElement(t, forkItemSelector, false)
})
t.Run("Logged in", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/forks")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertElement(t, forkItemSelector, true)
})
})
}

View file

@ -5,17 +5,20 @@
package integration package integration
import ( import (
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/release" "code.gitea.io/gitea/services/release"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -159,3 +162,47 @@ func TestSyncRepoTags(t *testing.T) {
}) })
}) })
} }
func TestRepushTag(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
httpContext := NewAPITestContext(t, owner.Name, repo.Name)
dstPath := t.TempDir()
u.Path = httpContext.GitPath()
u.User = url.UserPassword(owner.Name, userPassword)
doGitClone(dstPath, u)(t)
// create and push a tag
_, _, err := git.NewCommand(git.DefaultContext, "tag", "v2.0").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
_, _, err = git.NewCommand(git.DefaultContext, "push", "origin", "--tags", "v2.0").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
// create a release for the tag
createdRelease := createNewReleaseUsingAPI(t, token, owner, repo, "v2.0", "", "Release of v2.0", "desc")
assert.False(t, createdRelease.IsDraft)
// delete the tag
_, _, err = git.NewCommand(git.DefaultContext, "push", "origin", "--delete", "v2.0").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
// query the release by API and it should be a draft
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, "v2.0"))
resp := MakeRequest(t, req, http.StatusOK)
var respRelease *api.Release
DecodeJSON(t, resp, &respRelease)
assert.True(t, respRelease.IsDraft)
// re-push the tag
_, _, err = git.NewCommand(git.DefaultContext, "push", "origin", "--tags", "v2.0").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
// query the release by API and it should not be a draft
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, "v2.0"))
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &respRelease)
assert.False(t, respRelease.IsDraft)
})
}

View file

@ -5,14 +5,18 @@
package integration package integration
import ( import (
"bytes"
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit" unit_model "code.gitea.io/gitea/models/unit"
@ -836,3 +840,171 @@ func TestUserRepos(t *testing.T) {
} }
} }
} }
func TestUserActivate(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)()
called := false
code := ""
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
called = true
assert.Len(t, msgs, 1)
assert.Equal(t, `"doesnotexist" <doesnotexist@example.com>`, msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.activate_account"), msgs[0].Subject)
messageDoc := NewHTMLParser(t, bytes.NewBuffer([]byte(msgs[0].Body)))
link, ok := messageDoc.Find("a").Attr("href")
assert.True(t, ok)
u, err := url.Parse(link)
require.NoError(t, err)
code = u.Query()["code"][0]
})()
session := emptyTestSession(t)
req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
"_csrf": GetCSRF(t, session, "/user/sign_up"),
"user_name": "doesnotexist",
"email": "doesnotexist@example.com",
"password": "examplePassword!1",
"retype": "examplePassword!1",
})
session.MakeRequest(t, req, http.StatusOK)
assert.True(t, called)
queryCode, err := url.QueryUnescape(code)
require.NoError(t, err)
lookupKey, validator, ok := strings.Cut(queryCode, ":")
assert.True(t, ok)
rawValidator, err := hex.DecodeString(validator)
require.NoError(t, err)
authToken, err := auth_model.FindAuthToken(db.DefaultContext, lookupKey, auth_model.UserActivation)
require.NoError(t, err)
assert.False(t, authToken.IsExpired())
assert.EqualValues(t, authToken.HashedValidator, auth_model.HashValidator(rawValidator))
req = NewRequest(t, "POST", "/user/activate?code="+code)
session.MakeRequest(t, req, http.StatusOK)
unittest.AssertNotExistsBean(t, &auth_model.AuthorizationToken{ID: authToken.ID})
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "doesnotexist", IsActive: true})
}
func TestUserPasswordReset(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
called := false
code := ""
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
if called {
return
}
called = true
assert.Len(t, msgs, 1)
assert.Equal(t, user2.EmailTo(), msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.reset_password"), msgs[0].Subject)
messageDoc := NewHTMLParser(t, bytes.NewBuffer([]byte(msgs[0].Body)))
link, ok := messageDoc.Find("a").Attr("href")
assert.True(t, ok)
u, err := url.Parse(link)
require.NoError(t, err)
code = u.Query()["code"][0]
})()
session := emptyTestSession(t)
req := NewRequestWithValues(t, "POST", "/user/forgot_password", map[string]string{
"_csrf": GetCSRF(t, session, "/user/forgot_password"),
"email": user2.Email,
})
session.MakeRequest(t, req, http.StatusOK)
assert.True(t, called)
queryCode, err := url.QueryUnescape(code)
require.NoError(t, err)
lookupKey, validator, ok := strings.Cut(queryCode, ":")
assert.True(t, ok)
rawValidator, err := hex.DecodeString(validator)
require.NoError(t, err)
authToken, err := auth_model.FindAuthToken(db.DefaultContext, lookupKey, auth_model.PasswordReset)
require.NoError(t, err)
assert.False(t, authToken.IsExpired())
assert.EqualValues(t, authToken.HashedValidator, auth_model.HashValidator(rawValidator))
req = NewRequestWithValues(t, "POST", "/user/recover_account", map[string]string{
"_csrf": GetCSRF(t, session, "/user/recover_account"),
"code": code,
"password": "new_password",
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertNotExistsBean(t, &auth_model.AuthorizationToken{ID: authToken.ID})
assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).ValidatePassword("new_password"))
}
func TestActivateEmailAddress(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
called := false
code := ""
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
if called {
return
}
called = true
assert.Len(t, msgs, 1)
assert.Equal(t, "newemail@example.org", msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.activate_email"), msgs[0].Subject)
messageDoc := NewHTMLParser(t, bytes.NewBuffer([]byte(msgs[0].Body)))
link, ok := messageDoc.Find("a").Attr("href")
assert.True(t, ok)
u, err := url.Parse(link)
require.NoError(t, err)
code = u.Query()["code"][0]
})()
session := loginUser(t, user2.Name)
req := NewRequestWithValues(t, "POST", "/user/settings/account/email", map[string]string{
"_csrf": GetCSRF(t, session, "/user/settings"),
"email": "newemail@example.org",
})
session.MakeRequest(t, req, http.StatusSeeOther)
assert.True(t, called)
queryCode, err := url.QueryUnescape(code)
require.NoError(t, err)
lookupKey, validator, ok := strings.Cut(queryCode, ":")
assert.True(t, ok)
rawValidator, err := hex.DecodeString(validator)
require.NoError(t, err)
authToken, err := auth_model.FindAuthToken(db.DefaultContext, lookupKey, auth_model.EmailActivation("newemail@example.org"))
require.NoError(t, err)
assert.False(t, authToken.IsExpired())
assert.EqualValues(t, authToken.HashedValidator, auth_model.HashValidator(rawValidator))
req = NewRequestWithValues(t, "POST", "/user/activate_email", map[string]string{
"code": code,
"email": "newemail@example.org",
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertNotExistsBean(t, &auth_model.AuthorizationToken{ID: authToken.ID})
unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{UID: user2.ID, IsActivated: true, Email: "newemail@example.org"})
}

View file

@ -5,6 +5,7 @@ RUN_MODE = prod
[database] [database]
DB_TYPE = sqlite3 DB_TYPE = sqlite3
PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/gitea.db PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/gitea.db
SQLITE_JOURNAL_MODE = MEMORY
[indexer] [indexer]
REPO_INDEXER_ENABLED = true REPO_INDEXER_ENABLED = true

View file

@ -48,6 +48,8 @@ func exitf(format string, args ...any) {
os.Exit(1) os.Exit(1)
} }
var preparedDir string
func InitTest(requireGitea bool) { func InitTest(requireGitea bool) {
log.RegisterEventWriter("test", testlogger.NewTestLoggerWriter) log.RegisterEventWriter("test", testlogger.NewTestLoggerWriter)
@ -175,6 +177,46 @@ func InitTest(requireGitea bool) {
log.Fatal("db.Exec: CREATE SCHEMA: %v", err) log.Fatal("db.Exec: CREATE SCHEMA: %v", err)
} }
} }
case setting.Database.Type.IsSQLite3():
setting.Database.Path = ":memory:"
}
setting.Repository.Local.LocalCopyPath = os.TempDir()
dir, err := os.MkdirTemp("", "prepared-forgejo")
if err != nil {
log.Fatal("os.MkdirTemp: %v", err)
}
preparedDir = dir
setting.Repository.Local.LocalCopyPath, err = os.MkdirTemp("", "local-upload")
if err != nil {
log.Fatal("os.MkdirTemp: %v", err)
}
if err := unittest.CopyDir(path.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), dir); err != nil {
log.Fatal("os.RemoveAll: %v", err)
}
ownerDirs, err := os.ReadDir(dir)
if err != nil {
log.Fatal("os.ReadDir: %v", err)
}
for _, ownerDir := range ownerDirs {
if !ownerDir.Type().IsDir() {
continue
}
repoDirs, err := os.ReadDir(filepath.Join(dir, ownerDir.Name()))
if err != nil {
log.Fatal("os.ReadDir: %v", err)
}
for _, repoDir := range repoDirs {
_ = os.MkdirAll(filepath.Join(dir, ownerDir.Name(), repoDir.Name(), "objects", "pack"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, ownerDir.Name(), repoDir.Name(), "objects", "info"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, ownerDir.Name(), repoDir.Name(), "refs", "heads"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, ownerDir.Name(), repoDir.Name(), "refs", "tag"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, ownerDir.Name(), repoDir.Name(), "refs", "pull"), 0o755)
}
} }
routers.InitWebInstalled(graceful.GetManager().HammerContext()) routers.InitWebInstalled(graceful.GetManager().HammerContext())
@ -225,28 +267,10 @@ func cancelProcesses(t testing.TB, delay time.Duration) {
} }
func PrepareGitRepoDirectory(t testing.TB) { func PrepareGitRepoDirectory(t testing.TB) {
require.NoError(t, util.RemoveAll(setting.RepoRootPath)) var err error
require.NoError(t, unittest.CopyDir(path.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath)) setting.RepoRootPath, err = os.MkdirTemp(t.TempDir(), "forgejo-repo-rooth")
ownerDirs, err := os.ReadDir(setting.RepoRootPath) require.NoError(t, err)
if err != nil { require.NoError(t, unittest.CopyDir(preparedDir, setting.RepoRootPath))
require.NoError(t, err, "unable to read the new repo root: %v\n", err)
}
for _, ownerDir := range ownerDirs {
if !ownerDir.Type().IsDir() {
continue
}
repoDirs, err := os.ReadDir(filepath.Join(setting.RepoRootPath, ownerDir.Name()))
if err != nil {
require.NoError(t, err, "unable to read the new repo root: %v\n", err)
}
for _, repoDir := range repoDirs {
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "pack"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "info"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "heads"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "tag"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "pull"), 0o755)
}
}
} }
func PrepareArtifactsStorage(t testing.TB) { func PrepareArtifactsStorage(t testing.TB) {

View file

@ -56,10 +56,21 @@ export async function renderMermaid() {
btn.setAttribute('data-clipboard-text', source); btn.setAttribute('data-clipboard-text', source);
mermaidBlock.append(btn); mermaidBlock.append(btn);
const updateIframeHeight = () => {
iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`;
};
// update height when element's visibility state changes, for example when the diagram is inside
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
// would initially set a incorrect height and the correct height is set during this callback.
(new IntersectionObserver(() => {
updateIframeHeight();
}, {root: document.documentElement})).observe(iframe);
iframe.addEventListener('load', () => { iframe.addEventListener('load', () => {
pre.replaceWith(mermaidBlock); pre.replaceWith(mermaidBlock);
mermaidBlock.classList.remove('tw-hidden'); mermaidBlock.classList.remove('tw-hidden');
iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`; updateIframeHeight();
setTimeout(() => { // avoid flash of iframe background setTimeout(() => { // avoid flash of iframe background
mermaidBlock.classList.remove('is-loading'); mermaidBlock.classList.remove('is-loading');
iframe.classList.remove('tw-invisible'); iframe.classList.remove('tw-invisible');