mirror of
https://github.com/gogs/gogs.git
synced 2026-05-28 21:30:36 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 878caa7378 | |||
| adea243ee8 | |||
| 44f0222a71 | |||
| 26483c41c6 | |||
| 403db931cf | |||
| cd2f94a85b | |||
| 4935e7a63b | |||
| 71dfd3c7ac | |||
| ecb04beadd | |||
| 83a48c286d | |||
| f739682e9a | |||
| d54f98f5a4 | |||
| e7d0cb646d | |||
| dd6be39208 | |||
| c93373baec | |||
| 90790b2966 | |||
| e9310ea08f | |||
| 75f99c9435 | |||
| 343007e78a | |||
| b67c13c6bb | |||
| a3c9f4acef | |||
| 4c80cbc7eb | |||
| bfec14a857 | |||
| cc8036e081 | |||
| 765c0e96db | |||
| f3e563d854 | |||
| 199cf4fd5b | |||
| 0089c4c8e5 | |||
| 6734dd46c3 | |||
| a5d3439e2d | |||
| d7571322a0 | |||
| edc83e6ab2 | |||
| 7297aee50d | |||
| 27e92f8463 | |||
| b36ba5b60e |
@@ -1,26 +0,0 @@
|
||||
version = 1
|
||||
|
||||
exclude_patterns = ["**/mocks_test.go"]
|
||||
|
||||
[[analyzers]]
|
||||
name = "docker"
|
||||
enabled = true
|
||||
|
||||
[[analyzers]]
|
||||
name = "shell"
|
||||
enabled = true
|
||||
|
||||
[[analyzers]]
|
||||
name = "go"
|
||||
enabled = true
|
||||
|
||||
[analyzers.meta]
|
||||
import_root = "github.com/gogs/gogs"
|
||||
|
||||
[[transformers]]
|
||||
name = "gofumpt"
|
||||
enabled = true
|
||||
|
||||
[[transformers]]
|
||||
name = "gofmt"
|
||||
enabled = true
|
||||
+5
-4
@@ -1,5 +1,3 @@
|
||||
.packager
|
||||
.packager/**
|
||||
scripts
|
||||
scripts/**
|
||||
.github/
|
||||
@@ -11,5 +9,8 @@ scripts/**
|
||||
.gitignore
|
||||
Dockerfile*
|
||||
gogs
|
||||
|
||||
!Taskfile.yml
|
||||
node_modules
|
||||
**/node_modules
|
||||
public/dist
|
||||
**/*.tsbuildinfo
|
||||
**/.vite
|
||||
|
||||
@@ -71,7 +71,6 @@ Contributing to another codebase is not as simple as code changes, it is also ab
|
||||
### Coding guidelines
|
||||
|
||||
1. Please read the Sourcegraph's [Go style guide](https://github.com/sourcegraph/sourcegraph-public-snapshot/blob/main/doc/dev/background-information/languages/go.md).
|
||||
1. **NO** direct modifications to `.css` files, `.css` files are all generated by `.less` files. You can regenerate `.css` files by executing `task less`.
|
||||
|
||||
## Your PR is merged!
|
||||
|
||||
|
||||
@@ -42,5 +42,5 @@ On the `main` branch:
|
||||
- [ ] Send a tweet on the [official Twitter account](https://twitter.com/GogsHQ) for the minor release.
|
||||
- [ ] Close the milestone for the minor release.
|
||||
- [ ] [Bump the hard-coded version](https://github.com/gogs/gogs/commit/a98968436cd5841cf691bb0b80c54c81470d1676) to the new develop version, e.g. `0.14.0+dev` -> `0.15.0+dev`.
|
||||
- [ ] Run `task legacy` to identify deprecated code that is aimed to be removed in current develop version.
|
||||
- [ ] Run `grep -rnw "\(LEGACY\|Deprecated\)" internal` to identify deprecated code that is aimed to be removed in current develop version.
|
||||
- [ ] **After 14 days**, publish [GitHub security advisories](https://github.com/gogs/gogs/security) for security patches included in the release.
|
||||
|
||||
@@ -13,7 +13,7 @@ _This is generated from the [patch release template](https://github.com/gogs/gog
|
||||
On the release branch:
|
||||
|
||||
- [ ] Make sure all commits are cherry-picked from the `main` branch by checking the patch milestone.
|
||||
- Run `task build` for every cherry-picked commit to make sure there is no compilation error.
|
||||
- Run `moon run gogs:build-prod --force` for every cherry-picked commit to make sure there is no compilation error.
|
||||
- [ ] [Update CHANGELOG on the `main` branch](https://github.com/gogs/gogs/commit/f1102a7a7c545ec221d2906f02fa19170d96f96d) to include entries for the current patch release.
|
||||
|
||||
## During release
|
||||
|
||||
@@ -15,65 +15,6 @@ on:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
buildx:
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'gogs/gogs' }}
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
- name: Inspect builder
|
||||
run: |
|
||||
echo "Name: ${{ steps.buildx.outputs.name }}"
|
||||
echo "Endpoint: ${{ steps.buildx.outputs.endpoint }}"
|
||||
echo "Status: ${{ steps.buildx.outputs.status }}"
|
||||
echo "Flags: ${{ steps.buildx.outputs.flags }}"
|
||||
echo "Platforms: ${{ steps.buildx.outputs.platforms }}"
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
tags: |
|
||||
gogs/gogs:latest
|
||||
ghcr.io/gogs/gogs:latest
|
||||
- name: Scan for container vulnerabilities
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: gogs/gogs:latest
|
||||
exit-code: '1'
|
||||
- name: Send email on failure
|
||||
uses: unknwon/send-email-on-failure@89339a1bc93f4ad1d30f3b7e4911fcba985c9adb # v1
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
smtp_username: ${{ secrets.SMTP_USERNAME }}
|
||||
smtp_password: ${{ secrets.SMTP_PASSWORD }}
|
||||
|
||||
buildx-next:
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'gogs/gogs' }}
|
||||
concurrency:
|
||||
@@ -126,13 +67,13 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
tags: |
|
||||
gogs/gogs:next-latest
|
||||
ghcr.io/gogs/gogs:next-latest
|
||||
registry.digitalocean.com/gogs/gogs:next-latest
|
||||
gogs/gogs:edge
|
||||
ghcr.io/gogs/gogs:edge
|
||||
registry.digitalocean.com/gogs/gogs:edge
|
||||
- name: Scan for container vulnerabilities
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: gogs/gogs:next-latest
|
||||
image-ref: gogs/gogs:edge
|
||||
exit-code: '1'
|
||||
- name: Send email on failure
|
||||
uses: unknwon/send-email-on-failure@89339a1bc93f4ad1d30f3b7e4911fcba985c9adb # v1
|
||||
@@ -255,6 +196,13 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
# Full history with tags is required so the next step can determine
|
||||
# whether this release is the highest stable version across the repo.
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- name: Compute image tags
|
||||
run: |
|
||||
IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -c 2-)
|
||||
@@ -263,19 +211,34 @@ jobs:
|
||||
TAGS="gogs/gogs:$IMAGE_TAG
|
||||
ghcr.io/gogs/gogs:$IMAGE_TAG"
|
||||
|
||||
# Add minor version tag for stable releases (no prerelease suffix per semver).
|
||||
# For stable releases (no prerelease suffix per semver), add the
|
||||
# minor-version tag only if this release is the highest patch of that
|
||||
# minor line, and add `latest` only if this release is the highest
|
||||
# stable version across the repository. Back-patches on older lines
|
||||
# must not clobber moving tags.
|
||||
if [[ ! "$IMAGE_TAG" =~ - ]]; then
|
||||
STABLE_TAGS=$(git tag --list 'v*' | sed 's/^v//' | grep -v -- '-' || true)
|
||||
HIGHEST_STABLE=$(echo "$STABLE_TAGS" | sort -V | tail -n 1)
|
||||
MINOR_TAG=$(echo "$IMAGE_TAG" | cut -d. -f1,2)
|
||||
TAGS="$TAGS
|
||||
# `|| true` keeps the step running when `grep` finds no matches,
|
||||
# since bash runs with `-e -o pipefail` in GitHub Actions.
|
||||
HIGHEST_IN_MINOR=$(echo "$STABLE_TAGS" | { grep "^${MINOR_TAG}\." || true; } | sort -V | tail -n 1)
|
||||
|
||||
if [[ "$IMAGE_TAG" == "$HIGHEST_IN_MINOR" ]]; then
|
||||
TAGS="$TAGS
|
||||
gogs/gogs:$MINOR_TAG
|
||||
ghcr.io/gogs/gogs:$MINOR_TAG"
|
||||
fi
|
||||
if [[ "$IMAGE_TAG" == "$HIGHEST_STABLE" ]]; then
|
||||
TAGS="$TAGS
|
||||
gogs/gogs:latest
|
||||
ghcr.io/gogs/gogs:latest"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "TAGS<<EOF" >> $GITHUB_ENV
|
||||
echo "$TAGS" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
- name: Check out code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
with:
|
||||
@@ -308,6 +271,11 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ env.TAGS }}
|
||||
- name: Scan for container vulnerabilities
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: gogs/gogs:${{ env.IMAGE_TAG }}
|
||||
exit-code: '1'
|
||||
- name: Send email on failure
|
||||
uses: unknwon/send-email-on-failure@89339a1bc93f4ad1d30f3b7e4911fcba985c9adb # v1
|
||||
if: ${{ failure() }}
|
||||
@@ -324,6 +292,13 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
# Full history with tags is required so the next step can determine
|
||||
# whether this release is the highest stable version across the repo.
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- name: Compute image tags
|
||||
run: |
|
||||
IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -c 2-)
|
||||
@@ -332,19 +307,34 @@ jobs:
|
||||
TAGS="gogs/gogs:next-$IMAGE_TAG
|
||||
ghcr.io/gogs/gogs:next-$IMAGE_TAG"
|
||||
|
||||
# Add minor version tag for stable releases (no prerelease suffix per semver).
|
||||
# For stable releases (no prerelease suffix per semver), add the
|
||||
# minor-version tag only if this release is the highest patch of that
|
||||
# minor line, and add `next-latest` only if this release is the
|
||||
# highest stable version across the repository. Back-patches on older
|
||||
# lines must not clobber moving tags.
|
||||
if [[ ! "$IMAGE_TAG" =~ - ]]; then
|
||||
STABLE_TAGS=$(git tag --list 'v*' | sed 's/^v//' | grep -v -- '-' || true)
|
||||
HIGHEST_STABLE=$(echo "$STABLE_TAGS" | sort -V | tail -n 1)
|
||||
MINOR_TAG=$(echo "$IMAGE_TAG" | cut -d. -f1,2)
|
||||
TAGS="$TAGS
|
||||
# `|| true` keeps the step running when `grep` finds no matches,
|
||||
# since bash runs with `-e -o pipefail` in GitHub Actions.
|
||||
HIGHEST_IN_MINOR=$(echo "$STABLE_TAGS" | { grep "^${MINOR_TAG}\." || true; } | sort -V | tail -n 1)
|
||||
|
||||
if [[ "$IMAGE_TAG" == "$HIGHEST_IN_MINOR" ]]; then
|
||||
TAGS="$TAGS
|
||||
gogs/gogs:next-$MINOR_TAG
|
||||
ghcr.io/gogs/gogs:next-$MINOR_TAG"
|
||||
fi
|
||||
if [[ "$IMAGE_TAG" == "$HIGHEST_STABLE" ]]; then
|
||||
TAGS="$TAGS
|
||||
gogs/gogs:next-latest
|
||||
ghcr.io/gogs/gogs:next-latest"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "TAGS<<EOF" >> $GITHUB_ENV
|
||||
echo "$TAGS" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
- name: Check out code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
with:
|
||||
@@ -378,6 +368,11 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ env.TAGS }}
|
||||
- name: Scan for container vulnerabilities
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: gogs/gogs:next-${{ env.IMAGE_TAG }}
|
||||
exit-code: '1'
|
||||
- name: Send email on failure
|
||||
uses: unknwon/send-email-on-failure@89339a1bc93f4ad1d30f3b7e4911fcba985c9adb # v1
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -35,20 +35,16 @@ jobs:
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: 1.26.x
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v2.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Check Go module tidiness and generated files
|
||||
shell: bash
|
||||
run: |
|
||||
go mod tidy
|
||||
task generate
|
||||
go generate ./...
|
||||
STATUS=$(git status --porcelain)
|
||||
if [ ! -z "$STATUS" ]; then
|
||||
echo "Unstaged files:"
|
||||
echo $STATUS
|
||||
echo "Run 'go mod tidy' or 'task generate' commit them"
|
||||
echo "Run 'go mod tidy' or 'go generate ./...' and commit them"
|
||||
exit 1
|
||||
fi
|
||||
- name: Run golangci-lint
|
||||
|
||||
@@ -23,22 +23,23 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}${{ matrix.suffix }}
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'gogs/gogs' }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {goos: linux, goarch: amd64}
|
||||
- {goos: linux, goarch: arm64}
|
||||
- {goos: linux, goarch: "386"}
|
||||
- {goos: darwin, goarch: amd64}
|
||||
- {goos: darwin, goarch: arm64}
|
||||
- {goos: windows, goarch: amd64}
|
||||
- {goos: windows, goarch: arm64}
|
||||
- {goos: windows, goarch: "386"}
|
||||
- {goos: windows, goarch: amd64, suffix: "_mws", tags: minwinsvc}
|
||||
- {goos: windows, goarch: arm64, suffix: "_mws", tags: minwinsvc}
|
||||
- {goos: windows, goarch: "386", suffix: "_mws", tags: minwinsvc}
|
||||
- {goos: linux, goarch: amd64, runner: ubuntu-latest}
|
||||
- {goos: linux, goarch: arm64, runner: ubuntu-latest}
|
||||
- {goos: linux, goarch: "386", runner: ubuntu-latest}
|
||||
- {goos: darwin, goarch: amd64, runner: macos-latest}
|
||||
- {goos: darwin, goarch: arm64, runner: macos-latest}
|
||||
- {goos: windows, goarch: amd64, runner: ubuntu-latest}
|
||||
- {goos: windows, goarch: arm64, runner: ubuntu-latest}
|
||||
- {goos: windows, goarch: "386", runner: ubuntu-latest}
|
||||
- {goos: windows, goarch: amd64, suffix: "_mws", tags: minwinsvc, runner: ubuntu-latest}
|
||||
- {goos: windows, goarch: arm64, suffix: "_mws", tags: minwinsvc, runner: ubuntu-latest}
|
||||
- {goos: windows, goarch: "386", suffix: "_mws", tags: minwinsvc, runner: ubuntu-latest}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
@@ -46,6 +47,17 @@ jobs:
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: 1.26.x
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- name: Build web assets
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter gogs-web run build
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
@@ -70,9 +82,9 @@ jobs:
|
||||
BINARY_NAME="gogs.exe"
|
||||
fi
|
||||
|
||||
TAGS_FLAG=""
|
||||
TAGS="prod"
|
||||
if [ -n "${{ matrix.tags }}" ]; then
|
||||
TAGS_FLAG="-tags ${{ matrix.tags }}"
|
||||
TAGS="$TAGS ${{ matrix.tags }}"
|
||||
fi
|
||||
|
||||
go build -v \
|
||||
@@ -80,8 +92,44 @@ jobs:
|
||||
-X \"gogs.io/gogs/internal/conf.BuildTime=$(date -u '+%Y-%m-%d %I:%M:%S %Z')\"
|
||||
-X \"gogs.io/gogs/internal/conf.BuildCommit=$(git rev-parse HEAD)\"
|
||||
" \
|
||||
$TAGS_FLAG \
|
||||
-tags "$TAGS" \
|
||||
-trimpath -o "$BINARY_NAME" ./cmd/gogs
|
||||
- name: Import Apple signing certificate
|
||||
if: ${{ matrix.goos == 'darwin' }}
|
||||
env:
|
||||
APPLE_DEVELOPER_ID_CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_BASE64 }}
|
||||
APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
|
||||
APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
if [ -z "$APPLE_DEVELOPER_ID_CERTIFICATE_BASE64" ] || [ -z "$APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD" ] || [ -z "$APPLE_KEYCHAIN_PASSWORD" ]; then
|
||||
echo "Missing required Apple signing secrets." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CERTIFICATE_PATH="$RUNNER_TEMP/developer_id_application.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
|
||||
|
||||
printf '%s' "$APPLE_DEVELOPER_ID_CERTIFICATE_BASE64" | base64 -d > "$CERTIFICATE_PATH"
|
||||
security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERTIFICATE_PATH" -P "$APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
security list-keychains -d user -s "$KEYCHAIN_PATH"
|
||||
security default-keychain -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
- name: Sign macOS binary
|
||||
if: ${{ matrix.goos == 'darwin' }}
|
||||
env:
|
||||
APPLE_DEVELOPER_IDENTITY: ${{ secrets.APPLE_DEVELOPER_IDENTITY }}
|
||||
run: |
|
||||
if [ -z "$APPLE_DEVELOPER_IDENTITY" ]; then
|
||||
echo "Missing required Apple signing identity secret." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
security find-identity -v -p codesigning
|
||||
codesign --force --options runtime --timestamp --sign "$APPLE_DEVELOPER_IDENTITY" "gogs"
|
||||
codesign --verify --verbose=2 "gogs"
|
||||
- name: Prepare archive contents
|
||||
run: |
|
||||
mkdir -p dist/gogs
|
||||
@@ -103,6 +151,28 @@ jobs:
|
||||
if [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
tar -czvf "${ARCHIVE_BASE}.tar.gz" gogs
|
||||
fi
|
||||
- name: Notarize macOS archive
|
||||
if: ${{ matrix.goos == 'darwin' }}
|
||||
env:
|
||||
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
|
||||
APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }}
|
||||
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
|
||||
run: |
|
||||
if [ -z "$APPLE_NOTARY_ISSUER_ID" ] || [ -z "$APPLE_NOTARY_KEY_BASE64" ] || [ -z "$APPLE_NOTARY_KEY_ID" ]; then
|
||||
echo "Missing required Apple notarization secrets." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
ARCHIVE_PATH="dist/gogs_${VERSION}_${{ matrix.goos }}_${{ matrix.goarch }}${{ matrix.suffix }}.zip"
|
||||
NOTARY_KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_NOTARY_KEY_ID}.p8"
|
||||
|
||||
printf '%s' "$APPLE_NOTARY_KEY_BASE64" | base64 -d > "$NOTARY_KEY_PATH"
|
||||
xcrun notarytool submit "$ARCHIVE_PATH" \
|
||||
--key "$NOTARY_KEY_PATH" \
|
||||
--key-id "$APPLE_NOTARY_KEY_ID" \
|
||||
--issuer "$APPLE_NOTARY_ISSUER_ID" \
|
||||
--wait
|
||||
- name: Upload to release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Web
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'web/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'conf/locale/locale_*.ini'
|
||||
- '.github/workflows/web.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'web/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'conf/locale/locale_*.ini'
|
||||
- '.github/workflows/web.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
web:
|
||||
name: Lint and build
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
MOON_COLOR: "true"
|
||||
FORCE_COLOR: "1"
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- name: Set up moon
|
||||
uses: moonrepo/setup-toolchain@261c62cb5b0f580c7be7c8cd0f023a2e96756095 # v0.6.4
|
||||
with:
|
||||
auto-install: false
|
||||
- name: Lint
|
||||
run: moon run web:lint
|
||||
- name: Build
|
||||
run: moon run web:build
|
||||
- name: Check for uncommitted changes
|
||||
run: git diff --exit-code --stat
|
||||
+5
-2
@@ -1,6 +1,10 @@
|
||||
# Build artifacts
|
||||
# Build artifacts and cache
|
||||
.bin/
|
||||
dist/
|
||||
.moon/cache/
|
||||
node_modules/
|
||||
.agents/skills/
|
||||
.claude/skills
|
||||
|
||||
# Runtime data
|
||||
log/
|
||||
@@ -9,7 +13,6 @@ data/
|
||||
|
||||
# Configuration and application files
|
||||
.idea/
|
||||
.task/
|
||||
.envrc
|
||||
|
||||
# System junk
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
$schema: "https://moonrepo.dev/schemas/tasks.json"
|
||||
|
||||
taskOptions:
|
||||
outputStyle: "stream"
|
||||
@@ -0,0 +1,9 @@
|
||||
$schema: "https://moonrepo.dev/schemas/workspace.json"
|
||||
|
||||
projects:
|
||||
gogs: "."
|
||||
web: "web"
|
||||
|
||||
vcs:
|
||||
client: "git"
|
||||
defaultBranch: "main"
|
||||
@@ -1 +0,0 @@
|
||||
main
|
||||
@@ -1 +0,0 @@
|
||||
web: ./gogs web -p ${PORT:=3000}
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
APP_NAME="gogs"
|
||||
CLI="${APP_NAME}"
|
||||
APP_USER=$(${CLI} config:get APP_USER)
|
||||
APP_GROUP=$(${CLI} config:get APP_GROUP)
|
||||
APP_CONFIG="/etc/${APP_NAME}/conf/app.ini"
|
||||
|
||||
mkdir -p "$(dirname ${APP_CONFIG})"
|
||||
chown "${APP_USER}"."${APP_GROUP}" "$(dirname ${APP_CONFIG})"
|
||||
[ -f ${APP_CONFIG} ] || ${CLI} run cp conf/app.ini ${APP_CONFIG}
|
||||
${CLI} config:set USER="${APP_USER}"
|
||||
sed -i "s|RUN_USER = git|RUN_USER = ${APP_USER}|" ${APP_CONFIG}
|
||||
sed -i "s|RUN_MODE = dev|RUN_MODE = prod|" ${APP_CONFIG}
|
||||
|
||||
${CLI} config:set GOGS_CUSTOM=/etc/${APP_NAME}
|
||||
|
||||
# scale
|
||||
${CLI} scale web=1 || true
|
||||
|
||||
# restart the service
|
||||
service gogs restart || true
|
||||
@@ -1,35 +0,0 @@
|
||||
targets:
|
||||
debian-11: &debian
|
||||
build_dependencies:
|
||||
- libpam0g-dev
|
||||
dependencies:
|
||||
- libpam0g
|
||||
- git
|
||||
debian-12:
|
||||
<<: *debian
|
||||
debian-13:
|
||||
<<: *debian
|
||||
debian-14:
|
||||
<<: *debian
|
||||
ubuntu-18.04:
|
||||
<<: *debian
|
||||
ubuntu-20.04:
|
||||
<<: *debian
|
||||
ubuntu-22.04:
|
||||
<<: *debian
|
||||
ubuntu-24.04:
|
||||
<<: *debian
|
||||
centos-9:
|
||||
build_dependencies:
|
||||
- pam-devel
|
||||
# required for Go buildpack
|
||||
- perl-Digest-SHA
|
||||
dependencies:
|
||||
- pam
|
||||
- git
|
||||
before:
|
||||
- mv .packager/Procfile .
|
||||
after:
|
||||
- mv bin/gogs gogs
|
||||
after_install: ./.packager/hooks/postinst
|
||||
buildpack: https://github.com/heroku/heroku-buildpack-go.git#main
|
||||
@@ -9,6 +9,8 @@ This applies to all texts, including but not limited to UI, documentation, code
|
||||
|
||||
- Use sentence case. Preserve original casing for brand names.
|
||||
- End with a period for a full sentence.
|
||||
- Never use em dashes (`—`) or en dashes (`–`) in prose. Rewrite the sentence with a comma, period, colon, or parentheses instead. Exception: em/en dashes are allowed as visual separators in UI design (e.g., between a title and description, in a terminal prompt label) where they function as a graphic element rather than punctuation.
|
||||
- Do not overuse semicolons. Two short sentences are almost always clearer than one sentence joined by a semicolon. Reserve the semicolon for the rare case where the two clauses are so tightly coupled that splitting them loses meaning, never as a default em-dash replacement or a way to chain related thoughts.
|
||||
- Do not add comments that repeat what the code is doing, always prefer more descriptive names. Do add comments for intentions that aren't obvious via reading the code alone. This rule takes precedence over matching existing patterns.
|
||||
|
||||
## Coding guidelines
|
||||
@@ -16,11 +18,21 @@ This applies to all texts, including but not limited to UI, documentation, code
|
||||
- Use `github.com/cockroachdb/errors` for error handling.
|
||||
- Use `github.com/stretchr/testify` for assertions in tests. Be mindful about the choice of `require` and `assert`, the former should be used when the test cannot proceed meaningfully after a failed assertion.
|
||||
|
||||
## Localization
|
||||
|
||||
- Only edit `conf/locale/locale_en-US.ini`. The other `locale_*.ini` files are community-maintained translations. Do not add, remove, or rewrite keys in them, even when removing keys that are dead on the Go/template side.
|
||||
|
||||
## UI guidelines
|
||||
|
||||
- Design mobile-friendly. Every UI must look and work well on narrow viewports before adding desktop refinements via responsive breakpoints. Test at ~375px width before considering a UI done.
|
||||
- Meet WCAG 2.2 AA at minimum. Specifically: every interactive control has a discernible accessible name (visible label or `aria-label`); color is never the sole carrier of information (pair with text, icon, or shape); text and meaningful icons meet 4.5:1 contrast against their background (3:1 for large text and UI components); focus is always visible and never trapped; touch targets are at least 24×24 CSS px (40×40 preferred). When unsure, lean toward more contrast, larger targets, and explicit labels.
|
||||
- For work under `web/`, follow the patterns in [`web/DESIGN.md`](web/DESIGN.md) (typography, color hierarchy, surface chrome, file naming, accessibility specifics). Update that doc when a pattern is used in two places.
|
||||
- When a page needs server data to render, fetch it in the TanStack Router route's `loader` so the page only mounts after the response arrives. Do not fire that fetch from a `useEffect` inside the page component, which causes a flash of empty UI before the data lands.
|
||||
|
||||
## Build instructions
|
||||
|
||||
- Prefer `task` command over vanilla `go` command when available. Use `--force` flag when necessary.
|
||||
- Run `task lint` after every time you finish changing code, and fix all linter errors.
|
||||
- Run `go mod tidy` after every time you change `go.mod`, do not manually edit `go.sum` file.
|
||||
- Prefer `moon run <project>:<task>` over vanilla `go` or `pnpm` commands when available (e.g. `moon run gogs:build`, `moon run web:dev`). Pass `--force` to bypass cache when necessary.
|
||||
- Run `moon run gogs:lint` after every time you finish changing Go code, and `moon run web:lint` after changing frontend code; fix all linter errors.
|
||||
|
||||
## Tool-use guidance
|
||||
|
||||
@@ -31,3 +43,4 @@ This applies to all texts, including but not limited to UI, documentation, code
|
||||
- When pushing changes to a pull request from a fork, use SSH address and do not add remote.
|
||||
- Never commit on the `main` branch directly unless being explicitly asked to do so. A single ask only grants a single commit action on the `main` branch.
|
||||
- Never amend commits unless being explicitly asked to do so.
|
||||
- When creating a git worktree, the worktree directory name must match its branch name. Do not use random or generated suffixes.
|
||||
|
||||
+14
-1
@@ -4,11 +4,24 @@ All notable changes to Gogs are documented in this file.
|
||||
|
||||
## 0.15.0+dev (`main`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Docker builds from `main` are now published only as `gogs/gogs:edge`, using the next-generation `Dockerfile.next`. The legacy `Dockerfile` no longer produces `main` builds. The `gogs/gogs:latest` and `gogs/gogs:next-latest` tags now always point to the highest published stable release, never to a back-patch on an older line. [#8278](https://github.com/gogs/gogs/pull/8278)
|
||||
|
||||
### Fixed
|
||||
|
||||
- _Security:_ Denial of service in repository and wiki file listing pages via crafted file names. [#8116](https://github.com/gogs/gogs/pull/8116) - [GHSA-3qq3-668m-v9mj](https://github.com/gogs/gogs/security/advisories/GHSA-3qq3-668m-v9mj)
|
||||
- _Security:_ Reverse proxy authentication header was honored from any remote address, allowing user impersonation when Gogs was reachable directly. The header is now only trusted from addresses listed in `[auth] TRUSTED_PROXY_IPS`. [#8264](https://github.com/gogs/gogs/pull/8264) - [GHSA-w6j9-vw59-27wv](https://github.com/gogs/gogs/security/advisories/GHSA-w6j9-vw59-27wv)
|
||||
- _Security:_ Server-side request forgery in webhook deliveries via HTTP redirects to local network addresses. [#8263](https://github.com/gogs/gogs/pull/8263) - [GHSA-c4v7-xg93-qf8g](https://github.com/gogs/gogs/security/advisories/GHSA-c4v7-xg93-qf8g)
|
||||
- _Security:_ The "remember me" auto-login cookie was derived from database columns, so an attacker with a database dump could forge a valid cookie for any user. The auto-login cookie path has been removed entirely. Persistence is now provided by the server-issued session cookie. [#8289](https://github.com/gogs/gogs/pull/8289) - [GHSA-4pph-25p3-pw73](https://github.com/gogs/gogs/security/advisories/GHSA-4pph-25p3-pw73)
|
||||
|
||||
### Removed
|
||||
|
||||
- The `gogs cert` subcommand. [#8153](https://github.com/gogs/gogs/pull/8153)
|
||||
- The `[email] DISABLE_HELO` configuration option. HELO/EHLO is now always sent during SMTP handshake. [#8164](https://github.com/gogs/gogs/pull/8164)
|
||||
- Support for MSSQL as a database backend. Stay on 0.14 for continued usage. [#8173](https://github.com/gogs/gogs/pull/8173)
|
||||
- Support for MSSQL as the database backend. Stay on 0.14 for continued usage. [#8173](https://github.com/gogs/gogs/pull/8173)
|
||||
- Support for `memcache` as the cache adapter. Stay on 0.14 for continued usage. [#8270](https://github.com/gogs/gogs/pull/8270)
|
||||
- The `/debug`, `/debug/pprof/*`, `/debug/profile/*`, and `/urlmap.json` endpoints. [#8271](https://github.com/gogs/gogs/pull/8271)
|
||||
|
||||
## 0.14.2
|
||||
|
||||
|
||||
+13
-2
@@ -1,3 +1,12 @@
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS webbuilder
|
||||
RUN corepack enable
|
||||
WORKDIR /src
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY web ./web
|
||||
COPY conf/locale ./conf/locale
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter gogs-web run build
|
||||
|
||||
FROM golang:1.26-alpine3.23 AS binarybuilder
|
||||
RUN apk --no-cache --no-progress add --virtual \
|
||||
build-deps \
|
||||
@@ -7,9 +16,11 @@ RUN apk --no-cache --no-progress add --virtual \
|
||||
|
||||
WORKDIR /gogs.io/gogs
|
||||
COPY . .
|
||||
COPY --from=webbuilder /src/public/dist ./public/dist
|
||||
|
||||
RUN ./docker/build/install-task.sh
|
||||
RUN TAGS="cert pam" task build
|
||||
RUN go build -v -trimpath -tags "pam prod" \
|
||||
-ldflags "-X 'gogs.io/gogs/internal/conf.BuildTime=$(date -u '+%Y-%m-%d %I:%M:%S %Z')' -X 'gogs.io/gogs/internal/conf.BuildCommit=$(git rev-parse HEAD)'" \
|
||||
-o .bin/gogs ./cmd/gogs
|
||||
|
||||
FROM alpine:3.23
|
||||
RUN apk --no-cache --no-progress add \
|
||||
|
||||
+13
-2
@@ -1,3 +1,12 @@
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS webbuilder
|
||||
RUN corepack enable
|
||||
WORKDIR /src
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY web ./web
|
||||
COPY conf/locale ./conf/locale
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter gogs-web run build
|
||||
|
||||
FROM golang:1.26-alpine3.23 AS binarybuilder
|
||||
RUN apk --no-cache --no-progress add --virtual \
|
||||
build-deps \
|
||||
@@ -7,9 +16,11 @@ RUN apk --no-cache --no-progress add --virtual \
|
||||
|
||||
WORKDIR /gogs.io/gogs
|
||||
COPY . .
|
||||
COPY --from=webbuilder /src/public/dist ./public/dist
|
||||
|
||||
RUN ./docker/build/install-task.sh
|
||||
RUN TAGS="cert pam" task build
|
||||
RUN go build -v -trimpath -tags "pam prod" \
|
||||
-ldflags "-X 'gogs.io/gogs/internal/conf.BuildTime=$(date -u '+%Y-%m-%d %I:%M:%S %Z')' -X 'gogs.io/gogs/internal/conf.BuildCommit=$(git rev-parse HEAD)'" \
|
||||
-o .bin/gogs ./cmd/gogs
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||

|
||||
|
||||
[](https://github.com/gogs/gogs/actions?query=branch%3Amain) [](https://sourcegraph.com/github.com/gogs/gogs)
|
||||
|
||||
👉 Deploy on DigitalOcean and [get $200 in free credits](https://m.do.co/c/5aeb02268b55)!
|
||||
<p>
|
||||
<div align="center">
|
||||
<img src="docs/images/logo-light.svg#gh-light-mode-only" alt="Gogs">
|
||||
<img src="docs/images/logo-dark.svg#gh-dark-mode-only" alt="Gogs">
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://github.com/gogs/gogs/actions?query=branch%3Amain"><img
|
||||
src="https://img.shields.io/github/checks-status/gogs/gogs/main?logo=github&style=for-the-badge" alt="GitHub Workflow Status"></a>
|
||||
<a href="https://sourcegraph.com/github.com/gogs/gogs"><img
|
||||
src="https://img.shields.io/badge/view%20on-Sourcegraph-brightgreen.svg?style=for-the-badge&logo=sourcegraph" alt="Sourcegraph"></a>
|
||||
</div>
|
||||
<div align="center">
|
||||
👉 Deploy on DigitalOcean and <a href="https://m.do.co/c/5aeb02268b55">get $200 in free credits</a>!
|
||||
</div>
|
||||
</p>
|
||||
|
||||
## 🔮 Vision
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
BINARY_EXT:
|
||||
sh: echo '{{if eq OS "windows"}}.exe{{end}}'
|
||||
|
||||
tasks:
|
||||
default:
|
||||
deps: [build]
|
||||
web:
|
||||
desc: Build the binary and start the web server
|
||||
deps: [build]
|
||||
cmds:
|
||||
- .bin/gogs web
|
||||
|
||||
build:
|
||||
desc: Build the binary
|
||||
cmds:
|
||||
- go build -v
|
||||
-ldflags '
|
||||
-X "{{.PKG_PATH}}.BuildTime={{.BUILD_TIME}}"
|
||||
-X "{{.PKG_PATH}}.BuildCommit={{.BUILD_COMMIT}}"
|
||||
'
|
||||
-tags '{{.TAGS}}'
|
||||
-trimpath -o .bin/gogs{{.BINARY_EXT}} ./cmd/gogs
|
||||
vars:
|
||||
PKG_PATH: gogs.io/gogs/internal/conf
|
||||
BUILD_TIME:
|
||||
sh: date -u '+%Y-%m-%d %I:%M:%S %Z'
|
||||
BUILD_COMMIT:
|
||||
sh: git rev-parse HEAD
|
||||
sources:
|
||||
- go.mod
|
||||
- cmd/gogs/*.go
|
||||
- internal/**/*.go
|
||||
- conf/**/*
|
||||
- public/**/*
|
||||
- templates/**/*
|
||||
- custom/**/*
|
||||
method: timestamp
|
||||
|
||||
generate-schemadoc:
|
||||
desc: Generate database schema documentation
|
||||
cmds:
|
||||
- go generate ./internal/database/schemadoc
|
||||
|
||||
generate:
|
||||
desc: Run all go:generate commands
|
||||
cmds:
|
||||
- go generate ./...
|
||||
|
||||
test:
|
||||
desc: Run all tests.
|
||||
cmds:
|
||||
- go test -cover -race ./...
|
||||
|
||||
clean:
|
||||
desc: Cleans up system meta files
|
||||
cmds:
|
||||
- find . -name "*.DS_Store" -type f -delete
|
||||
|
||||
less:
|
||||
desc: Generate CSS from LESS files
|
||||
cmds:
|
||||
- lessc --clean-css --source-map "public/less/gogs.less" public/css/gogs.min.css
|
||||
|
||||
fixme:
|
||||
desc: Show all occurrences of "FIXME"
|
||||
cmds:
|
||||
- grep -rnw "FIXME" internal
|
||||
|
||||
todo:
|
||||
desc: Show all occurrences of "TODO"
|
||||
cmds:
|
||||
- grep -rnw "TODO" internal
|
||||
|
||||
legacy:
|
||||
desc: Identify legacy and deprecated lines
|
||||
cmds:
|
||||
- grep -rnw "\(LEGACY\|Deprecated\)" internal
|
||||
|
||||
drop-test-db:
|
||||
desc: Drop the test database
|
||||
cmds:
|
||||
- |
|
||||
for dbname in $(psql -Xc "copy (select datname from pg_database where datname like 'gogs-%') to stdout"); do
|
||||
dropdb "$dbname"
|
||||
echo "dropped $dbname"
|
||||
done
|
||||
|
||||
lint:
|
||||
desc: Run all linters
|
||||
cmds:
|
||||
- golangci-lint run
|
||||
|
||||
docs:
|
||||
desc: Start docs server
|
||||
cmds:
|
||||
- cd docs && mint dev --port 3333
|
||||
@@ -30,6 +30,19 @@ func configFromLineage(cmd *cli.Command) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func intFlag(name string, value int, usage string) *cli.IntFlag {
|
||||
parts := strings.SplitN(name, ", ", 2)
|
||||
f := &cli.IntFlag{
|
||||
Name: parts[0],
|
||||
Value: value,
|
||||
Usage: usage,
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
f.Aliases = []string{parts[1]}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func boolFlag(name, usage string) *cli.BoolFlag {
|
||||
parts := strings.SplitN(name, ", ", 2)
|
||||
f := &cli.BoolFlag{
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/cache"
|
||||
"github.com/flamego/cache/redis"
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/strx"
|
||||
)
|
||||
|
||||
func parseCacheOptions(confOpts conf.CacheOptions) (cache.Options, error) {
|
||||
opts := cache.Options{
|
||||
GCInterval: time.Duration(confOpts.Interval) * time.Second,
|
||||
}
|
||||
|
||||
switch strx.Coalesce(strings.ToLower(confOpts.Adapter), "memory") {
|
||||
case "memory":
|
||||
opts.Initer = cache.MemoryIniter()
|
||||
case "file":
|
||||
opts.Initer = cache.FileIniter()
|
||||
opts.Config = cache.FileConfig{RootDir: confOpts.Host}
|
||||
case "redis":
|
||||
cfg, err := parseRedisConfig(confOpts.Host)
|
||||
if err != nil {
|
||||
return cache.Options{}, errors.Wrap(err, "parse redis config")
|
||||
}
|
||||
opts.Initer = redis.Initer()
|
||||
opts.Config = cfg
|
||||
default:
|
||||
return cache.Options{}, errors.Errorf("unsupported adapter %q", confOpts.Adapter)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func parseRedisConfig(host string) (redis.Config, error) {
|
||||
cfg, err := ini.Load([]byte(strings.ReplaceAll(host, ",", "\n")))
|
||||
if err != nil {
|
||||
return redis.Config{}, errors.Wrap(err, "load HOST")
|
||||
}
|
||||
|
||||
var config redis.Config
|
||||
for k, v := range cfg.Section("").KeysHash() {
|
||||
switch k {
|
||||
case "network":
|
||||
config.Options.Network = v
|
||||
case "addr":
|
||||
config.Options.Addr = v
|
||||
case "password":
|
||||
config.Options.Password = v
|
||||
case "db":
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return redis.Config{}, errors.Wrapf(err, "parse db %q", v)
|
||||
}
|
||||
config.Options.DB = n
|
||||
case "pool_size":
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return redis.Config{}, errors.Wrapf(err, "parse pool_size %q", v)
|
||||
}
|
||||
config.Options.PoolSize = n
|
||||
case "idle_timeout":
|
||||
d, err := time.ParseDuration(v + "s")
|
||||
if err != nil {
|
||||
return redis.Config{}, errors.Wrapf(err, "parse idle_timeout %q", v)
|
||||
}
|
||||
config.Options.ConnMaxIdleTime = d
|
||||
case "prefix":
|
||||
config.KeyPrefix = v
|
||||
case "tls":
|
||||
// Matches go-macaron/session/redis: any non-empty `tls=` value enables
|
||||
// TLS with InsecureSkipVerify.
|
||||
config.Options.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
case "hset_name":
|
||||
// Macaron stored values in a single Redis hash named by this key,
|
||||
// whereas Flamego stores per-key with KeyPrefix, so this knob has no equivalent.
|
||||
default:
|
||||
return redis.Config{}, errors.Errorf("unsupported redis HOST key %q", k)
|
||||
}
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package main
|
||||
package web
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -10,18 +10,20 @@ import (
|
||||
"net/http/fcgi"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/cache"
|
||||
"github.com/flamego/captcha"
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/go-macaron/binding"
|
||||
"github.com/go-macaron/cache"
|
||||
"github.com/go-macaron/captcha"
|
||||
macaroncache "github.com/go-macaron/cache"
|
||||
"github.com/go-macaron/csrf"
|
||||
"github.com/go-macaron/gzip"
|
||||
"github.com/go-macaron/i18n"
|
||||
"github.com/go-macaron/session"
|
||||
"github.com/go-macaron/toolbox"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/urfave/cli/v3"
|
||||
"gopkg.in/macaron.v1"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
@@ -35,141 +37,35 @@ import (
|
||||
"gogs.io/gogs/internal/route"
|
||||
"gogs.io/gogs/internal/route/admin"
|
||||
apiv1 "gogs.io/gogs/internal/route/api/v1"
|
||||
"gogs.io/gogs/internal/route/dev"
|
||||
"gogs.io/gogs/internal/route/lfs"
|
||||
"gogs.io/gogs/internal/route/org"
|
||||
"gogs.io/gogs/internal/route/repo"
|
||||
"gogs.io/gogs/internal/route/user"
|
||||
"gogs.io/gogs/internal/template"
|
||||
"gogs.io/gogs/internal/urlx"
|
||||
"gogs.io/gogs/public"
|
||||
"gogs.io/gogs/templates"
|
||||
)
|
||||
|
||||
var webCommand = cli.Command{
|
||||
Name: "web",
|
||||
Usage: "Start web server",
|
||||
Description: `Gogs web server is the only thing you need to run,
|
||||
and it takes care of all the other things for you`,
|
||||
Action: runWeb,
|
||||
Flags: []cli.Flag{
|
||||
stringFlag("port, p", "3000", "Temporary port number to prevent conflict"),
|
||||
stringFlag("config, c", filepath.Join(conf.CustomDir(), "conf", "app.ini"), "Custom configuration file path"),
|
||||
},
|
||||
}
|
||||
|
||||
// newMacaron initializes Macaron instance.
|
||||
func newMacaron() *macaron.Macaron {
|
||||
m := macaron.New()
|
||||
if !conf.Server.DisableRouterLog {
|
||||
m.Use(macaron.Logger())
|
||||
}
|
||||
m.Use(macaron.Recovery())
|
||||
if conf.Server.EnableGzip {
|
||||
m.Use(gzip.Gziper())
|
||||
}
|
||||
if conf.Server.Protocol == "fcgi" {
|
||||
m.SetURLPrefix(conf.Server.Subpath)
|
||||
}
|
||||
|
||||
// Register custom middleware first to make it possible to override files under "public".
|
||||
m.Use(macaron.Static(
|
||||
filepath.Join(conf.CustomDir(), "public"),
|
||||
macaron.StaticOptions{
|
||||
SkipLogging: conf.Server.DisableRouterLog,
|
||||
},
|
||||
))
|
||||
var publicFs http.FileSystem
|
||||
if !conf.Server.LoadAssetsFromDisk {
|
||||
publicFs = http.FS(public.Files)
|
||||
}
|
||||
m.Use(macaron.Static(
|
||||
filepath.Join(conf.WorkDir(), "public"),
|
||||
macaron.StaticOptions{
|
||||
ETag: true,
|
||||
SkipLogging: conf.Server.DisableRouterLog,
|
||||
FileSystem: publicFs,
|
||||
},
|
||||
))
|
||||
|
||||
m.Use(macaron.Static(
|
||||
conf.Picture.AvatarUploadPath,
|
||||
macaron.StaticOptions{
|
||||
ETag: true,
|
||||
Prefix: conf.UsersAvatarPathPrefix,
|
||||
SkipLogging: conf.Server.DisableRouterLog,
|
||||
},
|
||||
))
|
||||
m.Use(macaron.Static(
|
||||
conf.Picture.RepositoryAvatarUploadPath,
|
||||
macaron.StaticOptions{
|
||||
ETag: true,
|
||||
Prefix: database.RepoAvatarURLPrefix,
|
||||
SkipLogging: conf.Server.DisableRouterLog,
|
||||
},
|
||||
))
|
||||
|
||||
customDir := filepath.Join(conf.CustomDir(), "templates")
|
||||
renderOpt := macaron.RenderOptions{
|
||||
Directory: filepath.Join(conf.WorkDir(), "templates"),
|
||||
AppendDirectories: []string{customDir},
|
||||
Funcs: template.FuncMap(),
|
||||
IndentJSON: macaron.Env != macaron.PROD,
|
||||
}
|
||||
if !conf.Server.LoadAssetsFromDisk {
|
||||
renderOpt.TemplateFileSystem = templates.NewTemplateFileSystem("", customDir)
|
||||
}
|
||||
m.Use(macaron.Renderer(renderOpt))
|
||||
|
||||
localeNames, err := embedConf.FileNames("locale")
|
||||
// Run starts the web server with the given configuration path and port override.
|
||||
func Run(configPath string, portOverride int) error {
|
||||
err := route.GlobalInit(configPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to list locale files: %v", err)
|
||||
return errors.Wrap(err, "initialize application")
|
||||
}
|
||||
localeFiles := make(map[string][]byte)
|
||||
for _, name := range localeNames {
|
||||
localeFiles[name], err = embedConf.Files.ReadFile("locale/" + name)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to read locale file %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
m.Use(i18n.I18n(i18n.Options{
|
||||
SubURL: conf.Server.Subpath,
|
||||
Files: localeFiles,
|
||||
CustomDirectory: filepath.Join(conf.CustomDir(), "conf", "locale"),
|
||||
Langs: conf.I18n.Langs,
|
||||
Names: conf.I18n.Names,
|
||||
DefaultLang: "en-US",
|
||||
Redirect: true,
|
||||
}))
|
||||
m.Use(cache.Cacher(cache.Options{
|
||||
Adapter: conf.Cache.Adapter,
|
||||
AdapterConfig: conf.Cache.Host,
|
||||
Interval: conf.Cache.Interval,
|
||||
}))
|
||||
m.Use(captcha.Captchaer(captcha.Options{
|
||||
SubURL: conf.Server.Subpath,
|
||||
}))
|
||||
m.Use(toolbox.Toolboxer(m, toolbox.Options{
|
||||
HealthCheckFuncs: []*toolbox.HealthCheckFuncDesc{
|
||||
{
|
||||
Desc: "Database connection",
|
||||
Func: database.Ping,
|
||||
},
|
||||
},
|
||||
}))
|
||||
return m
|
||||
}
|
||||
|
||||
func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
err := route.GlobalInit(configFromLineage(cmd))
|
||||
m, err := newMacaron()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize application: %v", err)
|
||||
return errors.Wrap(err, "initialize macaron")
|
||||
}
|
||||
|
||||
m := newMacaron()
|
||||
webHandler, err := newRoutingHandler()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "initialize web handler")
|
||||
}
|
||||
|
||||
reqSignIn := context.Toggle(&context.ToggleOptions{SignInRequired: true})
|
||||
ignSignIn := context.Toggle(&context.ToggleOptions{SignInRequired: conf.Auth.RequireSigninView})
|
||||
reqSignOut := context.Toggle(&context.ToggleOptions{SignOutRequired: true})
|
||||
|
||||
bindIgnErr := binding.BindIgnErr
|
||||
|
||||
@@ -190,20 +86,6 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
m.Get("/^:type(issues|pulls)$", reqSignIn, user.Issues)
|
||||
|
||||
// ***** START: User *****
|
||||
m.Group("/user", func() {
|
||||
m.Group("/login", func() {
|
||||
m.Combo("").Get(user.Login).
|
||||
Post(bindIgnErr(form.SignIn{}), user.LoginPost)
|
||||
m.Combo("/two_factor").Get(user.LoginTwoFactor).Post(user.LoginTwoFactorPost)
|
||||
m.Combo("/two_factor_recovery_code").Get(user.LoginTwoFactorRecoveryCode).Post(user.LoginTwoFactorRecoveryCodePost)
|
||||
})
|
||||
|
||||
m.Get("/sign_up", user.SignUp)
|
||||
m.Post("/sign_up", bindIgnErr(form.Register{}), user.SignUpPost)
|
||||
m.Get("/reset_password", user.ResetPasswd)
|
||||
m.Post("/reset_password", user.ResetPasswdPost)
|
||||
}, reqSignOut)
|
||||
|
||||
m.Group("/user/settings", func() {
|
||||
m.Get("", user.Settings)
|
||||
m.Post("", bindIgnErr(form.UpdateProfile{}), user.SettingsPost)
|
||||
@@ -245,12 +127,8 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
})
|
||||
|
||||
m.Group("/user", func() {
|
||||
m.Any("/activate", user.Activate)
|
||||
m.Any("/activate_email", user.ActivateEmail)
|
||||
m.Get("/email2user", user.Email2User)
|
||||
m.Get("/forget_password", user.ForgotPasswd)
|
||||
m.Post("/forget_password", user.ForgotPasswdPost)
|
||||
m.Post("/logout", user.SignOut)
|
||||
})
|
||||
// ***** END: User *****
|
||||
|
||||
@@ -340,10 +218,6 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
m.Post("/action/:action", user.Action)
|
||||
}, reqSignIn, context.InjectParamsUser())
|
||||
|
||||
if macaron.Env == macaron.DEV {
|
||||
m.Get("/template/*", dev.TemplatePreview)
|
||||
}
|
||||
|
||||
reqRepoAdmin := context.RequireRepoAdmin()
|
||||
reqRepoWriter := context.RequireRepoWriter()
|
||||
|
||||
@@ -634,6 +508,11 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
m.Group("/api", func() {
|
||||
apiv1.RegisterRoutes(m)
|
||||
}, ignSignIn)
|
||||
|
||||
m.Any("/api/web/*", flamegoBridger(webHandler))
|
||||
m.Get("/redirect", flamegoBridger(webHandler))
|
||||
m.Get("/captcha/*", flamegoBridger(webHandler))
|
||||
m.Any("/*", func(c *context.Context) { c.ServeWeb() })
|
||||
},
|
||||
session.Sessioner(session.Options{
|
||||
Provider: conf.Session.Provider,
|
||||
@@ -643,6 +522,7 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
Gclifetime: conf.Session.GCInterval,
|
||||
Maxlifetime: conf.Session.MaxLifeTime,
|
||||
Secure: conf.Session.CookieSecure,
|
||||
CookieLifeTime: 86400 * conf.Security.LoginRememberDays,
|
||||
}),
|
||||
csrf.Csrfer(csrf.Options{
|
||||
Secret: conf.Security.SecretKey,
|
||||
@@ -654,7 +534,7 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
SetCookie: true,
|
||||
Secure: conf.Server.URL.Scheme == "https",
|
||||
}),
|
||||
context.Contexter(context.NewStore()),
|
||||
context.Contexter(context.NewStore(), webHandler),
|
||||
)
|
||||
|
||||
// ***************************
|
||||
@@ -668,7 +548,18 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
lfs.RegisterRoutes(m.Router)
|
||||
})
|
||||
|
||||
m.Route("/*", "GET,POST,OPTIONS", context.ServeGoGet(), repo.HTTPContexter(repo.NewStore()), repo.HTTP)
|
||||
gitHTTP := []macaron.Handler{context.ServeGoGet(), repo.HTTPContexter(repo.NewStore()), repo.HTTP}
|
||||
m.Route("/info/refs", "GET,OPTIONS", gitHTTP...)
|
||||
m.Route("/HEAD", "GET,OPTIONS", gitHTTP...)
|
||||
m.Route("/git-upload-pack", "POST,OPTIONS", gitHTTP...)
|
||||
m.Route("/git-receive-pack", "POST,OPTIONS", gitHTTP...)
|
||||
m.Route("/objects/info/alternates", "GET,OPTIONS", gitHTTP...)
|
||||
m.Route("/objects/info/http-alternates", "GET,OPTIONS", gitHTTP...)
|
||||
m.Route("/objects/info/packs", "GET,OPTIONS", gitHTTP...)
|
||||
m.Route("/objects/info/*", "GET,OPTIONS", gitHTTP...)
|
||||
m.Route("/objects/:prefix([0-9a-f]{2})/:suffix([0-9a-f]{38})", "GET,OPTIONS", gitHTTP...)
|
||||
m.Route("/objects/pack/pack-:sha([0-9a-f]{40}).pack", "GET,OPTIONS", gitHTTP...)
|
||||
m.Route("/objects/pack/pack-:sha([0-9a-f]{40}).idx", "GET,OPTIONS", gitHTTP...)
|
||||
})
|
||||
|
||||
// ***************************
|
||||
@@ -695,20 +586,19 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
}
|
||||
})
|
||||
|
||||
m.NotFound(route.NotFound)
|
||||
|
||||
// Flag for port number in case first time run conflict.
|
||||
if cmd.IsSet("port") {
|
||||
conf.Server.URL.Host = strings.Replace(conf.Server.URL.Host, ":"+conf.Server.URL.Port(), ":"+cmd.String("port"), 1)
|
||||
if portOverride > 0 {
|
||||
port := strconv.Itoa(portOverride)
|
||||
conf.Server.URL.Host = strings.Replace(conf.Server.URL.Host, ":"+conf.Server.URL.Port(), ":"+port, 1)
|
||||
conf.Server.ExternalURL = conf.Server.URL.String()
|
||||
conf.Server.HTTPPort = cmd.String("port")
|
||||
conf.Server.HTTPPort = portOverride
|
||||
}
|
||||
|
||||
var listenAddr string
|
||||
if conf.Server.Protocol == "unix" {
|
||||
listenAddr = conf.Server.HTTPAddr
|
||||
} else {
|
||||
listenAddr = fmt.Sprintf("%s:%s", conf.Server.HTTPAddr, conf.Server.HTTPPort)
|
||||
listenAddr = fmt.Sprintf("%s:%d", conf.Server.HTTPAddr, conf.Server.HTTPPort)
|
||||
}
|
||||
log.Info("Available on %s", conf.Server.ExternalURL)
|
||||
|
||||
@@ -753,30 +643,207 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
|
||||
if osx.Exist(listenAddr) {
|
||||
err = os.Remove(listenAddr)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to remove existing Unix domain socket: %v", err)
|
||||
return errors.Wrap(err, "remove existing Unix domain socket")
|
||||
}
|
||||
}
|
||||
|
||||
var listener *net.UnixListener
|
||||
listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: listenAddr, Net: "unix"})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to listen on Unix networks: %v", err)
|
||||
return errors.Wrap(err, "listen on Unix network")
|
||||
}
|
||||
|
||||
// FIXME: add proper implementation of signal capture on all protocols
|
||||
// execute this on SIGTERM or SIGINT: listener.Close()
|
||||
if err = os.Chmod(listenAddr, conf.Server.UnixSocketMode); err != nil {
|
||||
log.Fatal("Failed to change permission of Unix domain socket: %v", err)
|
||||
return errors.Wrap(err, "change permission of Unix domain socket")
|
||||
}
|
||||
err = http.Serve(listener, m)
|
||||
|
||||
default:
|
||||
log.Fatal("Unexpected server protocol: %s", conf.Server.Protocol)
|
||||
return errors.Newf("unexpected server protocol: %s", conf.Server.Protocol)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatal("Failed to start server: %v", err)
|
||||
return errors.Wrap(err, "start server")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRoutingHandler() (http.Handler, error) {
|
||||
f := flamego.New()
|
||||
f.Use(flamego.Recovery())
|
||||
f.Use(flamegoInjector)
|
||||
f.Use(captcha.Captchaer(captcha.Options{URLPrefix: "/captcha/"}))
|
||||
|
||||
cacherOpts, err := parseCacheOptions(conf.Cache)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "parse cache options")
|
||||
}
|
||||
f.Use(cache.Cacher(cacherOpts))
|
||||
|
||||
f.Get("/redirect", getRedirect)
|
||||
|
||||
// The captcha middleware writes the image response itself when the request path
|
||||
// matches its URLPrefix. This route just needs to exist so the request reaches
|
||||
// the middleware chain.
|
||||
f.Get("/captcha/image.jpeg", func() {})
|
||||
|
||||
mountWebAPIRoutes(f)
|
||||
err = mountWebAppRoutes(f)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "mount web app routes")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func getRedirect(c flamego.Context) {
|
||||
to := c.Request().URL.Query().Get("to")
|
||||
if !urlx.IsSameSite(to) {
|
||||
to = conf.Server.Subpath + "/"
|
||||
}
|
||||
c.Redirect(to, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// newMacaron initializes Macaron instance.
|
||||
func newMacaron() (*macaron.Macaron, error) {
|
||||
m := macaron.New()
|
||||
if !conf.Server.DisableRouterLog {
|
||||
m.Use(macaron.Logger())
|
||||
}
|
||||
m.Use(macaron.Recovery())
|
||||
if conf.Server.EnableGzip {
|
||||
m.Use(gzip.Gziper())
|
||||
}
|
||||
if conf.Server.Protocol == "fcgi" {
|
||||
m.SetURLPrefix(conf.Server.Subpath)
|
||||
}
|
||||
|
||||
// Register custom middleware first to make it possible to override files under "public".
|
||||
m.Use(macaron.Static(
|
||||
filepath.Join(conf.CustomDir(), "public"),
|
||||
macaron.StaticOptions{
|
||||
SkipLogging: conf.Server.DisableRouterLog,
|
||||
},
|
||||
))
|
||||
var publicFs http.FileSystem
|
||||
if !conf.Server.LoadAssetsFromDisk {
|
||||
publicFs = http.FS(public.Files)
|
||||
}
|
||||
m.Use(macaron.Static(
|
||||
filepath.Join(conf.WorkDir(), "public"),
|
||||
macaron.StaticOptions{
|
||||
ETag: true,
|
||||
SkipLogging: conf.Server.DisableRouterLog,
|
||||
FileSystem: publicFs,
|
||||
},
|
||||
))
|
||||
|
||||
m.Use(macaron.Static(
|
||||
conf.Picture.AvatarUploadPath,
|
||||
macaron.StaticOptions{
|
||||
ETag: true,
|
||||
Prefix: conf.UsersAvatarPathPrefix,
|
||||
SkipLogging: conf.Server.DisableRouterLog,
|
||||
},
|
||||
))
|
||||
m.Use(macaron.Static(
|
||||
conf.Picture.RepositoryAvatarUploadPath,
|
||||
macaron.StaticOptions{
|
||||
ETag: true,
|
||||
Prefix: database.RepoAvatarURLPrefix,
|
||||
SkipLogging: conf.Server.DisableRouterLog,
|
||||
},
|
||||
))
|
||||
|
||||
customDir := filepath.Join(conf.CustomDir(), "templates")
|
||||
renderOpt := macaron.RenderOptions{
|
||||
Directory: filepath.Join(conf.WorkDir(), "templates"),
|
||||
AppendDirectories: []string{customDir},
|
||||
Funcs: template.FuncMap(),
|
||||
IndentJSON: macaron.Env != macaron.PROD,
|
||||
}
|
||||
if !conf.Server.LoadAssetsFromDisk {
|
||||
renderOpt.TemplateFileSystem = templates.NewTemplateFileSystem("", customDir)
|
||||
}
|
||||
m.Use(macaron.Renderer(renderOpt))
|
||||
|
||||
localeNames, err := embedConf.FileNames("locale")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "list locale files")
|
||||
}
|
||||
localeFiles := make(map[string][]byte)
|
||||
for _, name := range localeNames {
|
||||
localeFiles[name], err = embedConf.Files.ReadFile("locale/" + name)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "read locale file %q", name)
|
||||
}
|
||||
}
|
||||
m.Use(i18n.I18n(i18n.Options{
|
||||
SubURL: conf.Server.Subpath,
|
||||
Files: localeFiles,
|
||||
CustomDirectory: filepath.Join(conf.CustomDir(), "conf", "locale"),
|
||||
Langs: conf.I18n.Langs,
|
||||
Names: conf.I18n.Names,
|
||||
DefaultLang: "en-US",
|
||||
Redirect: true,
|
||||
}))
|
||||
m.Use(macaroncache.Cacher(macaroncache.Options{
|
||||
Adapter: conf.Cache.Adapter,
|
||||
AdapterConfig: conf.Cache.Host,
|
||||
Interval: conf.Cache.Interval,
|
||||
}))
|
||||
m.Route("/healthcheck", http.MethodHead+","+http.MethodGet, healthCheck)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// renderIndex returns the index.html shell with per-request substitutions
|
||||
// applied for the given WebContext.
|
||||
func renderIndex(index []byte, wc context.WebContext) ([]byte, error) {
|
||||
// json.Marshal escapes <, >, and & to their \uXXXX forms by default, so
|
||||
// the payload cannot break out of the surrounding <script> with "</script>"
|
||||
// even if a field carries attacker-influenced text.
|
||||
payload, err := json.Marshal(struct {
|
||||
Lang string `json:"lang"`
|
||||
SubURL string `json:"subURL"`
|
||||
}{
|
||||
Lang: wc.Lang,
|
||||
SubURL: wc.SubURL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "marshal web context")
|
||||
}
|
||||
script := `<script>window.__webContext=` + string(payload) +
|
||||
`;document.documentElement.lang=window.__webContext.lang;</script>`
|
||||
|
||||
pairs := []string{
|
||||
"{{.WebContext}}", script,
|
||||
}
|
||||
if wc.SubURL != "" {
|
||||
// Vite bakes absolute root paths into the bundle output. Prefix them
|
||||
// with the subpath so they resolve correctly under non-root mounts.
|
||||
pairs = append(pairs,
|
||||
`src="/assets/`, `src="`+wc.SubURL+`/assets/`,
|
||||
`href="/assets/`, `href="`+wc.SubURL+`/assets/`,
|
||||
`src="/src/`, `src="`+wc.SubURL+`/src/`,
|
||||
`href="/img/`, `href="`+wc.SubURL+`/img/`,
|
||||
)
|
||||
}
|
||||
return []byte(strings.NewReplacer(pairs...).Replace(string(index))), nil
|
||||
}
|
||||
|
||||
func healthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if err := database.Ping(); err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintf(w, "* Database connection: %s\n", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("* Database connection: OK\n"))
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/binding"
|
||||
"github.com/flamego/cache"
|
||||
"github.com/flamego/captcha"
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/flamego/session"
|
||||
"github.com/flamego/validator"
|
||||
"github.com/go-macaron/i18n"
|
||||
macaronsession "github.com/go-macaron/session"
|
||||
"gopkg.in/macaron.v1"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
"gogs.io/gogs/internal/auth"
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/context"
|
||||
"gogs.io/gogs/internal/database"
|
||||
"gogs.io/gogs/internal/email"
|
||||
"gogs.io/gogs/internal/tool"
|
||||
"gogs.io/gogs/internal/userx"
|
||||
)
|
||||
|
||||
type (
|
||||
webAPIUserKey struct{}
|
||||
webAPISessionKey struct{}
|
||||
webAPIMacaronKey struct{}
|
||||
webAPILocaleKey struct{}
|
||||
)
|
||||
|
||||
func flamegoBridger(webHandler http.Handler) func(c *context.Context, l i18n.Locale) {
|
||||
return func(c *context.Context, l i18n.Locale) {
|
||||
ctx := c.Req.Context()
|
||||
ctx = stdctx.WithValue(ctx, webAPIUserKey{}, c.User)
|
||||
ctx = stdctx.WithValue(ctx, webAPISessionKey{}, c.Session)
|
||||
ctx = stdctx.WithValue(ctx, webAPIMacaronKey{}, c.Context)
|
||||
ctx = stdctx.WithValue(ctx, webAPILocaleKey{}, l)
|
||||
webHandler.ServeHTTP(c.Resp, c.Req.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func flamegoInjector(c flamego.Context) {
|
||||
ctx := c.Request().Context()
|
||||
user, _ := ctx.Value(webAPIUserKey{}).(*database.User)
|
||||
sess, _ := ctx.Value(webAPISessionKey{}).(macaronsession.Store)
|
||||
mc, _ := ctx.Value(webAPIMacaronKey{}).(*macaron.Context)
|
||||
l, _ := ctx.Value(webAPILocaleKey{}).(i18n.Locale)
|
||||
c.Map(user, sess, mc, l)
|
||||
c.MapTo(flamegoSessionAdapter{sess: sess}, (*session.Session)(nil))
|
||||
}
|
||||
|
||||
// flamegoSessionAdapter exposes the underlying Macaron session via the Flamego
|
||||
// session interface so the captcha middleware (and any future Flamego-native
|
||||
// session consumer) can read/write the same session store the rest of the app
|
||||
// uses.
|
||||
type flamegoSessionAdapter struct {
|
||||
sess macaronsession.Store
|
||||
}
|
||||
|
||||
func (s flamegoSessionAdapter) ID() string { return s.sess.ID() }
|
||||
func (s flamegoSessionAdapter) Get(key interface{}) interface{} { return s.sess.Get(key) }
|
||||
func (s flamegoSessionAdapter) Set(key, val interface{}) { _ = s.sess.Set(key, val) }
|
||||
func (s flamegoSessionAdapter) SetFlash(val interface{}) { _ = s.sess.Set("_flash", val) }
|
||||
func (s flamegoSessionAdapter) Delete(key interface{}) { _ = s.sess.Delete(key) }
|
||||
func (s flamegoSessionAdapter) Flush() { _ = s.sess.Flush() }
|
||||
func (s flamegoSessionAdapter) Encode() ([]byte, error) { return nil, nil }
|
||||
|
||||
func webAPIBodyLimiter(c flamego.Context) {
|
||||
r := c.Request().Request
|
||||
r.Body = http.MaxBytesReader(c.ResponseWriter(), r.Body, 4*1024) // 4 KiB
|
||||
}
|
||||
|
||||
func parseUserFromCode(ctx stdctx.Context, code string) (user *database.User) {
|
||||
if len(code) <= tool.TimeLimitCodeLength {
|
||||
return nil
|
||||
}
|
||||
|
||||
hexStr := code[tool.TimeLimitCodeLength:]
|
||||
if b, err := hex.DecodeString(hexStr); err == nil {
|
||||
if user, err = database.Handle.Users().GetByUsername(ctx, string(b)); user != nil {
|
||||
return user
|
||||
} else if !database.IsErrUserNotExist(err) {
|
||||
log.Error("parseUserFromCode: get user by name %q: %v", string(b), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyUserActiveCode(ctx stdctx.Context, code string) (user *database.User) {
|
||||
if user = parseUserFromCode(ctx, code); user != nil {
|
||||
prefix := code[:tool.TimeLimitCodeLength]
|
||||
data := strconv.FormatInt(user.ID, 10) + user.Email + user.LowerName + user.Password + user.Rands
|
||||
if tool.VerifyTimeLimitCode(data, conf.Auth.ActivateCodeLives, prefix) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// webAPIValidator is the shared validator instance used by every webapi
|
||||
// binding. Registering the json-tag name function makes validation errors
|
||||
// carry the wire field name (e.g. "recoveryCode") via ve.Field(), so the
|
||||
// 400 payload keys match what the React client sends and reads.
|
||||
var webAPIValidator = func() *validator.Validate {
|
||||
v := validator.New()
|
||||
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "-" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
})
|
||||
_ = v.RegisterValidation("alphadashdot", func(fl validator.FieldLevel) bool {
|
||||
return !alphaDashDotInvalid.MatchString(fl.Field().String())
|
||||
})
|
||||
return v
|
||||
}()
|
||||
|
||||
var alphaDashDotInvalid = regexp.MustCompile(`[^\d\w\-_\.]`)
|
||||
|
||||
// bindJSON binds the request body to T. On binding or validation failure it
|
||||
// short-circuits with a 400 carrying the standard renderBindingErrors payload,
|
||||
// so downstream handlers can drop the `if len(bindErrs) > 0` boilerplate and
|
||||
// the binding.Errors parameter entirely.
|
||||
func bindJSON(model any) flamego.Handler {
|
||||
return binding.JSON(model, binding.Options{
|
||||
Validator: webAPIValidator,
|
||||
ErrorHandler: func(c flamego.Context, l i18n.Locale, errs binding.Errors) {
|
||||
w := c.ResponseWriter()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(renderBindingErrors(l, errs))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func mountWebAPIRoutes(f *flamego.Flame) {
|
||||
f.ReturnHandler(func(c flamego.Context, statusCode int, resp any, err error) {
|
||||
w := c.ResponseWriter()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if statusCode >= http.StatusInternalServerError && conf.IsProdMode() {
|
||||
msg = "Internal server error"
|
||||
}
|
||||
resp = map[string]any{"error": msg}
|
||||
}
|
||||
if resp == nil {
|
||||
w.WriteHeader(statusCode)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
f.Group("/api/web", func() {
|
||||
f.Group("/user", func() {
|
||||
f.Get("/info", getUserInfo)
|
||||
f.Combo("/sign-up").
|
||||
Get(getUserSignUp).
|
||||
Post(bindJSON(userSignUpRequest{}), postUserSignUp)
|
||||
f.Group("/reset-password", func() {
|
||||
f.Combo("").
|
||||
Get(getUserResetPassword).
|
||||
Post(bindJSON(userResetPasswordEmailRequest{}), postUserResetPassword)
|
||||
f.Post("/complete", bindJSON(userResetPasswordCompleteRequest{}), postUserResetPasswordComplete)
|
||||
})
|
||||
f.Combo("/sign-in").
|
||||
Get(getUserSignIn).
|
||||
Post(bindJSON(userSignInRequest{}), postUserSignIn)
|
||||
f.Group("/mfa", func() {
|
||||
f.Combo("").
|
||||
Get(getUserMFA).
|
||||
Post(bindJSON(userMFARequest{}), postUserMFA)
|
||||
f.Post("/recovery", bindJSON(userMFARecoveryRequest{}), postUserMFARecovery)
|
||||
})
|
||||
f.Group("/activate", func() {
|
||||
f.Combo("").
|
||||
Get(getUserActivate).
|
||||
Post(postUserActivate)
|
||||
f.Post("/complete", bindJSON(userActivateCompleteRequest{}), postUserActivateComplete)
|
||||
})
|
||||
f.Post("/sign-out", postUserSignOut)
|
||||
})
|
||||
}, webAPIBodyLimiter)
|
||||
}
|
||||
|
||||
// fieldErrors maps JSON field names to per-field localized messages. A non-nil
|
||||
// value renders inline under the input. A nil value marks the input as
|
||||
// invalid (highlight + focus eligibility) without duplicating text. Used in
|
||||
// concert with bindingErrorResponse.Error to surface one banner message while
|
||||
// highlighting multiple inputs.
|
||||
type fieldErrors map[string]*string
|
||||
|
||||
// bindingErrorResponse carries form-validation failures. Error is the top-level
|
||||
// message shown as a banner above the form (used when the failure is not tied
|
||||
// to a specific input, e.g. malformed body, bad credentials).
|
||||
type bindingErrorResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Fields fieldErrors `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// ruleSuffixKeys maps a validator tag to the shared "form.*_error" suffix key
|
||||
// (e.g. "max" -> "form.max_size_error"). Messages are composed as
|
||||
// <field label> + <suffix>, mirroring the legacy Macaron binding behavior.
|
||||
var ruleSuffixKeys = map[string]string{
|
||||
"required": "form.require_error",
|
||||
"max": "form.max_size_error",
|
||||
"min": "form.min_size_error",
|
||||
"len": "form.size_error",
|
||||
"email": "form.email_error",
|
||||
"url": "form.url_error",
|
||||
"alphadashdot": "form.alpha_dash_dot_error",
|
||||
}
|
||||
|
||||
// renderBindingErrors maps binding.Errors to the response shape, looking up
|
||||
// localized messages via the request's locale. The per-field label comes from
|
||||
// "form.<StructField>" (e.g. "form.UserName"); the rule suffix comes from
|
||||
// ruleSuffixKeys. Rule parameters (e.g. "254" for `max=254`) are passed
|
||||
// through to the suffix translation for %s expansion. Always HTTP 400.
|
||||
func renderBindingErrors(l i18n.Locale, errs binding.Errors) *bindingErrorResponse {
|
||||
for _, e := range errs {
|
||||
if e.Category == binding.ErrorCategoryDeserialization {
|
||||
return &bindingErrorResponse{Error: l.Tr("form.invalid_request") + ": " + e.Err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
out := make(fieldErrors)
|
||||
for _, e := range errs {
|
||||
var ves validator.ValidationErrors
|
||||
ok := errors.As(e.Err, &ves)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, ve := range ves {
|
||||
field := ve.Field()
|
||||
if _, exists := out[field]; exists {
|
||||
// Keep the first rule that failed for a given field so the client renders one
|
||||
// message per input. Subsequent rules surface only after the first is fixed.
|
||||
continue
|
||||
}
|
||||
label := l.Tr("form." + ve.StructField())
|
||||
suffixKey, known := ruleSuffixKeys[ve.Tag()]
|
||||
var msg string
|
||||
switch {
|
||||
case !known:
|
||||
msg = l.Tr("form.unknown_error") + " " + ve.Tag()
|
||||
case ve.Param() != "":
|
||||
msg = label + l.Tr(suffixKey, ve.Param())
|
||||
default:
|
||||
msg = label + l.Tr(suffixKey)
|
||||
}
|
||||
out[field] = &msg
|
||||
}
|
||||
}
|
||||
return &bindingErrorResponse{Fields: out}
|
||||
}
|
||||
|
||||
type loginSource struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
type getUserSignInResponse struct {
|
||||
LoginSources []loginSource `json:"loginSources"`
|
||||
}
|
||||
|
||||
type getUserSignUpResponse struct {
|
||||
RegistrationDisabled bool `json:"registrationDisabled"`
|
||||
CaptchaEnabled bool `json:"captchaEnabled"`
|
||||
}
|
||||
|
||||
func getUserSignUp() (statusCode int, resp *getUserSignUpResponse, err error) {
|
||||
return http.StatusOK, &getUserSignUpResponse{
|
||||
RegistrationDisabled: conf.Auth.DisableRegistration,
|
||||
CaptchaEnabled: conf.Auth.EnableRegistrationCaptcha,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type userSignUpRequest struct {
|
||||
UserName string `json:"userName" validate:"required,alphadashdot,max=35"`
|
||||
Email string `json:"email" validate:"required,email,max=254"`
|
||||
Password string `json:"password" validate:"required,max=255"`
|
||||
Captcha string `json:"captcha"`
|
||||
}
|
||||
|
||||
type userSignUpResponse struct {
|
||||
EmailConfirmationRequired bool `json:"emailConfirmationRequired,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Hours int `json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
func postUserSignUp(r *http.Request, mc *macaron.Context, ca cache.Cache, l i18n.Locale, cpt captcha.Captcha, req userSignUpRequest) (statusCode int, resp any, err error) {
|
||||
if conf.Auth.DisableRegistration {
|
||||
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_prompt")}, nil
|
||||
}
|
||||
if conf.Auth.EnableRegistrationCaptcha && !cpt.ValidText(req.Captcha) {
|
||||
msg := l.Tr("form.captcha_incorrect")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"captcha": &msg},
|
||||
}, nil
|
||||
}
|
||||
u, err := database.Handle.Users().Create(
|
||||
r.Context(),
|
||||
req.UserName,
|
||||
req.Email,
|
||||
database.CreateUserOptions{
|
||||
Password: req.Password,
|
||||
Activated: !conf.Auth.RequireEmailConfirmation,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case database.IsErrUserAlreadyExist(err):
|
||||
msg := l.Tr("form.username_been_taken")
|
||||
return http.StatusUnprocessableEntity, &bindingErrorResponse{Fields: fieldErrors{"userName": &msg}}, nil
|
||||
case database.IsErrEmailAlreadyUsed(err):
|
||||
msg := l.Tr("form.email_been_used")
|
||||
return http.StatusUnprocessableEntity, &bindingErrorResponse{Fields: fieldErrors{"email": &msg}}, nil
|
||||
case database.IsErrNameNotAllowed(err):
|
||||
msg := l.Tr("user.form.name_not_allowed", err.(database.ErrNameNotAllowed).Value())
|
||||
return http.StatusBadRequest, &bindingErrorResponse{Fields: fieldErrors{"userName": &msg}}, nil
|
||||
default:
|
||||
log.Error("postUserSignUp: create user %q: %v", req.UserName, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "create user")
|
||||
}
|
||||
}
|
||||
log.Trace("Account created: %s", u.Name)
|
||||
|
||||
if database.Handle.Users().Count(r.Context()) == 1 {
|
||||
v := true
|
||||
err := database.Handle.Users().Update(
|
||||
r.Context(),
|
||||
u.ID,
|
||||
database.UpdateUserOptions{
|
||||
IsActivated: &v,
|
||||
IsAdmin: &v,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("postUserSignUp: update first user %q: %v", u.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||
}
|
||||
}
|
||||
|
||||
if conf.Auth.RequireEmailConfirmation && u.ID > 1 {
|
||||
if err := email.SendActivateAccountMail(mc, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("postUserSignUp: send activation mail to user %q: %v", u.Name, err)
|
||||
}
|
||||
if err := ca.Set(r.Context(), userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||
log.Error("postUserSignUp: put mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
return http.StatusOK, &userSignUpResponse{
|
||||
EmailConfirmationRequired: true,
|
||||
Email: u.Email,
|
||||
Hours: conf.Auth.ActivateCodeLives / 60,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return http.StatusOK, &userSignUpResponse{}, nil
|
||||
}
|
||||
|
||||
func getUserSignIn(r *http.Request) (statusCode int, resp *getUserSignInResponse, err error) {
|
||||
sources, err := database.Handle.LoginSources().List(r.Context(), database.ListLoginSourceOptions{OnlyActivated: true})
|
||||
if err != nil {
|
||||
log.Error("getUserSignIn: list activated login sources: %v", err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "list activated login sources")
|
||||
}
|
||||
loginSources := make([]loginSource, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
loginSources = append(loginSources, loginSource{ID: s.ID, Name: s.Name, IsDefault: s.IsDefault})
|
||||
}
|
||||
return http.StatusOK, &getUserSignInResponse{LoginSources: loginSources}, nil
|
||||
}
|
||||
|
||||
type userSignInRequest struct {
|
||||
Username string `json:"username" validate:"required,max=254"`
|
||||
Password string `json:"password" validate:"required,max=255"`
|
||||
LoginSource int64 `json:"loginSource"`
|
||||
}
|
||||
|
||||
type getUserResetPasswordResponse struct {
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
func getUserResetPassword(r *http.Request) (statusCode int, resp *getUserResetPasswordResponse, err error) {
|
||||
code := r.URL.Query().Get("code")
|
||||
return http.StatusOK, &getUserResetPasswordResponse{
|
||||
EmailEnabled: conf.Email.Enabled,
|
||||
Valid: code != "" && verifyUserActiveCode(r.Context(), code) != nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type userResetPasswordEmailRequest struct {
|
||||
Email string `json:"email" validate:"required,email,max=254"`
|
||||
}
|
||||
|
||||
type userResetPasswordCompleteRequest struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
Password string `json:"password" validate:"required,min=6,max=255"`
|
||||
}
|
||||
|
||||
type userResetPasswordResponse struct {
|
||||
Hours int `json:"hours,omitempty"`
|
||||
ResendLimited bool `json:"resendLimited,omitempty"`
|
||||
}
|
||||
|
||||
func postUserResetPassword(r *http.Request, ca cache.Cache, l i18n.Locale, req userResetPasswordEmailRequest) (statusCode int, resp any, err error) {
|
||||
if !conf.Email.Enabled {
|
||||
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_mail")}, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
u, err := database.Handle.Users().GetByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
if database.IsErrUserNotExist(err) {
|
||||
return http.StatusOK, &userResetPasswordResponse{Hours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||
}
|
||||
log.Error("postUserResetPassword: get user by email %q: %v", req.Email, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by email")
|
||||
}
|
||||
|
||||
if !u.IsLocal() {
|
||||
msg := l.Tr("auth.non_local_account")
|
||||
return http.StatusForbidden, &bindingErrorResponse{Fields: fieldErrors{"email": &msg}}, nil
|
||||
}
|
||||
|
||||
if _, err := ca.Get(ctx, userx.MailResendCacheKey(u.ID)); err == nil {
|
||||
return http.StatusOK, &userResetPasswordResponse{
|
||||
Hours: conf.Auth.ActivateCodeLives / 60,
|
||||
ResendLimited: true,
|
||||
}, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Error("postUserResetPassword: get mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
|
||||
if err = email.SendResetPasswordMail(l, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("postUserResetPassword: send reset password mail to user %q: %v", u.Name, err)
|
||||
}
|
||||
if err = ca.Set(ctx, userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||
log.Error("postUserResetPassword: put mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
|
||||
return http.StatusOK, &userResetPasswordResponse{Hours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||
}
|
||||
|
||||
func postUserResetPasswordComplete(r *http.Request, l i18n.Locale, req userResetPasswordCompleteRequest) (statusCode int, resp any, err error) {
|
||||
u := verifyUserActiveCode(r.Context(), req.Code)
|
||||
if u == nil {
|
||||
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
|
||||
}
|
||||
|
||||
if err := database.Handle.Users().Update(r.Context(), u.ID, database.UpdateUserOptions{Password: &req.Password}); err != nil {
|
||||
log.Error("postUserResetPasswordComplete: update password for user %q: %v", u.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||
}
|
||||
|
||||
log.Trace("User password reset: %s", u.Name)
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
type userSignInResponse struct {
|
||||
// MFA is true when the account has MFA enabled and the password step
|
||||
// succeeded but a second factor is still required. The client should
|
||||
// navigate to /user/mfa to complete the challenge.
|
||||
MFA bool `json:"mfa,omitempty"`
|
||||
}
|
||||
|
||||
func postUserSignIn(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userSignInRequest) (statusCode int, resp any, err error) {
|
||||
u, err := database.Handle.Users().Authenticate(r.Context(), req.Username, req.Password, req.LoginSource)
|
||||
if err != nil {
|
||||
switch {
|
||||
case auth.IsErrBadCredentials(err):
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Error: l.Tr("form.username_password_incorrect"),
|
||||
Fields: fieldErrors{"username": nil, "password": nil},
|
||||
}, nil
|
||||
case database.IsErrLoginSourceMismatch(err):
|
||||
return http.StatusUnprocessableEntity, nil, errors.New(l.Tr("form.auth_source_mismatch"))
|
||||
default:
|
||||
log.Error("postUserSignIn: authenticate user %q: %v", req.Username, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "authenticate user")
|
||||
}
|
||||
}
|
||||
|
||||
if database.Handle.TwoFactors().IsEnabled(r.Context(), u.ID) {
|
||||
sess.Set("mfaUserID", u.ID)
|
||||
return http.StatusOK, &userSignInResponse{MFA: true}, nil
|
||||
}
|
||||
|
||||
completeSignIn(sess, mc, u)
|
||||
return http.StatusOK, &userSignInResponse{}, nil
|
||||
}
|
||||
|
||||
// completeSignIn finalizes the sign-in session for u: writes the auth session,
|
||||
// clears any in-flight MFA state, and sets the login-status cookie. The
|
||||
// caller is responsible for navigating to a post-login destination via
|
||||
// /redirect?to=.
|
||||
func completeSignIn(sess session.Session, mc *macaron.Context, u *database.User) {
|
||||
sess.Set("uid", u.ID)
|
||||
sess.Set("uname", u.Name)
|
||||
sess.Delete("mfaUserID")
|
||||
|
||||
mc.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
|
||||
if conf.Security.EnableLoginStatusCookie {
|
||||
mc.SetCookie(conf.Security.LoginStatusCookieName, "true", 0, conf.Server.Subpath)
|
||||
}
|
||||
}
|
||||
|
||||
func getUserMFA(sess session.Session) (statusCode int, resp any, err error) {
|
||||
if _, ok := sess.Get("mfaUserID").(int64); !ok {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
type userMFARequest struct {
|
||||
Passcode string `json:"passcode" validate:"required,len=6"`
|
||||
}
|
||||
|
||||
type userMFAResponse struct{}
|
||||
|
||||
func postUserMFA(r *http.Request, sess session.Session, mc *macaron.Context, ca cache.Cache, l i18n.Locale, req userMFARequest) (statusCode int, resp any, err error) {
|
||||
userID, ok := sess.Get("mfaUserID").(int64)
|
||||
if !ok {
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{Error: l.Tr("auth.mfa_session_expired")}, nil
|
||||
}
|
||||
|
||||
t, err := database.Handle.TwoFactors().GetByUserID(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error("postUserMFA: get two factor by user ID %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get two factor by user ID")
|
||||
}
|
||||
|
||||
valid, err := t.ValidateTOTP(req.Passcode)
|
||||
if err != nil {
|
||||
log.Error("postUserMFA: validate TOTP for user %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "validate TOTP")
|
||||
}
|
||||
if !valid {
|
||||
msg := l.Tr("auth.mfa_invalid_passcode")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"passcode": &msg},
|
||||
}, nil
|
||||
}
|
||||
|
||||
cacheKey := userx.TwoFactorCacheKey(userID, req.Passcode)
|
||||
if _, err := ca.Get(r.Context(), cacheKey); err == nil {
|
||||
msg := l.Tr("auth.mfa_reused_passcode")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"passcode": &msg},
|
||||
}, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Error("postUserMFA: get two factor passcode cache for user %d: %v", userID, err)
|
||||
}
|
||||
if err = ca.Set(r.Context(), cacheKey, 1, 60*time.Second); err != nil {
|
||||
log.Error("postUserMFA: cache two factor passcode for user %d: %v", userID, err)
|
||||
}
|
||||
|
||||
u, err := database.Handle.Users().GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error("postUserMFA: get user by ID %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by ID")
|
||||
}
|
||||
|
||||
completeSignIn(sess, mc, u)
|
||||
return http.StatusOK, &userMFAResponse{}, nil
|
||||
}
|
||||
|
||||
type userMFARecoveryRequest struct {
|
||||
RecoveryCode string `json:"recoveryCode" validate:"required,len=11"`
|
||||
}
|
||||
|
||||
func postUserMFARecovery(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userMFARecoveryRequest) (statusCode int, resp any, err error) {
|
||||
userID, ok := sess.Get("mfaUserID").(int64)
|
||||
if !ok {
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{Error: l.Tr("auth.mfa_session_expired")}, nil
|
||||
}
|
||||
|
||||
if err := database.Handle.TwoFactors().UseRecoveryCode(r.Context(), userID, req.RecoveryCode); err != nil {
|
||||
if database.IsTwoFactorRecoveryCodeNotFound(err) {
|
||||
msg := l.Tr("auth.mfa_invalid_recovery_code")
|
||||
return http.StatusUnauthorized, &bindingErrorResponse{
|
||||
Fields: fieldErrors{"recoveryCode": &msg},
|
||||
}, nil
|
||||
}
|
||||
log.Error("postUserMFARecovery: use recovery code for user %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "use recovery code")
|
||||
}
|
||||
|
||||
u, err := database.Handle.Users().GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
log.Error("postUserMFARecovery: get user by ID %d: %v", userID, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by ID")
|
||||
}
|
||||
|
||||
completeSignIn(sess, mc, u)
|
||||
return http.StatusOK, &userMFAResponse{}, nil
|
||||
}
|
||||
|
||||
type userInfo struct {
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatarURL"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
CanCreateOrganization bool `json:"canCreateOrganization"`
|
||||
}
|
||||
|
||||
func getUserInfo(user *database.User) (statusCode int, resp *userInfo, err error) {
|
||||
if user == nil {
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
return http.StatusOK,
|
||||
&userInfo{
|
||||
Username: user.Name,
|
||||
AvatarURL: user.AvatarURL(),
|
||||
IsAdmin: user.IsAdmin,
|
||||
CanCreateOrganization: user.CanCreateOrganization(),
|
||||
},
|
||||
nil
|
||||
}
|
||||
|
||||
type getUserActivateResponse struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"`
|
||||
}
|
||||
|
||||
func getUserActivate(u *database.User) (statusCode int, resp any, err error) {
|
||||
if u == nil {
|
||||
return http.StatusUnauthorized, nil, nil
|
||||
}
|
||||
// An already-active and authenticated user has no business on the activation page.
|
||||
if u.IsActive {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
return http.StatusOK, &getUserActivateResponse{
|
||||
Email: u.Email,
|
||||
CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type postUserActivateResponse struct {
|
||||
RateLimited bool `json:"rateLimited,omitempty"`
|
||||
CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"`
|
||||
}
|
||||
|
||||
func postUserActivate(r *http.Request, u *database.User, mc *macaron.Context, ca cache.Cache, l i18n.Locale) (statusCode int, resp any, err error) {
|
||||
if u == nil {
|
||||
return http.StatusUnauthorized, nil, nil
|
||||
}
|
||||
if u.IsActive {
|
||||
return http.StatusNotFound, nil, nil
|
||||
}
|
||||
if !conf.Auth.RequireEmailConfirmation {
|
||||
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_mail")}, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if _, err := ca.Get(ctx, userx.MailResendCacheKey(u.ID)); err == nil {
|
||||
return http.StatusOK, &postUserActivateResponse{
|
||||
RateLimited: true,
|
||||
CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60,
|
||||
}, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Error("postUserActivate: get mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
|
||||
if err := email.SendActivateAccountMail(mc, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("postUserActivate: send activation mail to user %q: %v", u.Name, err)
|
||||
}
|
||||
if err := ca.Set(ctx, userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||
log.Error("postUserActivate: put mail resend cache for user %q: %v", u.Name, err)
|
||||
}
|
||||
return http.StatusOK, &postUserActivateResponse{CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||
}
|
||||
|
||||
type userActivateCompleteRequest struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
func postUserActivateComplete(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userActivateCompleteRequest) (statusCode int, resp any, err error) {
|
||||
target := verifyUserActiveCode(r.Context(), req.Code)
|
||||
if target == nil {
|
||||
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
|
||||
}
|
||||
|
||||
v := true
|
||||
if err := database.Handle.Users().Update(
|
||||
r.Context(),
|
||||
target.ID,
|
||||
database.UpdateUserOptions{
|
||||
GenerateNewRands: true,
|
||||
IsActivated: &v,
|
||||
},
|
||||
); err != nil {
|
||||
log.Error("postUserActivateComplete: update user %q: %v", target.Name, err)
|
||||
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||
}
|
||||
|
||||
log.Trace("User activated: %s", target.Name)
|
||||
completeSignIn(sess, mc, target)
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
|
||||
type postUserSignOutResponse struct {
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
}
|
||||
|
||||
func postUserSignOut(sess macaronsession.Store, mc *macaron.Context) (statusCode int, resp *postUserSignOutResponse, err error) {
|
||||
_ = sess.Flush()
|
||||
_ = sess.Destory(mc)
|
||||
mc.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
|
||||
if conf.Auth.CustomLogoutURL != "" {
|
||||
return http.StatusOK, &postUserSignOutResponse{RedirectTo: conf.Auth.CustomLogoutURL}, nil
|
||||
}
|
||||
return http.StatusNoContent, nil, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//go:build !prod
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/flamego"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
"gogs.io/gogs/internal/context"
|
||||
)
|
||||
|
||||
func mountWebAppRoutes(f *flamego.Flame) error {
|
||||
viteURL, err := url.Parse("http://localhost:5173")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "parse Vite URL")
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(viteURL)
|
||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
|
||||
return nil
|
||||
}
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "read Vite response body")
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
wc := context.WebContextFrom(resp.Request)
|
||||
body, err := renderIndex(raw, wc)
|
||||
if err != nil {
|
||||
log.Error("Failed to render index: %v", err)
|
||||
body = []byte("Internal Server Error\n")
|
||||
resp.StatusCode = http.StatusInternalServerError
|
||||
resp.Status = http.StatusText(http.StatusInternalServerError)
|
||||
resp.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
} else if wc.StatusCode > 0 {
|
||||
resp.StatusCode = wc.StatusCode
|
||||
resp.Status = http.StatusText(wc.StatusCode)
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.ContentLength = int64(len(body))
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
// The upstream validators describe the unmodified body. Drop them
|
||||
// so the browser does not satisfy a conditional request from a
|
||||
// cached copy that has a stale injected lang attribute.
|
||||
resp.Header.Del("ETag")
|
||||
resp.Header.Del("Last-Modified")
|
||||
return nil
|
||||
}
|
||||
f.Any("/{**}", func(w http.ResponseWriter, r *http.Request) {
|
||||
proxy.ServeHTTP(w, r)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build prod
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/flamego"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/context"
|
||||
"gogs.io/gogs/public"
|
||||
)
|
||||
|
||||
func mountWebAppRoutes(f *flamego.Flame) error {
|
||||
webFS, err := fs.Sub(public.WebAssets, "dist")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "load embedded web assets")
|
||||
}
|
||||
// Prefix matches the path rewrites renderIndex applies to the index
|
||||
// shell. Without it the browser fetches /<subpath>/assets/... and the
|
||||
// static handler looks them up in webFS at "<subpath>/assets/...",
|
||||
// which has no <subpath> directory, so every asset would 404 and fall
|
||||
// through to the wildcard handler as text/html.
|
||||
//
|
||||
// Index is set to a sentinel that does not exist in the FS so flamego.Static
|
||||
// never serves the raw index.html for "/" requests. The catch-all below
|
||||
// always renders the shell through renderIndex instead, ensuring template
|
||||
// substitutions are applied.
|
||||
f.Use(flamego.Static(flamego.StaticOptions{
|
||||
FileSystem: http.FS(webFS),
|
||||
Prefix: conf.Server.Subpath,
|
||||
Index: "__disabled__",
|
||||
}))
|
||||
|
||||
index, err := public.WebAssets.ReadFile("dist/index.html")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, `read "dist/index.html"`)
|
||||
}
|
||||
|
||||
f.Get("/{**}", func(w http.ResponseWriter, r *http.Request) {
|
||||
wc := context.WebContextFrom(r)
|
||||
body, err := renderIndex(index, wc)
|
||||
if err != nil {
|
||||
log.Error("Failed to render index: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// The body is rewritten per request (lang injection, future
|
||||
// runtime config), so caching it would serve stale content to
|
||||
// any user whose request resolves to a different locale. Use
|
||||
// no-store rather than no-cache so the browser cannot keep a
|
||||
// copy at all, not even for revalidation. Static assets keep
|
||||
// their normal caching via flamego.Static.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
status := wc.StatusCode
|
||||
if status <= 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
+20
-1
@@ -4,10 +4,12 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
"gogs.io/gogs/cmd/gogs/internal/web"
|
||||
"gogs.io/gogs/internal/conf"
|
||||
)
|
||||
|
||||
@@ -15,10 +17,27 @@ func init() {
|
||||
conf.App.Version = "0.15.0+dev"
|
||||
}
|
||||
|
||||
var webCommand = cli.Command{
|
||||
Name: "web",
|
||||
Usage: "Start the web server",
|
||||
Description: "Serves the web interface, API, and HTTP Git endpoints.",
|
||||
Action: func(_ context.Context, cmd *cli.Command) error {
|
||||
var portOverride int
|
||||
if cmd.IsSet("port") {
|
||||
portOverride = cmd.Int("port")
|
||||
}
|
||||
return web.Run(configFromLineage(cmd), portOverride)
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
intFlag("port, p", 3000, "Alternative listening port to use"),
|
||||
stringFlag("config, c", filepath.Join(conf.CustomDir(), "conf", "app.ini"), "Custom configuration file path"),
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
cmd := &cli.Command{
|
||||
Name: "Gogs",
|
||||
Usage: "A painless self-hosted Git service",
|
||||
Usage: "The painless way to host your own Git service",
|
||||
Version: conf.App.Version,
|
||||
Commands: []*cli.Command{
|
||||
&webCommand,
|
||||
|
||||
+10
-11
@@ -164,12 +164,8 @@ INSTALL_LOCK = false
|
||||
; The secret to encrypt cookie values, 2FA code, etc.
|
||||
; !!CHANGE THIS TO KEEP YOUR USER DATA SAFE!!
|
||||
SECRET_KEY = !#@FDEWREWR&*(
|
||||
; The days remembered for auto-login.
|
||||
; The number of days a sign-in session persists across browser restarts.
|
||||
LOGIN_REMEMBER_DAYS = 7
|
||||
; The cookie name to store auto-login information.
|
||||
COOKIE_REMEMBER_NAME = gogs_incredible
|
||||
; The cookie name to store logged in username.
|
||||
COOKIE_USERNAME = gogs_awesome
|
||||
; Whether to set secure cookie.
|
||||
COOKIE_SECURE = false
|
||||
; Whether to set cookie to indicate user login status.
|
||||
@@ -233,6 +229,9 @@ ENABLE_REVERSE_PROXY_AUTHENTICATION = false
|
||||
ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = false
|
||||
; The HTTP header used as username for reverse proxy authentication.
|
||||
REVERSE_PROXY_AUTHENTICATION_HEADER = X-WEBAUTH-USER
|
||||
; Lists the IPs or CIDR ranges whose requests are allowed to set the reverse
|
||||
; proxy authentication header.
|
||||
TRUSTED_PROXY_IPS = 127.0.0.0/8,::1/128
|
||||
|
||||
[user]
|
||||
; Whether to enable email notifications for users.
|
||||
@@ -252,19 +251,20 @@ COOKIE_NAME = i_like_gogs
|
||||
COOKIE_SECURE = false
|
||||
; The GC interval in seconds for session data.
|
||||
GC_INTERVAL = 3600
|
||||
; The maximum life time in seconds for a session.
|
||||
MAX_LIFE_TIME = 86400
|
||||
; The maximum idle time in seconds before a session record is garbage-collected.
|
||||
; Set lower than `[security] LOGIN_REMEMBER_DAYS * 86400` to enforce a sliding
|
||||
; idle timeout. Otherwise the session lives for the full cookie lifetime.
|
||||
MAX_LIFE_TIME = 604800
|
||||
; The cookie name for CSRF token.
|
||||
CSRF_COOKIE_NAME = _csrf
|
||||
|
||||
[cache]
|
||||
; The cache adapter, either "memory", "redis", or "memcache".
|
||||
; The cache adapter, either "memory" or "redis".
|
||||
ADAPTER = memory
|
||||
; For "memory" only, GC interval in seconds.
|
||||
INTERVAL = 60
|
||||
; For "redis" and "memcache", connection host address:
|
||||
; For "redis", connection host address:
|
||||
; - redis: network=tcp,addr=:6379,password=macaron,db=0,pool_size=100,idle_timeout=180
|
||||
; - memcache: `127.0.0.1:11211`
|
||||
HOST =
|
||||
|
||||
[http]
|
||||
@@ -569,6 +569,5 @@ mn-MN = mn
|
||||
ro-RO = ro
|
||||
|
||||
[other]
|
||||
SHOW_FOOTER_BRANDING = false
|
||||
; Show time of template execution in the footer
|
||||
SHOW_FOOTER_TEMPLATE_LOAD_TIME = true
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
app_desc = A painless self-hosted Git service
|
||||
app_desc = The painless way to host your own Git service
|
||||
|
||||
home = Home
|
||||
dashboard = Dashboard
|
||||
explore = Explore
|
||||
help = Help
|
||||
sign_in = Sign In
|
||||
sign_out = Sign Out
|
||||
sign_up = Sign Up
|
||||
register = Register
|
||||
sign_in = Sign in
|
||||
sign_out = Sign out
|
||||
sign_up = Sign up
|
||||
register = Create account
|
||||
website = Website
|
||||
page = Page
|
||||
template = Template
|
||||
@@ -17,35 +17,46 @@ user_profile_and_more = User profile and more
|
||||
signed_in_as = Signed in as
|
||||
|
||||
username = Username
|
||||
username_placeholder = Enter your username or email
|
||||
new_username_placeholder = Choose a username
|
||||
email = Email
|
||||
email_placeholder = Enter your email
|
||||
password = Password
|
||||
re_type = Re-Type
|
||||
password_placeholder = Enter your password
|
||||
captcha = Captcha
|
||||
captcha_placeholder = Enter the characters shown above
|
||||
captcha_image_alt = Captcha image
|
||||
refresh_captcha = Refresh captcha
|
||||
click_to_refresh_captcha = Click to refresh
|
||||
|
||||
repository = Repository
|
||||
organization = Organization
|
||||
mirror = Mirror
|
||||
new_repo = New Repository
|
||||
new_migrate = New Migration
|
||||
new_mirror = New Mirror
|
||||
new_fork = New Fork Repository
|
||||
new_org = New Organization
|
||||
manage_org = Manage Organizations
|
||||
admin_panel = Admin Panel
|
||||
new_repo = New repository
|
||||
new_migrate = New migration
|
||||
new_mirror = New mirror
|
||||
new_fork = New fork repository
|
||||
new_org = New organization
|
||||
manage_org = Manage organizations
|
||||
admin_panel = Admin panel
|
||||
account_settings = Account Settings
|
||||
settings = Settings
|
||||
your_profile = Your Profile
|
||||
your_settings = Your Settings
|
||||
theme = Theme
|
||||
theme_light = Light
|
||||
theme_dark = Dark
|
||||
theme_system = System
|
||||
your_profile = Your profile
|
||||
your_settings = Your settings
|
||||
|
||||
activities = Activities
|
||||
pull_requests = Pull Requests
|
||||
pull_requests = Pull requests
|
||||
issues = Issues
|
||||
|
||||
cancel = Cancel
|
||||
|
||||
[status]
|
||||
page_not_found = Page Not Found
|
||||
internal_server_error = Internal Server Error
|
||||
page_not_found = Page not found
|
||||
internal_server_error = Internal server error
|
||||
|
||||
[install]
|
||||
install = Installation
|
||||
@@ -116,7 +127,7 @@ admin_setting_desc = You don't need to create an admin account right now. The fi
|
||||
admin_title = Admin Account Settings
|
||||
admin_name = Username
|
||||
admin_password = Password
|
||||
confirm_password = Confirm Password
|
||||
confirm_password = Confirm password
|
||||
admin_email = Admin Email
|
||||
install_gogs = Install Gogs
|
||||
test_git_failed = Failed to test 'git' command: %v
|
||||
@@ -151,39 +162,68 @@ organizations = Organizations
|
||||
search = Search
|
||||
|
||||
[auth]
|
||||
create_new_account = Create New Account
|
||||
create_new_account = Create new account
|
||||
sign_up_submitting = Creating account...
|
||||
sign_up_failed = Could not create account, please try again.
|
||||
sign_in_submitting = Signing in...
|
||||
sign_in_failed = Could not sign in, please try again.
|
||||
show_password = Show password
|
||||
hide_password = Hide password
|
||||
back_to_sign_in = Back to sign in
|
||||
mfa_title = Multi-factor authentication
|
||||
mfa_passcode = Passcode
|
||||
mfa_passcode_placeholder = Enter the 6-digit code from your authenticator
|
||||
mfa_recovery_code = Recovery code
|
||||
mfa_recovery_code_placeholder = Enter a recovery code
|
||||
mfa_use_recovery_code = Use a recovery code instead
|
||||
mfa_use_passcode = Use a passcode instead
|
||||
mfa_verify = Verify
|
||||
mfa_verifying = Verifying...
|
||||
mfa_session_expired = Your sign-in session has expired. Please sign in again.
|
||||
mfa_verify_failed = Verification failed. Please try again.
|
||||
mfa_invalid_passcode = The passcode you entered is not valid.
|
||||
mfa_reused_passcode = The passcode you entered has already been used, please try another one.
|
||||
mfa_invalid_recovery_code = Recovery code already used or invalid.
|
||||
register_hepler_msg = Already have an account? Sign in now!
|
||||
social_register_hepler_msg = Already have an account? Bind now!
|
||||
disable_register_prompt = Sorry, registration has been disabled. Please contact the site administrator.
|
||||
disable_register_mail = Sorry, email services are disabled. Please contact the site administrator.
|
||||
auth_source = Authentication Source
|
||||
auth_source = Authentication source
|
||||
local = Local
|
||||
remember_me = Remember Me
|
||||
forgot_password= Forgot Password
|
||||
forget_password = Forgot password?
|
||||
sign_up_now = Need an account? Sign up now.
|
||||
confirmation_mail_sent_prompt = A new confirmation email has been sent to <b>%s</b>, please check your inbox within the next %d hours to complete the registration process.
|
||||
active_your_account = Activate Your Account
|
||||
sign_up_now = Create a new account
|
||||
confirmation_email_sent = A new confirmation email has been sent to <b>%s</b>, please check your inbox within the next %d hours to complete the registration process.
|
||||
activate_your_account = Activate your account
|
||||
prohibit_login = Login Prohibited
|
||||
prohibit_login_desc = Your account is prohibited from logging in. Please contact the site admin.
|
||||
resent_limit_prompt = Sorry, you already requested an activation email recently. Please wait 3 minutes then try again.
|
||||
resend_rate_limited = Sorry, you already requested an activation email recently. Please wait 3 minutes then try again.
|
||||
has_unconfirmed_mail = Hi %s, you have an unconfirmed email address (<b>%s</b>). If you haven't received a confirmation email or need to receive a new one, please click the button below.
|
||||
resend_mail = Click here to resend your activation email
|
||||
send_reset_mail = Click here to (re)send your password reset email
|
||||
reset_password = Reset Your Password
|
||||
invalid_code = Sorry, your confirmation code has expired or not valid.
|
||||
reset_password_helper = Click here to reset your password
|
||||
password_too_short = Password length must be at least 6 characters.
|
||||
send_activation_email = Send activation email
|
||||
check_activation_email = Please check your email and click the activation link to finish creating your account.
|
||||
activation_email_pending = Your email address <email>{email}</email> is not yet confirmed. Click below to send a new activation email valid for <hours>{hours} hours</hours>.
|
||||
activation_email_sent = A new activation email has been sent to <email>{email}</email>. Please check your inbox within <hours>{hours} hours</hours>.
|
||||
sending_activation_email = Sending activation email...
|
||||
send_activation_email_failed = Could not send activation email, please try again.
|
||||
activating_account = Activating your account...
|
||||
send_reset_email = Send password reset email
|
||||
reset_password_email_submitting = Sending password reset email...
|
||||
reset_password_email_failed = Could not send password reset email, please try again.
|
||||
reset_password_email_sent = A password reset email has been sent to <email>{email}</email>, please check your inbox within <hours>{hours} hours</hours>.
|
||||
reset_password = Reset your password
|
||||
invalid_code = The confirmation code has expired or not valid.
|
||||
reset_password_submit = Reset password
|
||||
reset_password_submitting = Resetting password...
|
||||
reset_password_resend_limited = You already requested a password reset email recently. Please wait 3 minutes then try again.
|
||||
reset_password_failed = Could not reset password, please try again.
|
||||
new_password = New password
|
||||
new_password_placeholder = Enter your new password
|
||||
confirm_password_placeholder = Re-enter your password
|
||||
confirm_new_password = Confirm new password
|
||||
confirm_new_password_placeholder = Re-enter your new password
|
||||
password_mismatch = The two passwords do not match.
|
||||
non_local_account = Non-local accounts cannot change passwords through Gogs.
|
||||
|
||||
login_two_factor = Two-factor Authentication
|
||||
login_two_factor_passcode = Authentication Passcode
|
||||
login_two_factor_enter_recovery_code = Enter a two-factor recovery code
|
||||
login_two_factor_recovery = Two-factor Recovery
|
||||
login_two_factor_recovery_code = Recovery Code
|
||||
login_two_factor_enter_passcode = Enter a two-factor passcode
|
||||
login_two_factor_invalid_recovery_code = Recovery code already used or invalid.
|
||||
|
||||
[mail]
|
||||
activate_account = Please activate your account
|
||||
activate_email = Verify your email address
|
||||
@@ -197,7 +237,9 @@ no = No
|
||||
modify = Modify
|
||||
|
||||
[form]
|
||||
invalid_request = The request could not be processed
|
||||
UserName = Username
|
||||
Username = Username
|
||||
RepoName = Repository name
|
||||
Email = Email address
|
||||
Password = Password
|
||||
@@ -208,6 +250,8 @@ PayloadUrl = Payload URL
|
||||
TeamName = Team name
|
||||
AuthName = Authorization name
|
||||
AdminEmail = Admin email
|
||||
Passcode = Passcode
|
||||
RecoveryCode = Recovery code
|
||||
|
||||
NewBranchName = New branch name
|
||||
CommitSummary = Commit summary
|
||||
@@ -235,7 +279,7 @@ repo_name_been_taken = Repository name has already been taken.
|
||||
org_name_been_taken = Organization name has already been taken.
|
||||
team_name_been_taken = Team name has already been taken.
|
||||
email_been_used = Email address has already been used.
|
||||
username_password_incorrect = Username or password is not correct.
|
||||
username_password_incorrect = Username or password is incorrect.
|
||||
auth_source_mismatch = The authentication source selected is not associated with the user.
|
||||
enterred_invalid_repo_name = Please make sure that the repository name you entered is correct.
|
||||
enterred_invalid_owner_name = Please make sure that the owner name you entered is correct.
|
||||
@@ -1085,7 +1129,7 @@ users.created = Created
|
||||
users.send_register_notify = Send Registration Notification To User
|
||||
users.new_success = New account '%s' has been created successfully.
|
||||
users.edit = Edit
|
||||
users.auth_source = Authentication Source
|
||||
users.auth_source = Authentication source
|
||||
users.local = Local
|
||||
users.auth_login_name = Authentication Login Name
|
||||
users.password_helper = Leave it empty to remain unchanged.
|
||||
@@ -1118,7 +1162,7 @@ repos.stars = Stars
|
||||
repos.issues = Issues
|
||||
repos.size = Size
|
||||
|
||||
auths.auth_sources = Authentication Sources
|
||||
auths.auth_sources = Authentication sources
|
||||
auths.new = Add New Source
|
||||
auths.name = Name
|
||||
auths.type = Type
|
||||
@@ -1248,8 +1292,6 @@ config.db.max_idle_conns = Maximum idle connections
|
||||
|
||||
config.security_config = Security configuration
|
||||
config.security.login_remember_days = Login remember days
|
||||
config.security.cookie_remember_name = Remember cookie
|
||||
config.security.cookie_username = Username cookie
|
||||
config.security.cookie_secure = Enable secure cookie
|
||||
config.security.reverse_proxy_auth_user = Reverse proxy authentication header
|
||||
config.security.enable_login_status_cookie = Enable login status cookie
|
||||
@@ -1284,6 +1326,7 @@ config.auth.enable_registration_captcha = Enable registration captcha
|
||||
config.auth.enable_reverse_proxy_authentication = Enable reverse proxy authentication
|
||||
config.auth.enable_reverse_proxy_auto_registration = Enable reverse proxy auto registration
|
||||
config.auth.reverse_proxy_authentication_header = Reverse proxy authentication header
|
||||
config.auth.trusted_proxy_ips = Trusted proxy IPs
|
||||
|
||||
config.user_config = User configuration
|
||||
config.user.enable_email_notify = Enable email notification
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -xe
|
||||
|
||||
if [ "$(uname -m)" = "aarch64" ]; then
|
||||
export arch='arm64'
|
||||
export checksum='17f325293d08f6f964e0530842e9ef1410dd5f83ee6475b493087391032b0cfd'
|
||||
elif [ "$(uname -m)" = "armv7l" ]; then
|
||||
export arch='arm'
|
||||
export checksum='e5b0261e9f6563ce3ace9e038520eb59d2c77c8d85f2b47ab41e1fe7cf321528'
|
||||
else
|
||||
export arch='amd64'
|
||||
export checksum='a35462ec71410cccfc428072de830e4478bc57a919d0131ef7897759270dff8f'
|
||||
fi
|
||||
|
||||
wget --quiet https://github.com/go-task/task/releases/download/v3.40.1/task_linux_${arch}.tar.gz -O task_linux_${arch}.tar.gz
|
||||
echo "${checksum} task_linux_${arch}.tar.gz" | sha256sum -cs
|
||||
|
||||
tar -xzf task_linux_${arch}.tar.gz
|
||||
mv task /usr/local/bin/task
|
||||
@@ -10,7 +10,7 @@
|
||||
Locale files has been successfully imported!
|
||||
```
|
||||
|
||||
1. Run `task web` to start the web server, then visit the site in the browser to make sure nothing blows up.
|
||||
1. Run `moon run gogs:dev` to start the web server, then visit the site in the browser to make sure nothing blows up.
|
||||
1. Check out a new branch using `git checkout -b update-locales`.
|
||||
1. Stage changes
|
||||
1. Run `git commit -m "locale: sync from Crowdin"`.
|
||||
|
||||
@@ -23,7 +23,7 @@ Gogs has the following dependencies:
|
||||
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) (v1.8.3 or higher)
|
||||
- [Go](https://golang.org/doc/install) (v1.20 or higher)
|
||||
- [Less.js](http://lesscss.org/usage/#command-line-usage-installing)
|
||||
- [Task](https://github.com/go-task/task) (v3)
|
||||
- [Moon](https://moonrepo.dev/docs/install)
|
||||
- [goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports)
|
||||
- [go-mockgen](https://github.com/derision-test/go-mockgen)
|
||||
- Database upon your choice (pick one, we choose PostgreSQL in this document):
|
||||
@@ -38,13 +38,16 @@ Gogs has the following dependencies:
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
brew install go postgresql git npm go-task/tap/go-task
|
||||
brew install go postgresql git npm moon portless
|
||||
portless trust
|
||||
npm install -g less
|
||||
npm install -g less-plugin-clean-css
|
||||
go install github.com/derision-test/go-mockgen/cmd/go-mockgen@v1.3.3
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
```
|
||||
|
||||
`portless trust` adds the local CA to your system trust store so `https://gogs.localhost` works without browser warnings. The `moon run gogs:dev` task will start the proxy and register the route automatically.
|
||||
|
||||
1. Configure PostgreSQL to start automatically:
|
||||
|
||||
```bash
|
||||
@@ -78,11 +81,12 @@ Gogs has the following dependencies:
|
||||
```bash
|
||||
sudo apt install -y make git-all postgresql postgresql-contrib golang-go nodejs
|
||||
npm install -g less
|
||||
go install github.com/go-task/task/v3/cmd/task@latest
|
||||
go install github.com/derision-test/go-mockgen/cmd/go-mockgen@v1.3.3
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
```
|
||||
|
||||
1. Install [Moon](https://moonrepo.dev/docs/install).
|
||||
|
||||
1. Configure startup services:
|
||||
|
||||
```bash
|
||||
@@ -146,10 +150,10 @@ SSL_MODE = disable
|
||||
The following command will start the web server and automatically recompile and restart the server if any Go files changed:
|
||||
|
||||
```bash
|
||||
task web --watch
|
||||
moon run gogs:dev
|
||||
```
|
||||
|
||||
**NOTE** If you changed any file under `conf/`, `template/` or `public/` directory, be sure to run `task generate` afterwards!
|
||||
**NOTE** If you changed any file under `conf/`, `templates/` or `public/` directory, be sure to rerun `moon run gogs:dev` afterwards!
|
||||
|
||||
## Other nice things
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ If you choose to use MySQL or PostgreSQL as your database backend, you need to f
|
||||
|
||||
|Source| Description | Note|
|
||||
|------|------------------------------------------|-----|
|
||||
|Packager.io ([link](https://packager.io/gh/gogs/gogs))|Every commit of `main`|After installation, place custom configuration in `/etc/default/gogs`.|
|
||||
|Arch User Repository ([link](https://aur.archlinux.org/packages/gogs/))| Stable releases | Detailed instructions available in the [Arch Linux Wiki entry](https://wiki.archlinux.org/title/Gogs). |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -5,20 +5,25 @@ go 1.26.0
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/Masterminds/semver/v3 v3.4.0
|
||||
github.com/cockroachdb/errors v1.12.0
|
||||
github.com/cockroachdb/errors v1.13.0
|
||||
github.com/derision-test/go-mockgen/v2 v2.1.1
|
||||
github.com/editorconfig/editorconfig-core-go/v2 v2.6.4
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/flamego/binding v1.3.0
|
||||
github.com/flamego/cache v1.5.1
|
||||
github.com/flamego/captcha v1.3.0
|
||||
github.com/flamego/flamego v1.12.0
|
||||
github.com/flamego/session v1.3.0
|
||||
github.com/flamego/validator v1.0.0
|
||||
github.com/glebarez/go-sqlite v1.21.2
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-macaron/binding v1.2.0
|
||||
github.com/go-macaron/cache v0.0.0-20190810181446-10f7c57e2196
|
||||
github.com/go-macaron/captcha v0.2.0
|
||||
github.com/go-macaron/csrf v0.0.0-20190812063352-946f6d303a4c
|
||||
github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07
|
||||
github.com/go-macaron/i18n v0.6.0
|
||||
github.com/go-macaron/session v1.0.3
|
||||
github.com/go-macaron/toolbox v0.0.0-20190813233741-94defb8383c6
|
||||
github.com/go-macaron/session v1.0.4
|
||||
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561
|
||||
github.com/gogs/cron v0.0.0-20171120032916-9f6c956d3e14
|
||||
github.com/gogs/git-module v1.8.7
|
||||
@@ -45,7 +50,7 @@ require (
|
||||
github.com/urfave/cli/v3 v3.6.2
|
||||
github.com/wneessen/go-mail v0.7.2
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/image v0.38.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/text v0.35.0
|
||||
gopkg.in/ini.v1 v1.67.1
|
||||
@@ -61,15 +66,23 @@ require (
|
||||
|
||||
require (
|
||||
bitbucket.org/creachadair/shell v0.0.7 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.1 // indirect
|
||||
charm.land/log/v2 v2.0.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/alecthomas/participle/v2 v2.1.4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.6.2 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
|
||||
github.com/cockroachdb/redact v1.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
@@ -77,34 +90,38 @@ require (
|
||||
github.com/djherbis/buffer v1.2.0 // indirect
|
||||
github.com/djherbis/nio/v3 v3.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/getsentry/sentry-go v0.27.0 // indirect
|
||||
github.com/getsentry/sentry-go v0.46.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.2.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-macaron/inject v0.0.0-20200308113650-138e5925c53b // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/go-querystring v1.0.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/itchyny/gojq v0.12.11 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.5 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/lib/pq v1.10.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
@@ -115,10 +132,13 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.5.5 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.bobheadxi.dev/streamline v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.11.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.11.0 // indirect
|
||||
@@ -136,6 +156,3 @@ require (
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.39.0 // indirect
|
||||
)
|
||||
|
||||
// +heroku goVersion go1.26
|
||||
// +heroku install ./cmd/gogs
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
bitbucket.org/creachadair/shell v0.0.7 h1:Z96pB6DkSb7F3Y3BBnJeOZH2gazyMTWlvecSD4vDqfk=
|
||||
bitbucket.org/creachadair/shell v0.0.7/go.mod h1:oqtXSSvSYr4624lnnabXHaBsYW6RD80caLi2b3hJk0U=
|
||||
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
|
||||
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
|
||||
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.37.4 h1:glPeL3BQJsbF6aIIYfZizMwc5LTYz250bDMjttbBGAU=
|
||||
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
|
||||
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
gitea.com/lunny/log v0.0.0-20190322053110-01b5df579c4e/go.mod h1:uJEsN4LQpeGYRCjuPXPZBClU7N5pWzGuyF4uqLpE/e0=
|
||||
gitea.com/lunny/nodb v0.0.0-20200923032308-3238c4655727/go.mod h1:h0OwsgcpJLSYtHcM5+Xciw9OEeuxi6ty4HDiO8C7aIY=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
@@ -15,6 +21,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
|
||||
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
@@ -27,23 +39,36 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668 h1:U/lr3Dgy4WK+hNk4tyD+nuGjpVLPEHuJSFXMw11/HPA=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
|
||||
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo=
|
||||
github.com/cockroachdb/errors v1.12.0/go.mod h1:SvzfYNNBshAVbZ8wzNc/UPK3w1vf0dKDUP41ucAIf7g=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cockroachdb/errors v1.13.0 h1:BoCcJeiP9hpBJDETkX19qi8Tb8So37srSsp3stTaDMQ=
|
||||
github.com/cockroachdb/errors v1.13.0/go.mod h1:bjxt/4E5+OyuAnacpTIU9rn2mzPu1VlthvHP+xpROq0=
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
|
||||
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
|
||||
@@ -81,13 +106,25 @@ github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaB
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/flamego/binding v1.3.0 h1:CPbnSuP0SxT50JR7lK2khTjcQi1oOECqRK7kbOYw91U=
|
||||
github.com/flamego/binding v1.3.0/go.mod h1:xgm6FEpEKKkF8CQilK2X3MJ5kTjOTnYdz/ooFctDTdc=
|
||||
github.com/flamego/cache v1.5.1 h1:2B4QhLFV7je0oUMCVKsAGAT+OyDHlXhozOoUffm+O3s=
|
||||
github.com/flamego/cache v1.5.1/go.mod h1:cTWYm/Ls35KKHo8vwcKgTlJUNXswEhzFWqVCTFzj24s=
|
||||
github.com/flamego/captcha v1.3.0 h1:CyQivqkiO4zT0nJY2vO0ySdOi85Z7EyESGMXvNQmi5U=
|
||||
github.com/flamego/captcha v1.3.0/go.mod h1:fCjE5o1cJXQkVJ2aYk7ISIBohfbNy1WxI2A3Ervzyp8=
|
||||
github.com/flamego/flamego v1.12.0 h1:BS0iY6RytweVvu5j40fQJ53X2ZcUVeuQ8ZSigVkDB9A=
|
||||
github.com/flamego/flamego v1.12.0/go.mod h1:MM4kNGS7SvJtwUZYb2oGySR+ncdtIvtJHsl8OhH1Ngo=
|
||||
github.com/flamego/session v1.3.0 h1:mj+fyNnJeM9aNXx2CGKppH5VFFUVHNEkhjObJIVH9hY=
|
||||
github.com/flamego/session v1.3.0/go.mod h1:x4oNtRuWDnaA2uRylTm3kShbCI3lTWM+dUHuJyeeiZE=
|
||||
github.com/flamego/validator v1.0.0 h1:ixuWHVgiVGp4pVGtUn/0d6HBjZJbbXfJHDNkxW+rZoY=
|
||||
github.com/flamego/validator v1.0.0/go.mod h1:POYn0/5iW4sdamdPAYPrzqN6DFC4YaczY0gYY+Pyx5E=
|
||||
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
|
||||
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
|
||||
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
|
||||
github.com/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0=
|
||||
github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
@@ -100,6 +137,8 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
|
||||
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -109,8 +148,6 @@ github.com/go-macaron/binding v1.2.0 h1:/A8x8ZVQNTzFO43ch8czTqhc4VzOEPXYU/ELjIyh
|
||||
github.com/go-macaron/binding v1.2.0/go.mod h1:8pXMCyR9UPsXV02PYGLI+t2Xep/v2OgVuuLTNtCG03c=
|
||||
github.com/go-macaron/cache v0.0.0-20190810181446-10f7c57e2196 h1:fqWZxyMLF6RVGmjvsZ9FijiU9UlAjuE6nu9RfNBZ+iE=
|
||||
github.com/go-macaron/cache v0.0.0-20190810181446-10f7c57e2196/go.mod h1:O6fSdaYZbGh4clVMGMGO5k2KbMO0Cz8YdBnPrD0I8dM=
|
||||
github.com/go-macaron/captcha v0.2.0 h1:d38eYDDF8tdqoM0hJbk+Jb7WQGWlwYNnQwRqLRmSk1Y=
|
||||
github.com/go-macaron/captcha v0.2.0/go.mod h1:lmhlZnu9cTRGNQEkSh1qZi2IK3HJH4Z1MXkg6ARQKZA=
|
||||
github.com/go-macaron/csrf v0.0.0-20190812063352-946f6d303a4c h1:kFFz1OpaH3+efG7RA33z+D0piwpA/a3x/Zn2d8z9rfw=
|
||||
github.com/go-macaron/csrf v0.0.0-20190812063352-946f6d303a4c/go.mod h1:FX53Xq0NNlUj0E5in5J8Dq5nrbdK3ZyDIy6y5VWOiUo=
|
||||
github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07 h1:YSIA98PevNf1NtCa/J6cz7gjzpz99WVAOa9Eg0klKps=
|
||||
@@ -121,15 +158,16 @@ github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2
|
||||
github.com/go-macaron/inject v0.0.0-20200308113650-138e5925c53b h1:/aWj44HoEycE4MDi2HZf4t+XI7hKwZRltZf4ih5tB2c=
|
||||
github.com/go-macaron/inject v0.0.0-20200308113650-138e5925c53b/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw=
|
||||
github.com/go-macaron/session v0.0.0-20190805070824-1a3cdc6f5659/go.mod h1:tLd0QEudXocQckwcpCq5pCuTCuYc24I0bRJDuRe9OuQ=
|
||||
github.com/go-macaron/session v1.0.3 h1:YnSfcm24a4HHRnZzBU30FGvoo4kR6vYbTeyTlA1dya4=
|
||||
github.com/go-macaron/session v1.0.3/go.mod h1:NKoSrKpBFGEgeDtdLr/mnGaxa2LZVOg8/LwZKwPgQr0=
|
||||
github.com/go-macaron/toolbox v0.0.0-20190813233741-94defb8383c6 h1:x/v1iUWlqXTKVg17ulB0qCgcM2s+eysAbr/dseKLLss=
|
||||
github.com/go-macaron/toolbox v0.0.0-20190813233741-94defb8383c6/go.mod h1:YFNJ/JT4yLnpuIXTFef30SZkxGHUczjGZGFaZpPcdn0=
|
||||
github.com/go-macaron/session v1.0.4 h1:fIvtOwdYBsqlb+icre1LvWB7YKnosfoSpaqT1nybh8E=
|
||||
github.com/go-macaron/session v1.0.4/go.mod h1:NKoSrKpBFGEgeDtdLr/mnGaxa2LZVOg8/LwZKwPgQr0=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk8V3XHWUcJmYTh+ZnlHVyc+A4oZYS3Y=
|
||||
@@ -148,6 +186,8 @@ github.com/gogs/go-libravatar v0.0.0-20191106065024-33a75213d0a0 h1:K02vod+sn3M1
|
||||
github.com/gogs/go-libravatar v0.0.0-20191106065024-33a75213d0a0/go.mod h1:Zas3BtO88pk1cwUfEYlvnl/CRwh0ybDxRWSwRjG8I3w=
|
||||
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a h1:8DZwxETOVWIinYxDK+i6L+rMb7eGATGaakD6ZucfHVk=
|
||||
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a/go.mod h1:TUIZ+29jodWQ8Gk6Pvtg4E09aMsc3C/VLZiVYfUhWQU=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
@@ -219,8 +259,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -260,10 +300,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lunny/log v0.0.0-20160921050905-7887c61bf0de/go.mod h1:3q8WtuPQsoRbatJuy3nvq/hRSvuBJrHHr+ybPPiNvHQ=
|
||||
github.com/lunny/nodb v0.0.0-20160621015157-fc1ef06ad4af/go.mod h1:Cqz6pqow14VObJ7peltM+2n3PWOz7yTrfUuGbVFkzN0=
|
||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
@@ -286,6 +330,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/msteinert/pam v1.2.0 h1:mYfjlvN2KYs2Pb9G6nb/1f/nPfAttT/Jee5Sq9r3bGE=
|
||||
github.com/msteinert/pam v1.2.0/go.mod h1:d2n0DCUK8rGecChV3JzvmsDjOY4R7AYbsNxAT+ftQl0=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
@@ -353,8 +399,12 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM=
|
||||
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
@@ -413,6 +463,8 @@ github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
|
||||
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
|
||||
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
@@ -439,13 +491,14 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -468,6 +521,7 @@ golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
@@ -503,6 +557,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
@@ -588,6 +644,7 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210105161348-2e78108cf5f8/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
||||
|
||||
@@ -2,12 +2,19 @@ package smtp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
)
|
||||
|
||||
// dialTimeout bounds how long the SMTP authentication flow waits on the
|
||||
// underlying TCP connect. Without it, an unreachable or misspelled host hangs
|
||||
// the sign-in request until the OS-level connect timeout (minutes).
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
// Config contains configuration for SMTP authentication.
|
||||
//
|
||||
// ⚠️ WARNING: Change to the field name must preserve the INI key name for backward compatibility.
|
||||
@@ -21,10 +28,16 @@ type Config struct {
|
||||
}
|
||||
|
||||
func (c *Config) doAuth(auth smtp.Auth) error {
|
||||
client, err := smtp.Dial(fmt.Sprintf("%s:%d", c.Host, c.Port))
|
||||
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
|
||||
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(conn, c.Host)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Hello("gogs"); err != nil {
|
||||
|
||||
+29
-1
@@ -1,6 +1,7 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
_ "github.com/go-macaron/cache/memcache"
|
||||
"github.com/fatih/color"
|
||||
_ "github.com/go-macaron/cache/redis"
|
||||
_ "github.com/go-macaron/session/redis"
|
||||
"github.com/gogs/go-libravatar"
|
||||
@@ -23,6 +24,13 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
// fatih/color disables ANSI codes when stdout is not a TTY, which is the
|
||||
// case under process managers like moon. Honor TTY_FORCE so callers can
|
||||
// opt back in without a real terminal.
|
||||
if os.Getenv("TTY_FORCE") != "" {
|
||||
color.NoColor = false
|
||||
}
|
||||
|
||||
// Initialize the primary logger until logging service is up.
|
||||
err := log.NewConsole()
|
||||
if err != nil {
|
||||
@@ -221,6 +229,26 @@ func Init(customConf string) error {
|
||||
if err = File.Section("auth").MapTo(&Auth); err != nil {
|
||||
return errors.Wrap(err, "mapping [auth] section")
|
||||
}
|
||||
// Reset before re-parsing so repeated Init calls (e.g. via the web installer)
|
||||
// do not carry over CIDRs from a previous configuration.
|
||||
Auth.TrustedProxyCIDRs = nil
|
||||
for _, raw := range Auth.TrustedProxyIPs {
|
||||
// Allow bare IPs as a convenience by promoting them to single-host CIDRs.
|
||||
if !strings.Contains(raw, "/") {
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
raw += "/32"
|
||||
} else {
|
||||
raw += "/128"
|
||||
}
|
||||
}
|
||||
}
|
||||
_, cidr, err := net.ParseCIDR(raw)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "parse trusted proxy CIDR %q", raw)
|
||||
}
|
||||
Auth.TrustedProxyCIDRs = append(Auth.TrustedProxyCIDRs, cidr)
|
||||
}
|
||||
|
||||
// *************************
|
||||
// ----- User settings -----
|
||||
|
||||
+15
-12
@@ -2,6 +2,7 @@ package conf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
@@ -39,8 +40,6 @@ var (
|
||||
InstallLock bool
|
||||
SecretKey string
|
||||
LoginRememberDays int
|
||||
CookieRememberName string
|
||||
CookieUsername string
|
||||
CookieSecure bool
|
||||
EnableLoginStatusCookie bool
|
||||
LoginStatusCookieName string
|
||||
@@ -86,13 +85,6 @@ var (
|
||||
CSRFCookieName string `ini:"CSRF_COOKIE_NAME"`
|
||||
}
|
||||
|
||||
// Cache settings
|
||||
Cache struct {
|
||||
Adapter string
|
||||
Interval int
|
||||
Host string
|
||||
}
|
||||
|
||||
// HTTP settings
|
||||
HTTP struct {
|
||||
AccessControlAllowOrigin string
|
||||
@@ -221,7 +213,6 @@ var (
|
||||
|
||||
// Other settings
|
||||
Other struct {
|
||||
ShowFooterBranding bool
|
||||
ShowFooterTemplateLoadTime bool
|
||||
}
|
||||
|
||||
@@ -229,6 +220,14 @@ var (
|
||||
HasRobotsTxt bool
|
||||
)
|
||||
|
||||
type CacheOptions struct {
|
||||
Adapter string
|
||||
Interval int
|
||||
Host string
|
||||
}
|
||||
|
||||
var Cache CacheOptions
|
||||
|
||||
type AppOpts struct {
|
||||
// ⚠️ WARNING: Should only be set by the main package (i.e. "cmd/gogs/main.go").
|
||||
Version string `ini:"-"`
|
||||
@@ -252,7 +251,11 @@ type AuthOpts struct {
|
||||
EnableReverseProxyAuthentication bool
|
||||
EnableReverseProxyAutoRegistration bool
|
||||
ReverseProxyAuthenticationHeader string
|
||||
CustomLogoutURL string `ini:"CUSTOM_LOGOUT_URL"`
|
||||
TrustedProxyIPs []string `ini:"TRUSTED_PROXY_IPS"`
|
||||
CustomLogoutURL string `ini:"CUSTOM_LOGOUT_URL"`
|
||||
|
||||
// Derived from other static values
|
||||
TrustedProxyCIDRs []*net.IPNet `ini:"-"` // Parsed CIDR form of TrustedProxyIPs.
|
||||
}
|
||||
|
||||
// Authentication settings
|
||||
@@ -263,7 +266,7 @@ type ServerOpts struct {
|
||||
Domain string
|
||||
Protocol string
|
||||
HTTPAddr string `ini:"HTTP_ADDR"`
|
||||
HTTPPort string `ini:"HTTP_PORT"`
|
||||
HTTPPort int `ini:"HTTP_PORT"`
|
||||
CertFile string
|
||||
KeyFile string
|
||||
TLSMinVersion string `ini:"TLS_MIN_VERSION"`
|
||||
|
||||
@@ -48,7 +48,6 @@ func TestCheckInvalidOptions(t *testing.T) {
|
||||
_, _ = cfg.Section("server").NewKey("LANDING_PAGE", "true")
|
||||
_, _ = cfg.Section("database").NewKey("DB_TYPE", "true")
|
||||
_, _ = cfg.Section("database").NewKey("PASSWD", "true")
|
||||
_, _ = cfg.Section("other").NewKey("SHOW_FOOTER_BRANDING", "true")
|
||||
_, _ = cfg.Section("other").NewKey("SHOW_FOOTER_TEMPLATE_LOAD_TIME", "true")
|
||||
_, _ = cfg.Section("email").NewKey("ENABLED", "true")
|
||||
_, _ = cfg.Section("server").NewKey("NONEXISTENT_OPTION", "true")
|
||||
|
||||
+1
-2
@@ -74,8 +74,6 @@ MAX_IDLE_CONNS=30
|
||||
INSTALL_LOCK=false
|
||||
SECRET_KEY=`!#@FDEWREWR&*(`
|
||||
LOGIN_REMEMBER_DAYS=7
|
||||
COOKIE_REMEMBER_NAME=gogs_incredible
|
||||
COOKIE_USERNAME=gogs_awesome
|
||||
COOKIE_SECURE=false
|
||||
ENABLE_LOGIN_STATUS_COOKIE=false
|
||||
LOGIN_STATUS_COOKIE_NAME=login_status
|
||||
@@ -106,6 +104,7 @@ ENABLE_REGISTRATION_CAPTCHA=true
|
||||
ENABLE_REVERSE_PROXY_AUTHENTICATION=false
|
||||
ENABLE_REVERSE_PROXY_AUTO_REGISTRATION=false
|
||||
REVERSE_PROXY_AUTHENTICATION_HEADER=X-FORWARDED-FOR
|
||||
TRUSTED_PROXY_IPS=127.0.0.0/8,::1/128
|
||||
CUSTOM_LOGOUT_URL=
|
||||
|
||||
[user]
|
||||
|
||||
+52
-12
@@ -2,6 +2,7 @@ package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -70,24 +71,22 @@ func Toggle(options *ToggleOptions) macaron.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if isWebPath(c.Req.URL.Path) {
|
||||
c.ServeWeb()
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("redirect_to", url.QueryEscape(conf.Server.Subpath+c.Req.RequestURI), 0, conf.Server.Subpath)
|
||||
c.RedirectSubpath("/user/login")
|
||||
c.RedirectSubpath("/user/sign-in")
|
||||
return
|
||||
} else if !c.User.IsActive && conf.Auth.RequireEmailConfirmation {
|
||||
c.Title("auth.active_your_account")
|
||||
c.Success("user/auth/activate")
|
||||
// Inactive users get bounced to the React activation page, which
|
||||
// is responsible for offering a resend and showing status.
|
||||
c.RedirectSubpath("/user/activate")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to log in page if auto-signin info is provided and has not signed in.
|
||||
if !options.SignOutRequired && !c.IsLogged && !isAPIPath(c.Req.URL.Path) &&
|
||||
len(c.GetCookie(conf.Security.CookieUsername)) > 0 {
|
||||
c.SetCookie("redirect_to", url.QueryEscape(conf.Server.Subpath+c.Req.RequestURI), 0, conf.Server.Subpath)
|
||||
c.RedirectSubpath("/user/login")
|
||||
return
|
||||
}
|
||||
|
||||
if options.AdminRequired {
|
||||
if !c.User.IsAdmin {
|
||||
c.Status(http.StatusForbidden)
|
||||
@@ -102,6 +101,21 @@ func isAPIPath(url string) bool {
|
||||
return strings.HasPrefix(url, "/api/")
|
||||
}
|
||||
|
||||
func isWebPath(p string) bool {
|
||||
p = strings.TrimPrefix(p, conf.Server.Subpath)
|
||||
switch {
|
||||
case p == "/user/sign-in",
|
||||
p == "/user/mfa",
|
||||
strings.HasPrefix(p, "/assets/"),
|
||||
strings.HasPrefix(p, "/src/"),
|
||||
strings.HasPrefix(p, "/node_modules/"),
|
||||
strings.HasPrefix(p, "/@"),
|
||||
strings.HasPrefix(p, "/img/"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type AuthStore interface {
|
||||
// GetAccessTokenBySHA1 returns the access token with given SHA1. It returns
|
||||
// database.ErrAccessTokenNotExist when not found.
|
||||
@@ -198,7 +212,7 @@ func authenticatedUser(store AuthStore, ctx *macaron.Context, sess session.Store
|
||||
uid, isTokenAuth := authenticatedUserID(store, ctx, sess)
|
||||
|
||||
if uid <= 0 {
|
||||
if conf.Auth.EnableReverseProxyAuthentication {
|
||||
if conf.Auth.EnableReverseProxyAuthentication && isRequestFromTrustedProxy(ctx.Req.Request) {
|
||||
webAuthUser := ctx.Req.Header.Get(conf.Auth.ReverseProxyAuthenticationHeader)
|
||||
if len(webAuthUser) > 0 {
|
||||
user, err := store.GetUserByUsername(ctx.Req.Context(), webAuthUser)
|
||||
@@ -257,6 +271,32 @@ func authenticatedUser(store AuthStore, ctx *macaron.Context, sess session.Store
|
||||
return u, false, isTokenAuth
|
||||
}
|
||||
|
||||
// isRequestFromTrustedProxy reports whether the request's immediate remote
|
||||
// address falls within one of the configured trusted proxy CIDR ranges. The
|
||||
// reverse proxy authentication header is only honored for such requests so an
|
||||
// attacker reaching Gogs directly cannot forge it.
|
||||
func isRequestFromTrustedProxy(req *http.Request) bool {
|
||||
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
// Normalize IPv4-mapped IPv6 (e.g. "::ffff:127.0.0.1" on dual-stack listeners)
|
||||
// to its IPv4 form so it matches IPv4 CIDRs like 127.0.0.0/8.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
for _, cidr := range conf.Auth.TrustedProxyCIDRs {
|
||||
if cidr.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AuthenticateByToken attempts to authenticate a user by the given access
|
||||
// token. It returns database.ErrAccessTokenNotExist when the access token does not
|
||||
// exist.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gogs.io/gogs/internal/conf"
|
||||
)
|
||||
|
||||
func TestIsRequestFromTrustedProxy(t *testing.T) {
|
||||
mustCIDR := func(s string) *net.IPNet {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
original := conf.Auth.TrustedProxyCIDRs
|
||||
t.Cleanup(func() { conf.Auth.TrustedProxyCIDRs = original })
|
||||
conf.Auth.TrustedProxyCIDRs = []*net.IPNet{
|
||||
mustCIDR("127.0.0.0/8"),
|
||||
mustCIDR("::1/128"),
|
||||
mustCIDR("10.1.0.0/16"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
want bool
|
||||
}{
|
||||
{name: "loopback IPv4 with port", remoteAddr: "127.0.0.1:54321", want: true},
|
||||
{name: "loopback IPv6 with port", remoteAddr: "[::1]:54321", want: true},
|
||||
{name: "within configured CIDR", remoteAddr: "10.1.2.3:8080", want: true},
|
||||
{name: "outside configured CIDR", remoteAddr: "203.0.113.5:443", want: false},
|
||||
{name: "IPv4-mapped IPv6 matches IPv4 CIDR", remoteAddr: "[::ffff:127.0.0.1]:54321", want: true},
|
||||
{name: "remote without port", remoteAddr: "127.0.0.1", want: false},
|
||||
{name: "unparseable remote", remoteAddr: "not-an-ip", want: false},
|
||||
{name: "empty remote", remoteAddr: "", want: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := &http.Request{RemoteAddr: tc.remoteAddr}
|
||||
require.Equal(t, tc.want, isRequestFromTrustedProxy(req))
|
||||
})
|
||||
}
|
||||
}
|
||||
+60
-12
@@ -1,6 +1,7 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -38,6 +39,8 @@ type Context struct {
|
||||
|
||||
Repo *Repository
|
||||
Org *Organization
|
||||
|
||||
webHandler http.Handler
|
||||
}
|
||||
|
||||
// RawTitle sets the "Title" field in template data.
|
||||
@@ -156,10 +159,54 @@ func (c *Context) RenderWithErr(msg string, status int, tpl string, f any) {
|
||||
c.HTML(status, tpl)
|
||||
}
|
||||
|
||||
// NotFound renders the 404 page.
|
||||
// WebContext carries per-request inputs into the web handler so it can
|
||||
// render the React shell. Fields are read by helpers like WebContextFrom.
|
||||
type WebContext struct {
|
||||
Lang string
|
||||
SubURL string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
// WebContextKey is the request context key for WebContext values. Exported
|
||||
// so callers outside this package (e.g. the web NotFound handler) can attach
|
||||
// a WebContext when the request bypasses Contexter.
|
||||
type WebContextKey struct{}
|
||||
|
||||
// WebContextFrom returns the WebContext attached to r, or a zero value with
|
||||
// sensible defaults when nothing was attached.
|
||||
func WebContextFrom(r *http.Request) WebContext {
|
||||
wr, ok := r.Context().Value(WebContextKey{}).(WebContext)
|
||||
if !ok {
|
||||
return WebContext{Lang: "en-US"}
|
||||
}
|
||||
if wr.Lang == "" {
|
||||
wr.Lang = "en-US"
|
||||
}
|
||||
return wr
|
||||
}
|
||||
|
||||
// NotFound renders the React 404 page through the web handler with a 404
|
||||
// status.
|
||||
func (c *Context) NotFound() {
|
||||
c.Title("status.page_not_found")
|
||||
c.HTML(http.StatusNotFound, fmt.Sprintf("status/%d", http.StatusNotFound))
|
||||
c.serveWeb(WebContext{
|
||||
Lang: c.Language(),
|
||||
SubURL: conf.Server.Subpath,
|
||||
StatusCode: http.StatusNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeWeb delegates the current request to the web handler. The web frontend
|
||||
// decides what to render based on the request path.
|
||||
func (c *Context) ServeWeb() {
|
||||
c.serveWeb(WebContext{
|
||||
Lang: c.Language(),
|
||||
SubURL: conf.Server.Subpath,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Context) serveWeb(wr WebContext) {
|
||||
ctx := stdctx.WithValue(c.Req.Context(), WebContextKey{}, wr)
|
||||
c.webHandler.ServeHTTP(c.Resp, c.Req.WithContext(ctx))
|
||||
}
|
||||
|
||||
// Error renders the 500 page.
|
||||
@@ -221,16 +268,18 @@ func (c *Context) ServeContent(name string, r io.ReadSeeker, params ...any) {
|
||||
// https://github.com/go-macaron/csrf/blob/5d38f39de352972063d1ef026fc477283841bb9b/csrf.go#L148.
|
||||
var csrfTokenExcludePattern = lazyregexp.New(`[^a-zA-Z0-9-_].*`)
|
||||
|
||||
// Contexter initializes a classic context for a request.
|
||||
func Contexter(store Store) macaron.Handler {
|
||||
// Contexter initializes a classic context for a request. webHandler
|
||||
// receives 404 responses so the React frontend can render its own 404 page.
|
||||
func Contexter(store Store, webHandler http.Handler) macaron.Handler {
|
||||
return func(ctx *macaron.Context, l i18n.Locale, cache cache.Cache, sess session.Store, f *session.Flash, x csrf.CSRF) {
|
||||
c := &Context{
|
||||
Context: ctx,
|
||||
Cache: cache,
|
||||
csrf: x,
|
||||
Flash: f,
|
||||
Session: sess,
|
||||
Link: conf.Server.Subpath + strings.TrimSuffix(ctx.Req.URL.Path, "/"),
|
||||
Context: ctx,
|
||||
Cache: cache,
|
||||
csrf: x,
|
||||
Flash: f,
|
||||
Session: sess,
|
||||
Link: conf.Server.Subpath + strings.TrimSuffix(ctx.Req.URL.Path, "/"),
|
||||
webHandler: webHandler,
|
||||
Repo: &Repository{
|
||||
PullRequest: &PullRequest{},
|
||||
},
|
||||
@@ -279,7 +328,6 @@ func Contexter(store Store) macaron.Handler {
|
||||
log.Trace("CSRF Token: %v", c.Data["CSRFToken"])
|
||||
|
||||
c.Data["ShowRegistrationButton"] = !conf.Auth.DisableRegistration
|
||||
c.Data["ShowFooterBranding"] = conf.Other.ShowFooterBranding
|
||||
|
||||
c.renderNoticeBanner()
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/go-macaron/binding"
|
||||
"gorm.io/gorm"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
@@ -128,11 +127,6 @@ func (s *UsersStore) Authenticate(ctx context.Context, login, password string, l
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Validate username make sure it satisfies requirement.
|
||||
if binding.AlphaDashDotPattern.MatchString(extAccount.Name) {
|
||||
return nil, errors.Newf("invalid pattern for attribute 'username' [%s]: must be valid alpha or numeric or dash(-_) or dot characters", extAccount.Name)
|
||||
}
|
||||
|
||||
return s.Create(ctx, extAccount.Name, extAccount.Email,
|
||||
CreateUserOptions{
|
||||
FullName: extAccount.FullName,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -711,7 +712,13 @@ func (t *HookTask) deliver() {
|
||||
Header("X-Gogs-Delivery", t.UUID).
|
||||
Header("X-Gogs-Signature", t.Signature).
|
||||
Header("X-Gogs-Event", string(t.EventType)).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: conf.Webhook.SkipTLSVerify})
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: conf.Webhook.SkipTLSVerify}).
|
||||
SetCheckRedirect(func(req *http.Request, _ []*http.Request) error {
|
||||
// The webhook target is explicitly configured by the user, so any
|
||||
// redirect would silently retarget the signed payload. Refuse all
|
||||
// redirects rather than chase them.
|
||||
return errors.Newf("refusing to follow webhook redirect to %q", req.URL.Redacted())
|
||||
})
|
||||
|
||||
switch t.ContentType {
|
||||
case JSON:
|
||||
|
||||
+140
-44
@@ -1,21 +1,33 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"gopkg.in/macaron.v1"
|
||||
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/markup"
|
||||
"gogs.io/gogs/templates"
|
||||
)
|
||||
|
||||
// Translator is the minimal locale-translation contract used by mail
|
||||
// composition. It decouples this package from any specific web framework so
|
||||
// callers can pass either macaron.Context or, post-migration, Flamego's
|
||||
// i18n.Locale.
|
||||
type Translator interface {
|
||||
Tr(format string, args ...any) string
|
||||
}
|
||||
|
||||
const (
|
||||
tmplAuthActivate = "auth/activate"
|
||||
tmplAuthActivateEmail = "auth/activate_email"
|
||||
@@ -29,46 +41,130 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
tplRender *macaron.TplRender
|
||||
tplRenderOnce sync.Once
|
||||
tplSet *template.Template
|
||||
tplSetOnce sync.Once
|
||||
tplSetErr error
|
||||
)
|
||||
|
||||
// render renders a mail template with given data.
|
||||
func render(tpl string, data map[string]any) (string, error) {
|
||||
tplRenderOnce.Do(func() {
|
||||
customDir := filepath.Join(conf.CustomDir(), "templates")
|
||||
opt := &macaron.RenderOptions{
|
||||
Directory: filepath.Join(conf.WorkDir(), "templates", "mail"),
|
||||
AppendDirectories: []string{filepath.Join(customDir, "mail")},
|
||||
Extensions: []string{".tmpl", ".html"},
|
||||
Funcs: []template.FuncMap{map[string]any{
|
||||
"AppName": func() string {
|
||||
return conf.App.BrandName
|
||||
},
|
||||
"AppURL": func() string {
|
||||
return conf.Server.ExternalURL
|
||||
},
|
||||
"Year": func() int {
|
||||
return time.Now().Year()
|
||||
},
|
||||
"Str2HTML": func(raw string) template.HTML {
|
||||
return template.HTML(markup.Sanitize(raw))
|
||||
},
|
||||
}},
|
||||
}
|
||||
if !conf.Server.LoadAssetsFromDisk {
|
||||
opt.TemplateFileSystem = templates.NewTemplateFileSystem("mail", customDir)
|
||||
}
|
||||
func funcMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"AppName": func() string {
|
||||
return conf.App.BrandName
|
||||
},
|
||||
"AppURL": func() string {
|
||||
return conf.Server.ExternalURL
|
||||
},
|
||||
"Year": func() int {
|
||||
return time.Now().Year()
|
||||
},
|
||||
"Str2HTML": func(raw string) template.HTML {
|
||||
return template.HTML(markup.Sanitize(raw))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
ts := macaron.NewTemplateSet()
|
||||
ts.Set(macaron.DEFAULT_TPL_SET_NAME, opt)
|
||||
tplRender = &macaron.TplRender{
|
||||
TemplateSet: ts,
|
||||
Opt: opt,
|
||||
// Recognized mail-template file extensions. A template's name is its path
|
||||
// relative to the "mail" directory, without extension (e.g. "auth/activate").
|
||||
var mailTemplateExts = []string{".tmpl", ".html"}
|
||||
|
||||
// loadMailTemplates parses every mail template under the embedded "mail" tree
|
||||
// (or "<work>/templates/mail" when LoadAssetsFromDisk is set), then overlays
|
||||
// files from "<custom>/templates/mail" so an admin can override any builtin.
|
||||
func loadMailTemplates() (*template.Template, error) {
|
||||
root := template.New("").Funcs(funcMap())
|
||||
parse := func(name string, data []byte) error {
|
||||
_, err := root.New(name).Parse(string(data))
|
||||
return errors.Wrapf(err, "parse %q", name)
|
||||
}
|
||||
|
||||
if conf.Server.LoadAssetsFromDisk {
|
||||
baseRoot := filepath.Join(conf.WorkDir(), "templates", "mail")
|
||||
if _, err := os.Stat(baseRoot); err != nil {
|
||||
return nil, errors.Wrapf(err, "stat base mail templates %q", baseRoot)
|
||||
}
|
||||
if err := overlayDiskMailTemplates(baseRoot, parse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
for _, name := range templates.MailFileNames() {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !slices.Contains(mailTemplateExts, ext) {
|
||||
continue
|
||||
}
|
||||
data, err := templates.ReadMailFile(name)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "read embedded %q", name)
|
||||
}
|
||||
if err := parse(strings.TrimSuffix(filepath.ToSlash(name), ext), data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := overlayDiskMailTemplates(filepath.Join(conf.CustomDir(), "templates", "mail"), parse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// overlayDiskMailTemplates walks root and parses every recognized template
|
||||
// file via parse. A missing root is not an error: custom overrides are optional.
|
||||
func overlayDiskMailTemplates(root string, parse func(name string, data []byte) error) error {
|
||||
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return fs.SkipAll
|
||||
}
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(p))
|
||||
if !slices.Contains(mailTemplateExts, ext) {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "read %q", p)
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parse(strings.TrimSuffix(filepath.ToSlash(rel), ext), data)
|
||||
})
|
||||
}
|
||||
|
||||
return tplRender.HTMLString(tpl, data)
|
||||
func render(tpl string, data map[string]any) (string, error) {
|
||||
set, err := mailTemplateSet()
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "load mail templates")
|
||||
}
|
||||
t := set.Lookup(tpl)
|
||||
if t == nil {
|
||||
return "", errors.Newf("mail template %q not found", tpl)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
return "", errors.Wrapf(err, "execute %q", tpl)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// mailTemplateSet returns the parsed template set. When assets are loaded from
|
||||
// disk, templates are reloaded on every call so admin edits under
|
||||
// <work>/templates/mail or <custom>/templates/mail take effect without a
|
||||
// restart — matching the hot-reload behavior of the previous macaron renderer
|
||||
// for non-production environments. When assets are embedded, the set is loaded
|
||||
// once and cached for the process lifetime.
|
||||
func mailTemplateSet() (*template.Template, error) {
|
||||
if conf.Server.LoadAssetsFromDisk {
|
||||
return loadMailTemplates()
|
||||
}
|
||||
tplSetOnce.Do(func() {
|
||||
tplSet, tplSetErr = loadMailTemplates()
|
||||
})
|
||||
return tplSet, tplSetErr
|
||||
}
|
||||
|
||||
func SendTestMail(email string) error {
|
||||
@@ -102,7 +198,7 @@ type Issue interface {
|
||||
HTMLURL() string
|
||||
}
|
||||
|
||||
func SendUserMail(_ *macaron.Context, u User, tpl, code, subject, info string) error {
|
||||
func SendUserMail(_ Translator, u User, tpl, code, subject, info string) error {
|
||||
data := map[string]any{
|
||||
"Username": u.DisplayName(),
|
||||
"ActiveCodeLives": conf.Auth.ActivateCodeLives / 60,
|
||||
@@ -124,15 +220,15 @@ func SendUserMail(_ *macaron.Context, u User, tpl, code, subject, info string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func SendActivateAccountMail(c *macaron.Context, u User) error {
|
||||
return SendUserMail(c, u, tmplAuthActivate, u.GenerateEmailActivateCode(u.Email()), c.Tr("mail.activate_account"), "activate account")
|
||||
func SendActivateAccountMail(t Translator, u User) error {
|
||||
return SendUserMail(t, u, tmplAuthActivate, u.GenerateEmailActivateCode(u.Email()), t.Tr("mail.activate_account"), "activate account")
|
||||
}
|
||||
|
||||
func SendResetPasswordMail(c *macaron.Context, u User) error {
|
||||
return SendUserMail(c, u, tmplAuthResetPassword, u.GenerateEmailActivateCode(u.Email()), c.Tr("mail.reset_password"), "reset password")
|
||||
func SendResetPasswordMail(t Translator, u User) error {
|
||||
return SendUserMail(t, u, tmplAuthResetPassword, u.GenerateEmailActivateCode(u.Email()), t.Tr("mail.reset_password"), "reset password")
|
||||
}
|
||||
|
||||
func SendActivateEmailMail(c *macaron.Context, u User, email string) error {
|
||||
func SendActivateEmailMail(t Translator, u User, email string) error {
|
||||
data := map[string]any{
|
||||
"Username": u.DisplayName(),
|
||||
"ActiveCodeLives": conf.Auth.ActivateCodeLives / 60,
|
||||
@@ -144,7 +240,7 @@ func SendActivateEmailMail(c *macaron.Context, u User, email string) error {
|
||||
return errors.Wrap(err, "render")
|
||||
}
|
||||
|
||||
msg, err := newMessage([]string{email}, c.Tr("mail.activate_email"), body)
|
||||
msg, err := newMessage([]string{email}, t.Tr("mail.activate_email"), body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "new message")
|
||||
}
|
||||
@@ -154,7 +250,7 @@ func SendActivateEmailMail(c *macaron.Context, u User, email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func SendRegisterNotifyMail(c *macaron.Context, u User) error {
|
||||
func SendRegisterNotifyMail(t Translator, u User) error {
|
||||
data := map[string]any{
|
||||
"Username": u.DisplayName(),
|
||||
}
|
||||
@@ -163,7 +259,7 @@ func SendRegisterNotifyMail(c *macaron.Context, u User) error {
|
||||
return errors.Wrap(err, "render")
|
||||
}
|
||||
|
||||
msg, err := newMessage([]string{u.Email()}, c.Tr("mail.register_notify"), body)
|
||||
msg, err := newMessage([]string{u.Email()}, t.Tr("mail.register_notify"), body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "new message")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gogs.io/gogs/internal/conf"
|
||||
)
|
||||
|
||||
// TestRenderEmbeddedTemplates ensures every builtin mail template parses and
|
||||
// executes against the data shape its production caller supplies, so a syntax
|
||||
// regression or missing field is caught at build time, not on the first email.
|
||||
func TestRenderEmbeddedTemplates(t *testing.T) {
|
||||
conf.SetMockApp(t, conf.AppOpts{BrandName: "Gogs"})
|
||||
conf.SetMockServer(t, conf.ServerOpts{
|
||||
ExternalURL: "https://example.test/",
|
||||
LoadAssetsFromDisk: false,
|
||||
})
|
||||
resetTemplateCache(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{
|
||||
name: tmplAuthActivate,
|
||||
data: map[string]any{
|
||||
"Username": "alice",
|
||||
"ActiveCodeLives": 1440,
|
||||
"ResetPwdCodeLives": 1440,
|
||||
"Code": "abc",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: tmplAuthActivateEmail,
|
||||
data: map[string]any{
|
||||
"Username": "alice",
|
||||
"ActiveCodeLives": 1440,
|
||||
"Code": "abc",
|
||||
"Email": "alice@example.test",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: tmplAuthResetPassword,
|
||||
data: map[string]any{
|
||||
"Username": "alice",
|
||||
"ActiveCodeLives": 1440,
|
||||
"ResetPwdCodeLives": 1440,
|
||||
"Code": "abc",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: tmplAuthRegisterNotify,
|
||||
data: map[string]any{"Username": "alice"},
|
||||
},
|
||||
{
|
||||
name: tmplNotifyCollaborator,
|
||||
data: map[string]any{
|
||||
"Subject": "alice added you to bob/repo",
|
||||
"RepoName": "bob/repo",
|
||||
"Link": "https://example.test/bob/repo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: tmplIssueComment,
|
||||
data: map[string]any{
|
||||
"Subject": "[bob/repo] Re: Issue title",
|
||||
"Body": "<p>comment body</p>",
|
||||
"Link": "https://example.test/bob/repo/issues/1",
|
||||
"Doer": testDoer{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: tmplIssueMention,
|
||||
data: map[string]any{
|
||||
"Subject": "[bob/repo] @alice mentioned you",
|
||||
"Body": "<p>mention body</p>",
|
||||
"Link": "https://example.test/bob/repo/issues/1",
|
||||
"Doer": testDoer{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, err := render(tc.name, tc.data)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, body)
|
||||
assert.False(t, strings.Contains(body, "<no value>"), "template referenced a missing data key")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderUnknownTemplate asserts callers get a useful error rather than an
|
||||
// empty body when asking for a name that doesn't exist.
|
||||
func TestRenderUnknownTemplate(t *testing.T) {
|
||||
conf.SetMockServer(t, conf.ServerOpts{LoadAssetsFromDisk: false})
|
||||
resetTemplateCache(t)
|
||||
|
||||
_, err := render("does/not/exist", nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
// resetTemplateCache forces the next render call to reload templates, so each
|
||||
// test starts from a clean state regardless of execution order.
|
||||
func resetTemplateCache(t *testing.T) {
|
||||
t.Helper()
|
||||
tplSet = nil
|
||||
tplSetErr = nil
|
||||
tplSetOnce = sync.Once{}
|
||||
}
|
||||
|
||||
// testDoer satisfies the User interface for fields the issue templates touch.
|
||||
type testDoer struct{}
|
||||
|
||||
func (testDoer) ID() int64 { return 1 }
|
||||
func (testDoer) DisplayName() string { return "alice" }
|
||||
func (testDoer) Email() string { return "alice@example.test" }
|
||||
func (testDoer) GenerateEmailActivateCode(string) string { return "abc" }
|
||||
@@ -41,7 +41,7 @@ func Assign(form any, data map[string]any) {
|
||||
typ := reflect.TypeOf(form)
|
||||
val := reflect.ValueOf(form)
|
||||
|
||||
if typ.Kind() == reflect.Ptr {
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
typ = typ.Elem()
|
||||
val = val.Elem()
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func validate(errs binding.Errors, data map[string]any, f Form, l macaron.Locale
|
||||
Assign(f, data)
|
||||
|
||||
typ := reflect.TypeOf(f)
|
||||
if typ.Kind() == reflect.Ptr {
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
typ = typ.Elem()
|
||||
}
|
||||
|
||||
|
||||
@@ -54,35 +54,6 @@ func (f *Install) Validate(ctx *macaron.Context, errs binding.Errors) binding.Er
|
||||
return validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
||||
|
||||
// _____ ____ _________________ ___
|
||||
// / _ \ | | \__ ___/ | \
|
||||
// / /_\ \| | / | | / ~ \
|
||||
// / | \ | / | | \ Y /
|
||||
// \____|__ /______/ |____| \___|_ /
|
||||
// \/ \/
|
||||
|
||||
type Register struct {
|
||||
UserName string `binding:"Required;AlphaDashDot;MaxSize(35)"`
|
||||
Email string `binding:"Required;Email;MaxSize(254)"`
|
||||
Password string `binding:"Required;MaxSize(255)"`
|
||||
Retype string
|
||||
}
|
||||
|
||||
func (f *Register) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
|
||||
return validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
||||
|
||||
type SignIn struct {
|
||||
UserName string `binding:"Required;MaxSize(254)"`
|
||||
Password string `binding:"Required;MaxSize(255)"`
|
||||
LoginSource int64
|
||||
Remember bool
|
||||
}
|
||||
|
||||
func (f *SignIn) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
|
||||
return validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
||||
|
||||
// __________________________________________.___ _______ ________ _________
|
||||
// / _____/\_ _____/\__ ___/\__ ___/| |\ \ / _____/ / _____/
|
||||
// \_____ \ | __)_ | | | | | |/ | \/ \ ___ \_____ \
|
||||
|
||||
@@ -23,7 +23,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
defaultSetting = Settings{false, "GogsServer", 60 * time.Second, 60 * time.Second, nil, nil, nil, false}
|
||||
defaultSetting = Settings{
|
||||
UserAgent: "GogsServer",
|
||||
ConnectTimeout: 60 * time.Second,
|
||||
ReadWriteTimeout: 60 * time.Second,
|
||||
}
|
||||
defaultCookieJar http.CookieJar
|
||||
settingMutex sync.Mutex
|
||||
)
|
||||
@@ -95,6 +99,7 @@ type Settings struct {
|
||||
Proxy func(*http.Request) (*url.URL, error)
|
||||
Transport http.RoundTripper
|
||||
EnableCookie bool
|
||||
CheckRedirect func(req *http.Request, via []*http.Request) error
|
||||
}
|
||||
|
||||
// Request provides more useful methods for requesting a URL than http.Request.
|
||||
@@ -151,6 +156,13 @@ func (r *Request) SetTLSClientConfig(config *tls.Config) *Request {
|
||||
return r
|
||||
}
|
||||
|
||||
// SetCheckRedirect sets the policy invoked by the underlying HTTP client before
|
||||
// following a redirect. See http.Client.CheckRedirect for semantics.
|
||||
func (r *Request) SetCheckRedirect(fn func(req *http.Request, via []*http.Request) error) *Request {
|
||||
r.setting.CheckRedirect = fn
|
||||
return r
|
||||
}
|
||||
|
||||
// Header add header item string in request.
|
||||
func (r *Request) Header(key, value string) *Request {
|
||||
r.req.Header.Set(key, value)
|
||||
@@ -330,8 +342,9 @@ func (r *Request) getResponse() (*http.Response, error) {
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: trans,
|
||||
Jar: jar,
|
||||
Transport: trans,
|
||||
Jar: jar,
|
||||
CheckRedirect: r.setting.CheckRedirect,
|
||||
}
|
||||
|
||||
if len(r.setting.UserAgent) > 0 && r.req.Header.Get("User-Agent") == "" {
|
||||
|
||||
+2
-21
@@ -2,12 +2,8 @@ package route
|
||||
|
||||
import (
|
||||
gocontext "context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-macaron/i18n"
|
||||
"github.com/unknwon/paginater"
|
||||
"gopkg.in/macaron.v1"
|
||||
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/context"
|
||||
@@ -16,7 +12,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
tmplHome = "home"
|
||||
tmplExploreRepos = "explore/repos"
|
||||
tmplExploreUsers = "explore/users"
|
||||
tmplExploreOrganizations = "explore/organizations"
|
||||
@@ -25,23 +20,14 @@ const (
|
||||
func Home(c *context.Context) {
|
||||
if c.IsLogged {
|
||||
if !c.User.IsActive && conf.Auth.RequireEmailConfirmation {
|
||||
c.Data["Title"] = c.Tr("auth.active_your_account")
|
||||
c.Success(user.TmplUserAuthActivate)
|
||||
c.RedirectSubpath("/user/activate")
|
||||
} else {
|
||||
user.Dashboard(c)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check auto-login.
|
||||
uname := c.GetCookie(conf.Security.CookieUsername)
|
||||
if uname != "" {
|
||||
c.Redirect(conf.Server.Subpath + "/user/login")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["PageIsHome"] = true
|
||||
c.Success(tmplHome)
|
||||
c.ServeWeb()
|
||||
}
|
||||
|
||||
func ExploreRepos(c *context.Context) {
|
||||
@@ -157,8 +143,3 @@ func ExploreOrganizations(c *context.Context) {
|
||||
TplName: tmplExploreOrganizations,
|
||||
})
|
||||
}
|
||||
|
||||
func NotFound(c *macaron.Context, l i18n.Locale) {
|
||||
c.Data["Title"] = l.Tr("status.page_not_found")
|
||||
c.HTML(http.StatusNotFound, fmt.Sprintf("status/%d", http.StatusNotFound))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/gogs/git-module"
|
||||
"gopkg.in/ini.v1"
|
||||
"gopkg.in/macaron.v1"
|
||||
@@ -35,6 +36,7 @@ const (
|
||||
func checkRunMode() {
|
||||
if conf.IsProdMode() {
|
||||
macaron.Env = macaron.PROD
|
||||
flamego.SetEnv(flamego.EnvTypeProd)
|
||||
macaron.ColorLog = false
|
||||
git.SetOutput(nil)
|
||||
} else {
|
||||
@@ -157,7 +159,7 @@ func Install(c *context.Context) {
|
||||
f.Domain = conf.Server.Domain
|
||||
f.SSHPort = conf.SSH.Port
|
||||
f.UseBuiltinSSHServer = conf.SSH.StartBuiltinServer
|
||||
f.HTTPPort = conf.Server.HTTPPort
|
||||
f.HTTPPort = strconv.Itoa(conf.Server.HTTPPort)
|
||||
f.AppUrl = conf.Server.ExternalURL
|
||||
f.LogRootPath = conf.Log.RootPath
|
||||
f.DefaultBranch = conf.Repository.DefaultBranch
|
||||
@@ -415,5 +417,5 @@ func InstallPost(c *context.Context, f form.Install) {
|
||||
|
||||
log.Info("First-time run install finished!")
|
||||
c.Flash.Success(c.Tr("install.install_success"))
|
||||
c.Redirect(f.AppUrl + "user/login")
|
||||
c.Redirect(f.AppUrl + "user/sign-in")
|
||||
}
|
||||
|
||||
@@ -40,14 +40,32 @@ func askCredentials(c *macaron.Context, status int, text string) {
|
||||
c.Error(status, text)
|
||||
}
|
||||
|
||||
// gitHTTPAction returns the Git HTTP path suffix after /:username/:reponame/.
|
||||
func gitHTTPAction(c *macaron.Context) string {
|
||||
return gitHTTPActionFromPath(
|
||||
c.Req.URL.Path,
|
||||
conf.Server.Subpath,
|
||||
c.Params(":username"),
|
||||
c.Params(":reponame"),
|
||||
)
|
||||
}
|
||||
|
||||
func gitHTTPActionFromPath(urlPath, subpath, owner, repo string) string {
|
||||
if subpath != "" {
|
||||
urlPath = strings.TrimPrefix(urlPath, subpath)
|
||||
}
|
||||
prefix := path.Join("/", owner, repo) + "/"
|
||||
if after, ok := strings.CutPrefix(urlPath, prefix); ok {
|
||||
return after
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func HTTPContexter(store Store) macaron.Handler {
|
||||
return func(c *macaron.Context) {
|
||||
if len(conf.HTTP.AccessControlAllowOrigin) > 0 {
|
||||
// Set CORS headers for browser-based git clients
|
||||
c.Header().Set("Access-Control-Allow-Origin", conf.HTTP.AccessControlAllowOrigin)
|
||||
c.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent")
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if c.Req.Method == "OPTIONS" {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
@@ -93,7 +111,7 @@ func HTTPContexter(store Store) macaron.Handler {
|
||||
}
|
||||
|
||||
// In case user requested a wrong URL and not intended to access Git objects.
|
||||
action := c.Params("*")
|
||||
action := gitHTTPAction(c)
|
||||
if !strings.Contains(action, "git-") &&
|
||||
!strings.Contains(action, "info/") &&
|
||||
!strings.Contains(action, "HEAD") &&
|
||||
@@ -394,9 +412,6 @@ func HTTP(c *HTTPContext) {
|
||||
continue
|
||||
}
|
||||
|
||||
// We perform check here because route matched in cmd/web.go is wider than needed,
|
||||
// but we only want to output this message only if user is really trying to access
|
||||
// Git HTTP endpoints.
|
||||
if conf.Repository.DisableHTTPGit {
|
||||
c.Error(http.StatusForbidden, "Interacting with repositories by HTTP protocol is disabled")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGitHTTPActionFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
urlPath string
|
||||
subpath string
|
||||
owner string
|
||||
repo string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "info refs",
|
||||
urlPath: "/owner/repo/info/refs",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
want: "info/refs",
|
||||
},
|
||||
{
|
||||
name: "git suffix repo",
|
||||
urlPath: "/owner/repo.git/git-receive-pack",
|
||||
owner: "owner",
|
||||
repo: "repo.git",
|
||||
want: "git-receive-pack",
|
||||
},
|
||||
{
|
||||
name: "head",
|
||||
urlPath: "/owner/repo/HEAD",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
want: "HEAD",
|
||||
},
|
||||
{
|
||||
name: "objects info wildcard",
|
||||
urlPath: "/owner/repo/objects/info/exclude",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
want: "objects/info/exclude",
|
||||
},
|
||||
{
|
||||
name: "loose object",
|
||||
urlPath: "/owner/repo/objects/ab/cdef0123456789abcdef0123456789abcdef",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
want: "objects/ab/cdef0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
{
|
||||
name: "with subpath",
|
||||
urlPath: "/gogs/owner/repo/info/refs",
|
||||
subpath: "/gogs",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
want: "info/refs",
|
||||
},
|
||||
{
|
||||
name: "non git suffix",
|
||||
urlPath: "/owner/repo/src/main.go",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
want: "src/main.go",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := gitHTTPActionFromPath(tc.urlPath, tc.subpath, tc.owner, tc.repo)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func issues(c *context.Context, isPullList bool) {
|
||||
// Must sign in to see issues about you.
|
||||
if viewType != "all" && !c.IsLogged {
|
||||
c.SetCookie("redirect_to", "/"+url.QueryEscape(conf.Server.Subpath+c.Req.RequestURI), 0, conf.Server.Subpath)
|
||||
c.Redirect(conf.Server.Subpath + "/user/login")
|
||||
c.Redirect(conf.Server.Subpath + "/user/sign-in")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -656,7 +656,7 @@ func viewIssue(c *context.Context, isPullList bool) {
|
||||
c.Data["NumParticipants"] = len(participants)
|
||||
c.Data["Issue"] = issue
|
||||
c.Data["IsIssueOwner"] = c.Repo.IsWriter() || (c.IsLogged && issue.IsPoster(c.User.ID))
|
||||
c.Data["SignInLink"] = conf.Server.Subpath + "/user/login?redirect_to=" + c.Data["Link"].(string)
|
||||
c.Data["SignInLink"] = conf.Server.Subpath + "/user/sign-in?redirect_to=" + c.Data["Link"].(string)
|
||||
c.Success(tmplRepoIssueView)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,644 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
gocontext "context"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/go-macaron/captcha"
|
||||
log "unknwon.dev/clog/v2"
|
||||
|
||||
"gogs.io/gogs/internal/auth"
|
||||
"gogs.io/gogs/internal/conf"
|
||||
"gogs.io/gogs/internal/context"
|
||||
"gogs.io/gogs/internal/database"
|
||||
"gogs.io/gogs/internal/email"
|
||||
"gogs.io/gogs/internal/form"
|
||||
"gogs.io/gogs/internal/tool"
|
||||
"gogs.io/gogs/internal/urlx"
|
||||
"gogs.io/gogs/internal/userx"
|
||||
)
|
||||
|
||||
const (
|
||||
tmplUserAuthLogin = "user/auth/login"
|
||||
tmplUserAuthTwoFactor = "user/auth/two_factor"
|
||||
tmplUserAuthTwoFactorRecoveryCode = "user/auth/two_factor_recovery_code"
|
||||
tmplUserAuthSignup = "user/auth/signup"
|
||||
TmplUserAuthActivate = "user/auth/activate"
|
||||
tmplUserAuthForgotPassword = "user/auth/forgot_passwd"
|
||||
tmplUserAuthResetPassword = "user/auth/reset_passwd"
|
||||
)
|
||||
|
||||
// AutoLogin reads cookie and try to auto-login.
|
||||
func AutoLogin(c *context.Context) (bool, error) {
|
||||
if !database.HasEngine {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
uname := c.GetCookie(conf.Security.CookieUsername)
|
||||
if uname == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
isSucceed := false
|
||||
defer func() {
|
||||
if !isSucceed {
|
||||
log.Trace("auto-login cookie cleared: %s", uname)
|
||||
c.SetCookie(conf.Security.CookieUsername, "", -1, conf.Server.Subpath)
|
||||
c.SetCookie(conf.Security.CookieRememberName, "", -1, conf.Server.Subpath)
|
||||
c.SetCookie(conf.Security.LoginStatusCookieName, "", -1, conf.Server.Subpath)
|
||||
}
|
||||
}()
|
||||
|
||||
u, err := database.Handle.Users().GetByUsername(c.Req.Context(), uname)
|
||||
if err != nil {
|
||||
if !database.IsErrUserNotExist(err) {
|
||||
return false, errors.Newf("get user by name: %v", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if val, ok := c.GetSuperSecureCookie(u.Rands+u.Password, conf.Security.CookieRememberName); !ok || val != u.Name {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
isSucceed = true
|
||||
_ = c.Session.Set("uid", u.ID)
|
||||
_ = c.Session.Set("uname", u.Name)
|
||||
c.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
|
||||
if conf.Security.EnableLoginStatusCookie {
|
||||
c.SetCookie(conf.Security.LoginStatusCookieName, "true", 0, conf.Server.Subpath)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func Login(c *context.Context) {
|
||||
c.Title("sign_in")
|
||||
|
||||
// Check auto-login
|
||||
isSucceed, err := AutoLogin(c)
|
||||
if err != nil {
|
||||
c.Error(err, "auto login")
|
||||
return
|
||||
}
|
||||
|
||||
redirectTo := c.Query("redirect_to")
|
||||
if len(redirectTo) > 0 {
|
||||
c.SetCookie("redirect_to", redirectTo, 0, conf.Server.Subpath)
|
||||
} else {
|
||||
redirectTo, _ = url.QueryUnescape(c.GetCookie("redirect_to"))
|
||||
}
|
||||
|
||||
if isSucceed {
|
||||
if urlx.IsSameSite(redirectTo) {
|
||||
c.Redirect(redirectTo)
|
||||
} else {
|
||||
c.RedirectSubpath("/")
|
||||
}
|
||||
c.SetCookie("redirect_to", "", -1, conf.Server.Subpath)
|
||||
return
|
||||
}
|
||||
|
||||
// Display normal login page
|
||||
loginSources, err := database.Handle.LoginSources().List(c.Req.Context(), database.ListLoginSourceOptions{OnlyActivated: true})
|
||||
if err != nil {
|
||||
c.Error(err, "list activated login sources")
|
||||
return
|
||||
}
|
||||
c.Data["LoginSources"] = loginSources
|
||||
for i := range loginSources {
|
||||
if loginSources[i].IsDefault {
|
||||
c.Data["DefaultLoginSource"] = loginSources[i]
|
||||
c.Data["login_source"] = loginSources[i].ID
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Success(tmplUserAuthLogin)
|
||||
}
|
||||
|
||||
func afterLogin(c *context.Context, u *database.User, remember bool) {
|
||||
if remember {
|
||||
days := 86400 * conf.Security.LoginRememberDays
|
||||
c.SetCookie(conf.Security.CookieUsername, u.Name, days, conf.Server.Subpath, "", conf.Security.CookieSecure, true)
|
||||
c.SetSuperSecureCookie(u.Rands+u.Password, conf.Security.CookieRememberName, u.Name, days, conf.Server.Subpath, "", conf.Security.CookieSecure, true)
|
||||
}
|
||||
|
||||
_ = c.Session.Set("uid", u.ID)
|
||||
_ = c.Session.Set("uname", u.Name)
|
||||
_ = c.Session.Delete("twoFactorRemember")
|
||||
_ = c.Session.Delete("twoFactorUserID")
|
||||
|
||||
// Clear whatever CSRF has right now, force to generate a new one
|
||||
c.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
|
||||
if conf.Security.EnableLoginStatusCookie {
|
||||
c.SetCookie(conf.Security.LoginStatusCookieName, "true", 0, conf.Server.Subpath)
|
||||
}
|
||||
|
||||
redirectTo, _ := url.QueryUnescape(c.GetCookie("redirect_to"))
|
||||
c.SetCookie("redirect_to", "", -1, conf.Server.Subpath)
|
||||
if urlx.IsSameSite(redirectTo) {
|
||||
c.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
c.RedirectSubpath("/")
|
||||
}
|
||||
|
||||
func LoginPost(c *context.Context, f form.SignIn) {
|
||||
c.Title("sign_in")
|
||||
|
||||
loginSources, err := database.Handle.LoginSources().List(c.Req.Context(), database.ListLoginSourceOptions{OnlyActivated: true})
|
||||
if err != nil {
|
||||
c.Error(err, "list activated login sources")
|
||||
return
|
||||
}
|
||||
c.Data["LoginSources"] = loginSources
|
||||
|
||||
if c.HasError() {
|
||||
c.HTML(http.StatusBadRequest, tmplUserAuthLogin)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := database.Handle.Users().Authenticate(c.Req.Context(), f.UserName, f.Password, f.LoginSource)
|
||||
if err != nil {
|
||||
switch {
|
||||
case auth.IsErrBadCredentials(err):
|
||||
c.FormErr("UserName", "Password")
|
||||
c.RenderWithErr(c.Tr("form.username_password_incorrect"), http.StatusUnauthorized, tmplUserAuthLogin, &f)
|
||||
case database.IsErrLoginSourceMismatch(err):
|
||||
c.FormErr("LoginSource")
|
||||
c.RenderWithErr(c.Tr("form.auth_source_mismatch"), http.StatusUnprocessableEntity, tmplUserAuthLogin, &f)
|
||||
|
||||
default:
|
||||
c.Error(err, "authenticate user")
|
||||
}
|
||||
for i := range loginSources {
|
||||
if loginSources[i].IsDefault {
|
||||
c.Data["DefaultLoginSource"] = loginSources[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !database.Handle.TwoFactors().IsEnabled(c.Req.Context(), u.ID) {
|
||||
afterLogin(c, u, f.Remember)
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.Session.Set("twoFactorRemember", f.Remember)
|
||||
_ = c.Session.Set("twoFactorUserID", u.ID)
|
||||
c.RedirectSubpath("/user/login/two_factor")
|
||||
}
|
||||
|
||||
func LoginTwoFactor(c *context.Context) {
|
||||
_, ok := c.Session.Get("twoFactorUserID").(int64)
|
||||
if !ok {
|
||||
c.NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
c.Success(tmplUserAuthTwoFactor)
|
||||
}
|
||||
|
||||
func LoginTwoFactorPost(c *context.Context) {
|
||||
userID, ok := c.Session.Get("twoFactorUserID").(int64)
|
||||
if !ok {
|
||||
c.NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
t, err := database.Handle.TwoFactors().GetByUserID(c.Req.Context(), userID)
|
||||
if err != nil {
|
||||
c.Error(err, "get two factor by user ID")
|
||||
return
|
||||
}
|
||||
|
||||
passcode := c.Query("passcode")
|
||||
valid, err := t.ValidateTOTP(passcode)
|
||||
if err != nil {
|
||||
c.Error(err, "validate TOTP")
|
||||
return
|
||||
} else if !valid {
|
||||
c.Flash.Error(c.Tr("settings.two_factor_invalid_passcode"))
|
||||
c.RedirectSubpath("/user/login/two_factor")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := database.Handle.Users().GetByID(c.Req.Context(), userID)
|
||||
if err != nil {
|
||||
c.Error(err, "get user by ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent same passcode from being reused
|
||||
if c.Cache.IsExist(userx.TwoFactorCacheKey(u.ID, passcode)) {
|
||||
c.Flash.Error(c.Tr("settings.two_factor_reused_passcode"))
|
||||
c.RedirectSubpath("/user/login/two_factor")
|
||||
return
|
||||
}
|
||||
if err = c.Cache.Put(userx.TwoFactorCacheKey(u.ID, passcode), 1, 60); err != nil {
|
||||
log.Error("Failed to put cache 'two factor passcode': %v", err)
|
||||
}
|
||||
|
||||
afterLogin(c, u, c.Session.Get("twoFactorRemember").(bool))
|
||||
}
|
||||
|
||||
func LoginTwoFactorRecoveryCode(c *context.Context) {
|
||||
_, ok := c.Session.Get("twoFactorUserID").(int64)
|
||||
if !ok {
|
||||
c.NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
c.Success(tmplUserAuthTwoFactorRecoveryCode)
|
||||
}
|
||||
|
||||
func LoginTwoFactorRecoveryCodePost(c *context.Context) {
|
||||
userID, ok := c.Session.Get("twoFactorUserID").(int64)
|
||||
if !ok {
|
||||
c.NotFound()
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.Handle.TwoFactors().UseRecoveryCode(c.Req.Context(), userID, c.Query("recovery_code")); err != nil {
|
||||
if database.IsTwoFactorRecoveryCodeNotFound(err) {
|
||||
c.Flash.Error(c.Tr("auth.login_two_factor_invalid_recovery_code"))
|
||||
c.RedirectSubpath("/user/login/two_factor_recovery_code")
|
||||
} else {
|
||||
c.Error(err, "use recovery code")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
u, err := database.Handle.Users().GetByID(c.Req.Context(), userID)
|
||||
if err != nil {
|
||||
c.Error(err, "get user by ID")
|
||||
return
|
||||
}
|
||||
afterLogin(c, u, c.Session.Get("twoFactorRemember").(bool))
|
||||
}
|
||||
|
||||
func SignOut(c *context.Context) {
|
||||
_ = c.Session.Flush()
|
||||
_ = c.Session.Destory(c.Context)
|
||||
c.SetCookie(conf.Security.CookieUsername, "", -1, conf.Server.Subpath)
|
||||
c.SetCookie(conf.Security.CookieRememberName, "", -1, conf.Server.Subpath)
|
||||
c.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
|
||||
if conf.Auth.CustomLogoutURL != "" {
|
||||
c.Redirect(conf.Auth.CustomLogoutURL)
|
||||
return
|
||||
}
|
||||
c.RedirectSubpath("/")
|
||||
}
|
||||
|
||||
func SignUp(c *context.Context) {
|
||||
c.Title("sign_up")
|
||||
|
||||
c.Data["EnableCaptcha"] = conf.Auth.EnableRegistrationCaptcha
|
||||
|
||||
if conf.Auth.DisableRegistration {
|
||||
c.Data["DisableRegistration"] = true
|
||||
c.Success(tmplUserAuthSignup)
|
||||
return
|
||||
}
|
||||
|
||||
c.Success(tmplUserAuthSignup)
|
||||
}
|
||||
|
||||
func SignUpPost(c *context.Context, cpt *captcha.Captcha, f form.Register) {
|
||||
c.Title("sign_up")
|
||||
|
||||
c.Data["EnableCaptcha"] = conf.Auth.EnableRegistrationCaptcha
|
||||
|
||||
if conf.Auth.DisableRegistration {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if c.HasError() {
|
||||
c.HTML(http.StatusBadRequest, tmplUserAuthSignup)
|
||||
return
|
||||
}
|
||||
|
||||
if conf.Auth.EnableRegistrationCaptcha && !cpt.VerifyReq(c.Req) {
|
||||
c.FormErr("Captcha")
|
||||
c.RenderWithErr(c.Tr("form.captcha_incorrect"), http.StatusUnauthorized, tmplUserAuthSignup, &f)
|
||||
return
|
||||
}
|
||||
|
||||
if f.Password != f.Retype {
|
||||
c.FormErr("Password")
|
||||
c.RenderWithErr(c.Tr("form.password_not_match"), http.StatusBadRequest, tmplUserAuthSignup, &f)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := database.Handle.Users().Create(
|
||||
c.Req.Context(),
|
||||
f.UserName,
|
||||
f.Email,
|
||||
database.CreateUserOptions{
|
||||
Password: f.Password,
|
||||
Activated: !conf.Auth.RequireEmailConfirmation,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case database.IsErrUserAlreadyExist(err):
|
||||
c.FormErr("UserName")
|
||||
c.RenderWithErr(c.Tr("form.username_been_taken"), http.StatusUnprocessableEntity, tmplUserAuthSignup, &f)
|
||||
case database.IsErrEmailAlreadyUsed(err):
|
||||
c.FormErr("Email")
|
||||
c.RenderWithErr(c.Tr("form.email_been_used"), http.StatusUnprocessableEntity, tmplUserAuthSignup, &f)
|
||||
case database.IsErrNameNotAllowed(err):
|
||||
c.FormErr("UserName")
|
||||
c.RenderWithErr(c.Tr("user.form.name_not_allowed", err.(database.ErrNameNotAllowed).Value()), http.StatusBadRequest, tmplUserAuthSignup, &f)
|
||||
default:
|
||||
c.Error(err, "create user")
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Trace("Account created: %s", user.Name)
|
||||
|
||||
// FIXME: Count has pretty bad performance implication in large instances, we
|
||||
// should have a dedicate method to check whether the "user" table is empty.
|
||||
//
|
||||
// Auto-set admin for the only user.
|
||||
if database.Handle.Users().Count(c.Req.Context()) == 1 {
|
||||
v := true
|
||||
err := database.Handle.Users().Update(
|
||||
c.Req.Context(),
|
||||
user.ID,
|
||||
database.UpdateUserOptions{
|
||||
IsActivated: &v,
|
||||
IsAdmin: &v,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.Error(err, "update user")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send confirmation email.
|
||||
if conf.Auth.RequireEmailConfirmation && user.ID > 1 {
|
||||
if err := email.SendActivateAccountMail(c.Context, database.NewMailerUser(user)); err != nil {
|
||||
log.Error("Failed to send activate account mail: %v", err)
|
||||
}
|
||||
c.Data["IsSendRegisterMail"] = true
|
||||
c.Data["Email"] = user.Email
|
||||
c.Data["Hours"] = conf.Auth.ActivateCodeLives / 60
|
||||
c.Success(TmplUserAuthActivate)
|
||||
|
||||
if err := c.Cache.Put(userx.MailResendCacheKey(user.ID), 1, 180); err != nil {
|
||||
log.Error("Failed to put cache key 'mail resend': %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.RedirectSubpath("/user/login")
|
||||
}
|
||||
|
||||
// parseUserFromCode returns user by username encoded in code.
|
||||
// It returns nil if code or username is invalid.
|
||||
func parseUserFromCode(code string) (user *database.User) {
|
||||
if len(code) <= tool.TimeLimitCodeLength {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use tail hex username to query user
|
||||
hexStr := code[tool.TimeLimitCodeLength:]
|
||||
if b, err := hex.DecodeString(hexStr); err == nil {
|
||||
if user, err = database.Handle.Users().GetByUsername(gocontext.TODO(), string(b)); user != nil {
|
||||
return user
|
||||
} else if !database.IsErrUserNotExist(err) {
|
||||
log.Error("Failed to get user by name %q: %v", string(b), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// verify active code when active account
|
||||
func verifyUserActiveCode(code string) (user *database.User) {
|
||||
minutes := conf.Auth.ActivateCodeLives
|
||||
|
||||
if user = parseUserFromCode(code); user != nil {
|
||||
// time limit code
|
||||
prefix := code[:tool.TimeLimitCodeLength]
|
||||
data := strconv.FormatInt(user.ID, 10) + user.Email + user.LowerName + user.Password + user.Rands
|
||||
|
||||
if tool.VerifyTimeLimitCode(data, minutes, prefix) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verify active code when active account
|
||||
func verifyActiveEmailCode(code, email string) *database.EmailAddress {
|
||||
minutes := conf.Auth.ActivateCodeLives
|
||||
|
||||
if user := parseUserFromCode(code); user != nil {
|
||||
// time limit code
|
||||
prefix := code[:tool.TimeLimitCodeLength]
|
||||
data := strconv.FormatInt(user.ID, 10) + email + user.LowerName + user.Password + user.Rands
|
||||
|
||||
if tool.VerifyTimeLimitCode(data, minutes, prefix) {
|
||||
emailAddress, err := database.Handle.Users().GetEmail(gocontext.TODO(), user.ID, email, false)
|
||||
if err == nil {
|
||||
return emailAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Activate(c *context.Context) {
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.Data["IsActivatePage"] = true
|
||||
if c.User.IsActive {
|
||||
c.NotFound()
|
||||
return
|
||||
}
|
||||
// Resend confirmation email.
|
||||
if conf.Auth.RequireEmailConfirmation {
|
||||
if c.Cache.IsExist(userx.MailResendCacheKey(c.User.ID)) {
|
||||
c.Data["ResendLimited"] = true
|
||||
} else {
|
||||
c.Data["Hours"] = conf.Auth.ActivateCodeLives / 60
|
||||
if err := email.SendActivateAccountMail(c.Context, database.NewMailerUser(c.User)); err != nil {
|
||||
log.Error("Failed to send activate account mail: %v", err)
|
||||
}
|
||||
|
||||
if err := c.Cache.Put(userx.MailResendCacheKey(c.User.ID), 1, 180); err != nil {
|
||||
log.Error("Failed to put cache key 'mail resend': %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.Data["ServiceNotEnabled"] = true
|
||||
}
|
||||
c.Success(TmplUserAuthActivate)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify code.
|
||||
if user := verifyUserActiveCode(code); user != nil {
|
||||
v := true
|
||||
err := database.Handle.Users().Update(
|
||||
c.Req.Context(),
|
||||
user.ID,
|
||||
database.UpdateUserOptions{
|
||||
GenerateNewRands: true,
|
||||
IsActivated: &v,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.Error(err, "update user")
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("User activated: %s", user.Name)
|
||||
|
||||
_ = c.Session.Set("uid", user.ID)
|
||||
_ = c.Session.Set("uname", user.Name)
|
||||
c.RedirectSubpath("/")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["IsActivateFailed"] = true
|
||||
c.Success(TmplUserAuthActivate)
|
||||
}
|
||||
|
||||
func ActivateEmail(c *context.Context) {
|
||||
code := c.Query("code")
|
||||
emailAddr := c.Query("email")
|
||||
|
||||
// Verify code.
|
||||
if email := verifyActiveEmailCode(code, emailAddr); email != nil {
|
||||
err := database.Handle.Users().MarkEmailActivated(c.Req.Context(), email.UserID, email.Email)
|
||||
if err != nil {
|
||||
c.Error(err, "activate email")
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Email activated: %s", email.Email)
|
||||
c.Flash.Success(c.Tr("settings.add_email_success"))
|
||||
}
|
||||
|
||||
c.RedirectSubpath("/user/settings/email")
|
||||
}
|
||||
|
||||
func ForgotPasswd(c *context.Context) {
|
||||
c.Title("auth.forgot_password")
|
||||
|
||||
if !conf.Email.Enabled {
|
||||
c.Data["IsResetDisable"] = true
|
||||
c.Success(tmplUserAuthForgotPassword)
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["IsResetRequest"] = true
|
||||
c.Success(tmplUserAuthForgotPassword)
|
||||
}
|
||||
|
||||
func ForgotPasswdPost(c *context.Context) {
|
||||
c.Title("auth.forgot_password")
|
||||
|
||||
if !conf.Email.Enabled {
|
||||
c.Status(403)
|
||||
return
|
||||
}
|
||||
c.Data["IsResetRequest"] = true
|
||||
|
||||
emailAddr := c.Query("email")
|
||||
c.Data["Email"] = emailAddr
|
||||
|
||||
u, err := database.Handle.Users().GetByEmail(c.Req.Context(), emailAddr)
|
||||
if err != nil {
|
||||
if database.IsErrUserNotExist(err) {
|
||||
c.Data["Hours"] = conf.Auth.ActivateCodeLives / 60
|
||||
c.Data["IsResetSent"] = true
|
||||
c.Success(tmplUserAuthForgotPassword)
|
||||
return
|
||||
}
|
||||
|
||||
c.Error(err, "get user by email")
|
||||
return
|
||||
}
|
||||
|
||||
if !u.IsLocal() {
|
||||
c.FormErr("Email")
|
||||
c.RenderWithErr(c.Tr("auth.non_local_account"), http.StatusForbidden, tmplUserAuthForgotPassword, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if c.Cache.IsExist(userx.MailResendCacheKey(u.ID)) {
|
||||
c.Data["ResendLimited"] = true
|
||||
c.Success(tmplUserAuthForgotPassword)
|
||||
return
|
||||
}
|
||||
|
||||
if err = email.SendResetPasswordMail(c.Context, database.NewMailerUser(u)); err != nil {
|
||||
log.Error("Failed to send reset password mail: %v", err)
|
||||
}
|
||||
if err = c.Cache.Put(userx.MailResendCacheKey(u.ID), 1, 180); err != nil {
|
||||
log.Error("Failed to put cache key 'mail resend': %v", err)
|
||||
}
|
||||
|
||||
c.Data["Hours"] = conf.Auth.ActivateCodeLives / 60
|
||||
c.Data["IsResetSent"] = true
|
||||
c.Success(tmplUserAuthForgotPassword)
|
||||
}
|
||||
|
||||
func ResetPasswd(c *context.Context) {
|
||||
c.Title("auth.reset_password")
|
||||
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.NotFound()
|
||||
return
|
||||
}
|
||||
c.Data["Code"] = code
|
||||
c.Data["IsResetForm"] = true
|
||||
c.Success(tmplUserAuthResetPassword)
|
||||
}
|
||||
|
||||
func ResetPasswdPost(c *context.Context) {
|
||||
c.Title("auth.reset_password")
|
||||
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.NotFound()
|
||||
return
|
||||
}
|
||||
c.Data["Code"] = code
|
||||
|
||||
if u := verifyUserActiveCode(code); u != nil {
|
||||
// Validate password length.
|
||||
password := c.Query("password")
|
||||
if len(password) < 6 {
|
||||
c.Data["IsResetForm"] = true
|
||||
c.Data["Err_Password"] = true
|
||||
c.RenderWithErr(c.Tr("auth.password_too_short"), http.StatusBadRequest, tmplUserAuthResetPassword, nil)
|
||||
return
|
||||
}
|
||||
|
||||
err := database.Handle.Users().Update(c.Req.Context(), u.ID, database.UpdateUserOptions{Password: &password})
|
||||
if err != nil {
|
||||
c.Error(err, "update user")
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("User password reset: %s", u.Name)
|
||||
c.RedirectSubpath("/user/login")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["IsResetFailed"] = true
|
||||
c.Success(tmplUserAuthResetPassword)
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"bytes"
|
||||
gocontext "context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/pquerna/otp"
|
||||
@@ -240,6 +242,54 @@ func SettingsEmails(c *context.Context) {
|
||||
c.Success(tmplUserSettingsEmail)
|
||||
}
|
||||
|
||||
func parseUserFromCode(ctx gocontext.Context, code string) (user *database.User) {
|
||||
if len(code) <= tool.TimeLimitCodeLength {
|
||||
return nil
|
||||
}
|
||||
|
||||
hexStr := code[tool.TimeLimitCodeLength:]
|
||||
if b, err := hex.DecodeString(hexStr); err == nil {
|
||||
if user, err = database.Handle.Users().GetByUsername(ctx, string(b)); user != nil {
|
||||
return user
|
||||
} else if !database.IsErrUserNotExist(err) {
|
||||
log.Error("parseUserFromCode: get user by name %q: %v", string(b), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyActiveEmailCode(ctx gocontext.Context, code, email string) *database.EmailAddress {
|
||||
if user := parseUserFromCode(ctx, code); user != nil {
|
||||
prefix := code[:tool.TimeLimitCodeLength]
|
||||
data := strconv.FormatInt(user.ID, 10) + email + user.LowerName + user.Password + user.Rands
|
||||
if tool.VerifyTimeLimitCode(data, conf.Auth.ActivateCodeLives, prefix) {
|
||||
emailAddress, err := database.Handle.Users().GetEmail(ctx, user.ID, email, false)
|
||||
if err == nil {
|
||||
return emailAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ActivateEmail(c *context.Context) {
|
||||
code := c.Query("code")
|
||||
emailAddr := c.Query("email")
|
||||
|
||||
if email := verifyActiveEmailCode(c.Req.Context(), code, emailAddr); email != nil {
|
||||
err := database.Handle.Users().MarkEmailActivated(c.Req.Context(), email.UserID, email.Email)
|
||||
if err != nil {
|
||||
c.Error(err, "activate email")
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Email activated: %s", email.Email)
|
||||
c.Flash.Success(c.Tr("settings.add_email_success"))
|
||||
}
|
||||
|
||||
c.RedirectSubpath("/user/settings/email")
|
||||
}
|
||||
|
||||
func SettingsEmailPost(c *context.Context, f form.AddEmail) {
|
||||
c.Title("settings.emails")
|
||||
c.PageIs("SettingsEmails")
|
||||
|
||||
@@ -7,6 +7,16 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Coalesce returns the value of the first string that is not empty.
|
||||
func Coalesce(ss ...string) string {
|
||||
for _, s := range ss {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ToUpperFirst returns s with only the first Unicode letter mapped to its upper case.
|
||||
func ToUpperFirst(s string) string {
|
||||
for i, v := range s {
|
||||
|
||||
@@ -6,6 +6,23 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCoalesce(t *testing.T) {
|
||||
tests := []struct {
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"a", "b"}, "a"},
|
||||
{[]string{"", "b", "c"}, "b"},
|
||||
{[]string{"", "", ""}, ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.want, func(t *testing.T) {
|
||||
got := Coalesce(test.in...)
|
||||
assert.Equal(t, test.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUpperFirst(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
$schema: "https://moonrepo.dev/schemas/project.json"
|
||||
|
||||
language: "go"
|
||||
stack: "backend"
|
||||
id: "gogs"
|
||||
|
||||
fileGroups:
|
||||
sources:
|
||||
- "cmd/**/*.go"
|
||||
- "internal/**/*.go"
|
||||
- "public/**/*.go"
|
||||
- "templates/**/*.go"
|
||||
- "conf/**/*.go"
|
||||
tests:
|
||||
- "**/*_test.go"
|
||||
configs:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- ".golangci.yml"
|
||||
assets:
|
||||
- "conf/**/*"
|
||||
- "public/**/*"
|
||||
- "templates/**/*"
|
||||
|
||||
tasks:
|
||||
install:
|
||||
script: |
|
||||
go mod tidy
|
||||
go generate ./...
|
||||
inputs:
|
||||
- "@group(sources)"
|
||||
- "@group(configs)"
|
||||
- "mockgen.go"
|
||||
- "mockgen.yaml"
|
||||
|
||||
format:
|
||||
command: "golangci-lint fmt"
|
||||
inputs:
|
||||
- "@group(sources)"
|
||||
- "@group(configs)"
|
||||
deps:
|
||||
- "install"
|
||||
|
||||
lint:
|
||||
command: "golangci-lint run"
|
||||
inputs:
|
||||
- "@group(sources)"
|
||||
- "@group(configs)"
|
||||
deps:
|
||||
- "install"
|
||||
- "format"
|
||||
|
||||
test:
|
||||
command: "go test -cover -race ./..."
|
||||
inputs:
|
||||
- "@group(sources)"
|
||||
- "@group(tests)"
|
||||
- "@group(configs)"
|
||||
deps:
|
||||
- "install"
|
||||
|
||||
build:
|
||||
script: |
|
||||
go build -v -trimpath \
|
||||
-ldflags "-X 'gogs.io/gogs/internal/conf.BuildTime=$(date -u '+%Y-%m-%d %I:%M:%S %Z')' -X 'gogs.io/gogs/internal/conf.BuildCommit=$(git rev-parse HEAD)'" \
|
||||
-o .bin/gogs ./cmd/gogs
|
||||
inputs:
|
||||
- "@group(sources)"
|
||||
- "@group(configs)"
|
||||
- "@group(assets)"
|
||||
outputs:
|
||||
- ".bin/gogs"
|
||||
deps:
|
||||
- "install"
|
||||
|
||||
build-prod:
|
||||
script: |
|
||||
go build -v -trimpath -tags prod \
|
||||
-ldflags "-X 'gogs.io/gogs/internal/conf.BuildTime=$(date -u '+%Y-%m-%d %I:%M:%S %Z')' -X 'gogs.io/gogs/internal/conf.BuildCommit=$(git rev-parse HEAD)'" \
|
||||
-o .bin/gogs ./cmd/gogs
|
||||
inputs:
|
||||
- "@group(sources)"
|
||||
- "@group(configs)"
|
||||
- "@group(assets)"
|
||||
outputs:
|
||||
- ".bin/gogs"
|
||||
deps:
|
||||
- "install"
|
||||
- "web:build"
|
||||
|
||||
portless:
|
||||
script: |
|
||||
portless alias gogs 3000 --force >/dev/null
|
||||
portless proxy start >/dev/null 2>&1 || true
|
||||
mkdir -p .bin/custom/conf
|
||||
touch .bin/custom/conf/app.ini
|
||||
awk '
|
||||
BEGIN { in_server=0; saw_server=0; set_domain=0; set_url=0 }
|
||||
/^\[server\]/ { in_server=1; saw_server=1; print; next }
|
||||
/^\[/ {
|
||||
if (in_server) {
|
||||
if (!set_domain) print "DOMAIN = gogs.localhost"
|
||||
if (!set_url) print "EXTERNAL_URL = https://gogs.localhost/"
|
||||
in_server=0
|
||||
}
|
||||
print; next
|
||||
}
|
||||
in_server && /^[[:space:]]*DOMAIN[[:space:]]*=/ {
|
||||
print "DOMAIN = gogs.localhost"; set_domain=1; next
|
||||
}
|
||||
in_server && /^[[:space:]]*EXTERNAL_URL[[:space:]]*=/ {
|
||||
print "EXTERNAL_URL = https://gogs.localhost/"; set_url=1; next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (in_server) {
|
||||
if (!set_domain) print "DOMAIN = gogs.localhost"
|
||||
if (!set_url) print "EXTERNAL_URL = https://gogs.localhost/"
|
||||
} else if (!saw_server) {
|
||||
print ""
|
||||
print "[server]"
|
||||
print "DOMAIN = gogs.localhost"
|
||||
print "EXTERNAL_URL = https://gogs.localhost/"
|
||||
}
|
||||
}
|
||||
' .bin/custom/conf/app.ini > .bin/custom/conf/app.ini.tmp \
|
||||
&& mv .bin/custom/conf/app.ini.tmp .bin/custom/conf/app.ini
|
||||
|
||||
dev:
|
||||
script: "cd .bin && ./gogs web"
|
||||
preset: "server"
|
||||
env:
|
||||
TTY_FORCE: "1"
|
||||
deps:
|
||||
- "build"
|
||||
- "web:dev"
|
||||
- "portless"
|
||||
|
||||
prod:
|
||||
script: "cd .bin && ./gogs web"
|
||||
preset: "server"
|
||||
env:
|
||||
TTY_FORCE: "1"
|
||||
deps:
|
||||
- "build-prod"
|
||||
- "portless"
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "gogs",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.1.3"
|
||||
}
|
||||
Generated
+4292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
- "web"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@@ -1607,6 +1607,23 @@ $(document).ready(function() {
|
||||
$($(this).data("form")).submit();
|
||||
});
|
||||
|
||||
// Intercept the legacy sign-out form so it talks to the JSON API and
|
||||
// navigates to the URL the server hands back (honors CustomLogoutURL).
|
||||
$("#logout-form").on("submit", function(event) {
|
||||
event.preventDefault();
|
||||
var $form = $(this);
|
||||
var doneUrl = $form.data("done-url");
|
||||
$.ajax({
|
||||
url: $form.attr("action"),
|
||||
method: "POST"
|
||||
}).done(function(data) {
|
||||
var target = (data && data.redirectTo) ? data.redirectTo : doneUrl;
|
||||
window.location.assign(target);
|
||||
}).fail(function() {
|
||||
window.location.assign(doneUrl);
|
||||
});
|
||||
});
|
||||
|
||||
// Check or select on option to enable/disable target region
|
||||
$(".enable-system").change(function() {
|
||||
if (this.checked) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !prod
|
||||
|
||||
package public
|
||||
|
||||
import "embed"
|
||||
|
||||
// WebAssets is empty in dev — requests are proxied to the live Vite server.
|
||||
// Declared so the prod and dev builds share the same symbol.
|
||||
var WebAssets embed.FS
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build prod
|
||||
|
||||
package public
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:dist
|
||||
var WebAssets embed.FS
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"shadcn": {
|
||||
"source": "shadcn/ui",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/shadcn/SKILL.md",
|
||||
"computedHash": "80a6226e78f6d1fe464214ae0ef449d49d8ffaa3e7704f011e9b418c678ad4d1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,10 +202,6 @@
|
||||
<dl class="dl-horizontal admin-dl-horizontal">
|
||||
<dt>{{.i18n.Tr "admin.config.security.login_remember_days"}}</dt>
|
||||
<dd>{{.Security.LoginRememberDays}}</dd>
|
||||
<dt>{{.i18n.Tr "admin.config.security.cookie_remember_name"}}</dt>
|
||||
<dd>{{.Security.CookieRememberName}}</dd>
|
||||
<dt>{{.i18n.Tr "admin.config.security.cookie_username"}}</dt>
|
||||
<dd>{{.Security.CookieUsername}}</dd>
|
||||
<dt>{{.i18n.Tr "admin.config.security.cookie_secure"}}</dt>
|
||||
<dd><i class="fa fa{{if .Security.CookieSecure}}-check{{end}}-square-o"></i></dd>
|
||||
<dt>{{.i18n.Tr "admin.config.security.enable_login_status_cookie"}}</dt>
|
||||
@@ -306,6 +302,8 @@
|
||||
<dd><i class="fa fa{{if .Auth.EnableReverseProxyAutoRegistration}}-check{{end}}-square-o"></i></dd>
|
||||
<dt>{{.i18n.Tr "admin.config.auth.reverse_proxy_authentication_header"}}</dt>
|
||||
<dd><code>{{.Auth.ReverseProxyAuthenticationHeader}}</code></dd>
|
||||
<dt>{{.i18n.Tr "admin.config.auth.trusted_proxy_ips"}}</dt>
|
||||
<dd><code>{{Join .Auth.TrustedProxyIPs ", "}}</code></dd>
|
||||
<dt>{{.i18n.Tr "admin.config.auth_custom_logout_url"}}</dt>
|
||||
<dd>
|
||||
{{if .Auth.CustomLogoutURL}}
|
||||
|
||||
@@ -15,11 +15,8 @@
|
||||
{{.i18n.Tr "page"}}: <strong>{{LoadTimes .PageStartTime}}</strong> {{.i18n.Tr "template"}}: <strong>{{call .TmplLoadTimes}}</strong>
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .ShowFooterBranding}}
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs"><i class="fa fa-github-square"></i><span class="sr-only">GitHub</span></a>
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://twitter.com/GogsHQ"><i class="fa fa-twitter"></i><span class="sr-only">Twitter</span></a>
|
||||
<a target="_blank" rel="noopener noreferrer" href="http://weibo.com/gogschina"><i class="fa fa-weibo"></i><span class="sr-only">Sina Weibo</span></a>
|
||||
{{end}}
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs"><i class="fa fa-github-square"></i><span class="sr-only">GitHub</span></a>
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://twitter.com/GogsHQ"><i class="fa fa-twitter"></i><span class="sr-only">Twitter</span></a>
|
||||
<div class="ui language bottom floating slide up dropdown link item">
|
||||
<i class="world icon"></i>
|
||||
<div class="text">{{.LangName}}</div>
|
||||
|
||||
@@ -161,8 +161,7 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<form id="logout-form" class="item" action="{{AppSubURL}}/user/logout" method="POST">
|
||||
{{.CSRFTokenHTML}}
|
||||
<form id="logout-form" class="item" action="{{AppSubURL}}/api/web/user/sign-out" method="POST" data-done-url="{{AppSubURL}}/">
|
||||
<div class="submit-button" data-form="#logout-form">
|
||||
<i class="octicon octicon-sign-out"></i> {{.i18n.Tr "sign_out"}}
|
||||
</div>
|
||||
@@ -180,7 +179,7 @@
|
||||
<i class="octicon octicon-person"></i> {{.i18n.Tr "register"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="item{{if .PageIsSignIn}} active{{end}}" href="{{AppSubURL}}/user/login?redirect_to={{.Link}}">
|
||||
<a class="item{{if .PageIsSignIn}} active{{end}}" href="{{AppSubURL}}/user/sign-in?redirect_to={{.Link}}">
|
||||
<i class="octicon octicon-sign-in"></i> {{.i18n.Tr "sign_in"}}
|
||||
</a>
|
||||
</div><!-- end anonymous right menu -->
|
||||
|
||||
@@ -39,6 +39,9 @@ func (fs *fileSystem) Get(name string) (io.Reader, error) {
|
||||
func mustNames(fsys fs.FS) []string {
|
||||
var names []string
|
||||
walkDirFunc := func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
names = append(names, path)
|
||||
}
|
||||
@@ -50,6 +53,24 @@ func mustNames(fsys fs.FS) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// MailFileNames returns the embedded template file paths under "mail/",
|
||||
// each relative to the "mail" directory (e.g. "auth/activate.tmpl").
|
||||
func MailFileNames() []string {
|
||||
var names []string
|
||||
for _, name := range mustNames(files) {
|
||||
if rel, ok := strings.CutPrefix(name, "mail/"); ok {
|
||||
names = append(names, rel)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ReadMailFile returns the embedded mail template bytes at the given path
|
||||
// relative to the "mail" directory.
|
||||
func ReadMailFile(name string) ([]byte, error) {
|
||||
return files.ReadFile(path.Join("mail", name))
|
||||
}
|
||||
|
||||
// NewTemplateFileSystem returns a macaron.TemplateFileSystem instance for embedded assets.
|
||||
// The argument "dir" can be used to serve subset of embedded assets. Template file
|
||||
// found under the "customDir" on disk has higher precedence over embedded assets.
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="home">
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="sixteen wide center aligned centered column">
|
||||
<div class="logo">
|
||||
<img src="{{AppSubURL}}/img/gogs-hero.png" />
|
||||
</div>
|
||||
<div class="hero">
|
||||
<h2>{{.i18n.Tr "app_desc"}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if eq .Lang "de-DE"}}
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-flame"></i> Einfach zu installieren
|
||||
</h1>
|
||||
<p class="large">
|
||||
Starte einfach die Anwendung für deine Plattform. Gogs gibt es auch für <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs/tree/main/docker">Docker</a> oder als Installationspaket.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-device-desktop"></i> Plattformübergreifend
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs läuft überall. <a target="_blank" rel="noopener noreferrer" href="http://golang.org/">Go</a> kompiliert für: Windows, macOS, Linux, ARM, etc. Wähle dasjenige System, was dir am meisten gefällt!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-rocket"></i> Leichtgewicht
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs hat minimale Systemanforderungen und kann selbst auf einem günstigen und stromsparenden Raspberry Pi betrieben werden.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-code"></i> Quelloffen
|
||||
</h1>
|
||||
<p class="large">
|
||||
Der komplette Code befindet sich auf <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogits/gogs/">GitHub</a>! Unterstütze uns bei der Verbesserung dieses Projekts. Trau dich!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Lang "zh-CN"}}
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-flame"></i> 易安装
|
||||
</h1>
|
||||
<p class="large">
|
||||
您除了可以根据操作系统平台通过二进制运行,还可以通过 <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs/tree/main/docker">Docker</a>,以及包管理安装。
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-device-desktop"></i> 跨平台
|
||||
</h1>
|
||||
<p class="large">
|
||||
任何 <a target="_blank" rel="noopener noreferrer" href="http://golang.org/">Go 语言</a> 支持的平台都可以运行 Gogs,包括 Windows、Mac、Linux 以及 ARM。挑一个您喜欢的就行!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-rocket"></i> 轻量级
|
||||
</h1>
|
||||
<p class="large">
|
||||
一个廉价的树莓派的配置足以满足 Gogs 的最低系统硬件要求。最大程度上节省您的服务器资源!
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-code"></i> 开源化
|
||||
</h1>
|
||||
<p class="large">
|
||||
所有的代码都开源在 <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogits/gogs/">GitHub</a> 上,赶快加入我们来共同发展这个伟大的项目!还等什么?成为贡献者吧!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Lang "fr-FR"}}
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-flame"></i> Facile à installer
|
||||
</h1>
|
||||
<p class="large">
|
||||
Il suffit de lancer l'exécutable correspondant à votre système.
|
||||
Ou d'utiliser Gogs avec <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs/tree/main/docker">Docker</a>
|
||||
ou en l'installant depuis un package.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-device-desktop"></i> Multi-plateforme
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs tourne partout où <a target="_blank" rel="noopener noreferrer" href="http://golang.org/">Go</a> peut être compilé : Windows, macOS, Linux, ARM, etc. Choisissez votre préféré !
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-rocket"></i> Léger
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs utilise peu de ressources. Il peut même tourner sur un Raspberry Pi très bon marché. Économisez l'énergie de vos serveurs !
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-code"></i> Open Source
|
||||
</h1>
|
||||
<p class="large">
|
||||
Toutes les sources sont sur <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogits/gogs/">GitHub</a> ! Rejoignez-nous et contribuez à rendre ce projet encore meilleur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Lang "es-ES"}}
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-flame"></i> Fácil de instalar
|
||||
</h1>
|
||||
<p class="large">
|
||||
Simplemente arranca el binario para tu plataforma. O usa Gogs con <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs/tree/main/docker">Docker</a>, o utilice el paquete.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-device-desktop"></i> Multiplatforma
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs funciona en cualquier parte, <a target="_blank" rel="noopener noreferrer" href="http://golang.org/">Go</a> puede compilarse en: Windows, macOS, Linux, ARM, etc. !Elige tu favorita!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-rocket"></i> Ligero
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs tiene pocos requisitos y puede funcionar en una Raspberry Pi barata. !Ahorra energía!
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-code"></i> Open Source
|
||||
</h1>
|
||||
<p class="large">
|
||||
¡Está todo en <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogits/gogs/">GitHub</a>! Uniros contribuyendo a hacer este proyecto todavía mejor. ¡No seas tímido y colabora!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Lang "pt-BR"}}
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-flame"></i> Fácil de instalar
|
||||
</h1>
|
||||
<p class="large">
|
||||
Simplesmente rode o executável para o seu sistema operacional. Ou obtenha o Gogs com o <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs/tree/main/docker">Docker</a>, ou baixe o pacote.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-device-desktop"></i> Multi-plataforma
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs roda em qualquer sistema operacional em que <a target="_blank" rel="noopener noreferrer" href="http://golang.org/">Go</a> consegue compilar: Windows, macOS, Linux, ARM, etc. Escolha qual você gosta mais!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-rocket"></i> Leve e rápido
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs utiliza poucos recursos e consegue mesmo rodar no barato Raspberry Pi. Economize energia elétrica da sua máquina!
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-code"></i> Código aberto
|
||||
</h1>
|
||||
<p class="large">
|
||||
Está tudo no <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogits/gogs/">GitHub</a>! Contribua e torne este projeto ainda melhor. Não tenha vergonha de contribuir!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Lang "ru-RU"}}
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-flame"></i> Простой в установке
|
||||
</h1>
|
||||
<p class="large">
|
||||
Просто запустите исполняемый файл для вашей платформы. Используйте Gogs с <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs/tree/main/docker">Docker</a> или загрузите пакет.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-device-desktop"></i> Кроссплатформенный
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs работает на любой операционной системе, которая может компилировать <a target="_blank" rel="noopener noreferrer" href="http://golang.org/">Go</a>: Windows, macOS, Linux, ARM и т. д. Выбирайте, что вам больше нравится!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-rocket"></i> Легковесный
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs имеет низкие системные требования и может работать на недорогом Raspberry Pi. Экономьте энергию вашей машины!
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-code"></i> Открытый исходный код
|
||||
</h1>
|
||||
<p class="large">
|
||||
Всё это на <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogits/gogs/">GitHub</a>! Присоединяйтесь к нам, внося вклад, чтобы сделать этот проект еще лучше. Не бойтесь помогать!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Lang "uk-UA"}}
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-flame"></i> Простий у втановленні
|
||||
</h1>
|
||||
<p class="large">
|
||||
Просто запустіть виконуваний файл для вашої платформи. Використовуйте Gogs с <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs/tree/main/docker">Docker</a> або завантажте пакет.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-device-desktop"></i> Кросплатформність
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs працює у будь-якій операційній системі, що може компілювати <a target="_blank" rel="noopener noreferrer" href="http://golang.org/">Go</a>: Windows, macOS, Linux, ARM і т. д. Обирайте що вам більше до вподоби!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-rocket"></i> Легковісний
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs має низькі системні вимоги та може працювати на недорогому Raspberry Pi. Економте енергію вашої машини!
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-code"></i> Відкритий сирцевий код
|
||||
</h1>
|
||||
<p class="large">
|
||||
Все це у <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogits/gogs/">GitHub</a>! Приєднуйтеся до нас, робіть внесок, щоб зробити цей проект ще краще. Не бійтеся допомагати!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Lang "it-IT"}}
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-flame"></i> Facie da installare
|
||||
</h1>
|
||||
<p class="large">
|
||||
Basta avviare il binario per la tua piattaforma.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-device-desktop"></i> Multipiattaforma
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs funziona ovunque, <a target="_blank" rel="noopener noreferrer" href="http://golang.org/">Go</a> si può compilare su: Windows, macOS, Linux, ARM, etc. Scegli il tuo preferito!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-rocket"></i> Leggero
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs ha requisiti bassi e può funzionare su un Raspberry Pi economico. Risparmiare energia!
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-code"></i> Open Source
|
||||
</h1>
|
||||
<p class="large">
|
||||
Sta tutto su <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogits/gogs/">GitHub</a>! È tutto su GitHub! Unisciti a noi contribuendo a rendere questo progetto ancora miglior$
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-flame"></i> Easy to install
|
||||
</h1>
|
||||
<p class="large">
|
||||
Simply run the binary for your platform. Or ship Gogs with <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogs/gogs/tree/main/docker">Docker</a>, or get it packaged.
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-device-desktop"></i> Cross-platform
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs runs anywhere <a target="_blank" rel="noopener noreferrer" href="http://golang.org/">Go</a> can compile for: Windows, macOS, Linux, ARM, etc. Choose the one you love!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui stackable middle very relaxed page grid">
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-rocket"></i> Lightweight
|
||||
</h1>
|
||||
<p class="large">
|
||||
Gogs has low minimal requirements and can run on an inexpensive Raspberry Pi. Save your machine energy!
|
||||
</p>
|
||||
</div>
|
||||
<div class="eight wide center column">
|
||||
<h1 class="hero ui icon header">
|
||||
<i class="octicon octicon-code"></i> Open Source
|
||||
</h1>
|
||||
<p class="large">
|
||||
It's all on <a target="_blank" rel="noopener noreferrer" href="https://github.com/gogits/gogs/">GitHub</a>! Join us by contributing to make this project even better. Don't be shy to be a contributor!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -8,7 +8,7 @@
|
||||
<body>
|
||||
<p>Hi <b>{{.Username}}</b>, this is your registration confirmation email for {{AppName}}!</p>
|
||||
<p>You can now login via username: {{.Username}}.</p>
|
||||
<p><a href="{{AppURL}}user/login">{{AppURL}}user/login</a></p>
|
||||
<p><a href="{{AppURL}}user/sign-in">{{AppURL}}user/sign-in</a></p>
|
||||
<p>© {{Year}} <a target="_blank" rel="noopener noreferrer" href="{{AppURL}}">{{AppName}}</a></p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<body>
|
||||
<p>Hi <b>{{.Username}}</b>,</p>
|
||||
<p>Please click the following link to reset your password within <b>{{.ResetPwdCodeLives}} hours</b>:</p>
|
||||
<p><a href="{{AppURL}}user/reset_password?code={{.Code}}">{{AppURL}}user/reset_password?code={{.Code}}</a></p>
|
||||
<p><a href="{{AppURL}}user/reset-password?code={{.Code}}">{{AppURL}}user/reset-password?code={{.Code}}</a></p>
|
||||
<p>Not working? Try copying and pasting it to your browser.</p>
|
||||
<p>© {{Year}} <a target="_blank" rel="noopener noreferrer" href="{{AppURL}}">{{AppName}}</a></p>
|
||||
</body>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="ui container center">
|
||||
<p style="margin-top: 100px"><img src="{{AppSubURL}}/img/404.png" alt="404"/></p>
|
||||
<div class="ui divider"></div>
|
||||
<br>
|
||||
<p>If you think this is an error, please open an issue on <a href="https://github.com/gogs/gogs/issues/new">GitHub</a>.</p>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,38 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="user activate">
|
||||
<div class="ui middle very relaxed page grid">
|
||||
<div class="column">
|
||||
<form class="ui form" action="{{AppSubURL}}/user/activate" method="post">
|
||||
{{.CSRFTokenHTML}}
|
||||
<h2 class="ui top attached header">
|
||||
{{.i18n.Tr "auth.active_your_account"}}
|
||||
</h2>
|
||||
<div class="ui attached segment">
|
||||
{{template "base/alert" .}}
|
||||
{{if .IsActivatePage}}
|
||||
{{if .ServiceNotEnabled}}
|
||||
<p class="center">{{.i18n.Tr "auth.disable_register_mail"}}</p>
|
||||
{{else if .ResendLimited}}
|
||||
<p class="center">{{.i18n.Tr "auth.resent_limit_prompt"}}</p>
|
||||
{{else}}
|
||||
<p>{{.i18n.Tr "auth.confirmation_mail_sent_prompt" .LoggedUser.Email .Hours | Str2HTML}}</p>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{if .IsSendRegisterMail}}
|
||||
<p>{{.i18n.Tr "auth.confirmation_mail_sent_prompt" .Email .Hours | Str2HTML}}</p>
|
||||
{{else if .IsActivateFailed}}
|
||||
<p>{{.i18n.Tr "auth.invalid_code"}}</p>
|
||||
{{else}}
|
||||
<p>{{.i18n.Tr "auth.has_unconfirmed_mail" .LoggedUser.Name .LoggedUser.Email | Str2HTML}}</p>
|
||||
<div class="ui divider"></div>
|
||||
<div class="text right">
|
||||
<button class="ui blue button">{{.i18n.Tr "auth.resend_mail"}}</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,34 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="user forgot password">
|
||||
<div class="ui middle very relaxed page grid">
|
||||
<div class="column">
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
{{.CSRFTokenHTML}}
|
||||
<h2 class="ui top attached header">
|
||||
{{.i18n.Tr "auth.forgot_password"}}
|
||||
</h2>
|
||||
<div class="ui attached segment">
|
||||
{{template "base/alert" .}}
|
||||
{{if .IsResetSent}}
|
||||
<p>{{.i18n.Tr "auth.confirmation_mail_sent_prompt" .Email .Hours | Str2HTML}}</p>
|
||||
{{else if .IsResetRequest}}
|
||||
<div class="required inline field {{if .Err_Email}}error{{end}}">
|
||||
<label for="email">{{.i18n.Tr "email"}}</label>
|
||||
<input id="email" name="email" type="email" value="{{.Email}}" autofocus required>
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<button class="ui blue button">{{.i18n.Tr "auth.send_reset_mail"}}</button>
|
||||
</div>
|
||||
{{else if .IsResetDisable}}
|
||||
<p class="center">{{.i18n.Tr "auth.disable_register_mail"}}</p>
|
||||
{{else if .ResendLimited}}
|
||||
<p class="center">{{.i18n.Tr "auth.resent_limit_prompt"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,66 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="user signin">
|
||||
<div class="ui middle very relaxed page grid">
|
||||
<div class="column">
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
{{.CSRFTokenHTML}}
|
||||
<h3 class="ui top attached header">
|
||||
{{.i18n.Tr "sign_in"}}
|
||||
</h3>
|
||||
<div class="ui attached segment">
|
||||
{{template "base/alert" .}}
|
||||
<div class="required inline field {{if .Err_UserName}}error{{end}}">
|
||||
<label for="user_name">{{.i18n.Tr "home.uname_holder"}}</label>
|
||||
<input id="user_name" name="user_name" value="{{.user_name}}" autofocus required>
|
||||
</div>
|
||||
<div class="required inline field {{if .Err_Password}}error{{end}}">
|
||||
<label for="password">{{.i18n.Tr "password"}}</label>
|
||||
<input id="password" name="password" type="password" autocomplete="off" value="{{.password}}" required>
|
||||
</div>
|
||||
{{if .LoginSources}}
|
||||
<div class="required inline field {{if .Err_LoginSource}}error{{end}}">
|
||||
<label>{{.i18n.Tr "auth.auth_source"}}</label>
|
||||
<div class="ui selection dropdown">
|
||||
<input type="hidden" id="login_source" name="login_source" value="{{.login_source}}" required>
|
||||
<span class="text">
|
||||
{{if .DefaultLoginSource}}
|
||||
{{.DefaultLoginSource.Name}}
|
||||
{{else}}
|
||||
{{.i18n.Tr "auth.local"}}
|
||||
{{end}}
|
||||
</span>
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="menu">
|
||||
<div class="item" data-value="0">{{.i18n.Tr "auth.local"}}</div>
|
||||
{{range .LoginSources}}
|
||||
<div class="item" data-value="{{.ID}}">{{.Name}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<div class="ui checkbox">
|
||||
<label>{{.i18n.Tr "auth.remember_me"}}</label>
|
||||
<input name="remember" type="checkbox">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<button class="ui green button">{{.i18n.Tr "sign_in"}}</button>
|
||||
<a href="{{AppSubURL}}/user/forget_password">{{.i18n.Tr "auth.forget_password"}}</a>
|
||||
</div>
|
||||
{{if .ShowRegistrationButton}}
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<a href="{{AppSubURL}}/user/sign_up">{{.i18n.Tr "auth.sign_up_now" | Str2HTML}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,31 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="user reset password">
|
||||
<div class="ui middle very relaxed page grid">
|
||||
<div class="column">
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
{{.CSRFTokenHTML}}
|
||||
<input name="code" type="hidden" value="{{.Code}}">
|
||||
<h2 class="ui top attached header">
|
||||
{{.i18n.Tr "auth.reset_password"}}
|
||||
</h2>
|
||||
<div class="ui attached segment">
|
||||
{{template "base/alert" .}}
|
||||
{{if .IsResetForm}}
|
||||
<div class="required inline field {{if .Err_Password}}error{{end}}">
|
||||
<label for="password">{{.i18n.Tr "password"}}</label>
|
||||
<input id="password" name="password" type="password" value="{{.password}}" autofocus required>
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<button class="ui blue button">{{.i18n.Tr "auth.reset_password_helper"}}</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="center">{{.i18n.Tr "auth.invalid_code"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,56 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="user signup">
|
||||
<div class="ui middle very relaxed page grid">
|
||||
<div class="column">
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
{{.CSRFTokenHTML}}
|
||||
<h3 class="ui top attached header">
|
||||
{{.i18n.Tr "sign_up"}}
|
||||
</h3>
|
||||
<div class="ui attached segment">
|
||||
{{template "base/alert" .}}
|
||||
{{if .DisableRegistration}}
|
||||
<p>{{.i18n.Tr "auth.disable_register_prompt"}}</p>
|
||||
{{else}}
|
||||
<div class="required inline field {{if .Err_UserName}}error{{end}}">
|
||||
<label for="user_name">{{.i18n.Tr "username"}}</label>
|
||||
<input id="user_name" name="user_name" value="{{.user_name}}" autofocus required>
|
||||
</div>
|
||||
<div class="required inline field {{if .Err_Email}}error{{end}}">
|
||||
<label for="email">{{.i18n.Tr "email"}}</label>
|
||||
<input id="email" name="email" type="email" value="{{.email}}" required>
|
||||
</div>
|
||||
<div class="required inline field {{if .Err_Password}}error{{end}}">
|
||||
<label for="password">{{.i18n.Tr "password"}}</label>
|
||||
<input id="password" name="password" type="password" value="{{.password}}" required>
|
||||
</div>
|
||||
<div class="required inline field {{if .Err_Password}}error{{end}}">
|
||||
<label for="retype">{{.i18n.Tr "re_type"}}</label>
|
||||
<input id="retype" name="retype" type="password" value="{{.retype}}" required>
|
||||
</div>
|
||||
{{if .EnableCaptcha}}
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
{{.Captcha.CreateHtml}}
|
||||
</div>
|
||||
<div class="required inline field {{if .Err_Captcha}}error{{end}}">
|
||||
<label for="captcha">{{.i18n.Tr "captcha"}}</label>
|
||||
<input id="captcha" name="captcha" value="{{.captcha}}" autocomplete="off">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<button class="ui green button">{{.i18n.Tr "auth.create_new_account"}}</button>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<a href="{{AppSubURL}}/user/login">{{.i18n.Tr "auth.register_hepler_msg"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,28 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="user signin two-factor">
|
||||
<div class="ui middle very relaxed page grid">
|
||||
<div class="column">
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
{{.CSRFTokenHTML}}
|
||||
<h3 class="ui top attached center header">
|
||||
{{.i18n.Tr "auth.login_two_factor"}}
|
||||
</h3>
|
||||
<div class="ui attached segment">
|
||||
{{template "base/alert" .}}
|
||||
<div class="required field">
|
||||
<label for="passcode">{{.i18n.Tr "auth.login_two_factor_passcode"}}</label>
|
||||
<div class="ui fluid input">
|
||||
<input id="passcode" name="passcode" autofocus required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="ui fluid green button">{{.i18n.Tr "settings.two_factor_verify"}}</button>
|
||||
</div>
|
||||
<p>
|
||||
<a href="{{AppSubURL}}/user/login/two_factor_recovery_code">{{.i18n.Tr "auth.login_two_factor_enter_recovery_code"}}</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,28 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="user signin two-factor">
|
||||
<div class="ui middle very relaxed page grid">
|
||||
<div class="column">
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
{{.CSRFTokenHTML}}
|
||||
<h3 class="ui top attached center header">
|
||||
{{.i18n.Tr "auth.login_two_factor_recovery"}}
|
||||
</h3>
|
||||
<div class="ui attached segment">
|
||||
{{template "base/alert" .}}
|
||||
<div class="required field">
|
||||
<label for="recovery_code">{{.i18n.Tr "auth.login_two_factor_recovery_code"}}</label>
|
||||
<div class="ui fluid input">
|
||||
<input id="recovery_code" name="recovery_code" autofocus required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="ui fluid green button">{{.i18n.Tr "settings.two_factor_verify"}}</button>
|
||||
</div>
|
||||
<p>
|
||||
<a href="{{AppSubURL}}/user/login/two_factor">{{.i18n.Tr "auth.login_two_factor_enter_passcode"}}</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -0,0 +1,2 @@
|
||||
*.tsbuildinfo
|
||||
.vite
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import("prettier").Config} */
|
||||
export default {
|
||||
useTabs: false,
|
||||
tabWidth: 2,
|
||||
singleQuote: false,
|
||||
trailingComma: "all",
|
||||
printWidth: 120,
|
||||
plugins: ["@trivago/prettier-plugin-sort-imports"],
|
||||
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/(.+)", "^[./]"],
|
||||
importOrderSeparation: true,
|
||||
importOrderSortSpecifiers: true,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
# Gogs web design notes
|
||||
|
||||
A running record of design decisions made for the web frontend. Add an entry when a pattern is used in two places, or when a question caused a redo. Don't write aspirationally. Only capture what's already true in the code.
|
||||
|
||||
## Typography
|
||||
|
||||
Self-hosted via `@fontsource-variable`:
|
||||
|
||||
- **Sans**: Geist Variable, with PingFang SC and Microsoft YaHei as CJK fallbacks. Used for body text, headings, and UI chrome.
|
||||
- **Mono**: Geist Mono Variable, with the same CJK fallbacks. Used for code-shaped content (SHA, branch name, file path, shell command, terminal-style surfaces).
|
||||
|
||||
The browser does per-glyph fallback via the font-family stack. Latin characters render in Geist (the designed personality). CJK characters render in the next available system font (PingFang SC, Microsoft YaHei). The result: English looks distinctively Gogs, other scripts look clean and native.
|
||||
|
||||
Use mono only for content that **is** code, not for UI chrome (navbars, buttons, labels). Mono CJK fallbacks aren't truly monospace (CJK glyphs are wider than Latin), which is fine when the content is genuinely code, but reads as broken alignment if used decoratively for chrome.
|
||||
|
||||
Don't mix sans and mono within the same UI surface for arbitrary reasons. If a component is showing code, all of it goes mono.
|
||||
|
||||
## Color hierarchy
|
||||
|
||||
Palettes are adapted from [Pierre Theme](https://github.com/pierrecomputer/theme)'s "Light" and "Dark" (non-soft) variants. The dark-mode input background is bumped slightly above Pierre's value (`#262626` instead of `#1d1d1d`) so form fields read as edged elements outside an IDE panel context. Dark mode is opt-in via the `.dark` class on `:root` (see `lib/theme.ts`), not media-query driven, so the user's stored preference always wins. The `@custom-variant dark` rule in `index.css` lets utilities like `dark:...` target the same class.
|
||||
|
||||
Use these tokens. Don't introduce raw hex values in components.
|
||||
|
||||
**Surfaces and content**
|
||||
|
||||
- `--color-background`: page background. Body uses this by default.
|
||||
- `--color-foreground`: primary content. Headings, active states, the main label of any item, body text on `--color-background`.
|
||||
- `--color-muted-foreground`: secondary content. Metadata, helper text, terminal prompt characters, footer chrome, inactive items in a toggle group.
|
||||
- `--color-surface`: subtle raised surface. Used for hover backgrounds (`hover:bg-(--color-surface)` on links, buttons, menu rows) and for the muted fill of the faux-terminal frame.
|
||||
- `--color-card` / `--color-card-foreground`: card surface and its body text. Not currently used in components, but available for content cards.
|
||||
- `--color-popover` / `--color-popover-foreground`: popover surface and body. Used by the Radix popover primitive.
|
||||
|
||||
**Accents and state**
|
||||
|
||||
- `--color-primary` / `--color-primary-foreground`: brand blue (`#009fff` in both modes). Reserved for genuine brand emphasis. Don't use it to mean "primary action" between two peer links (see the peer-item rule below). Note: white-on-primary contrast is 2.84:1, which is below WCAG AA in both modes since the token is identical light and dark. Avoid using primary as a fill for body-sized text. Use it for chrome accents, ring/focus, and large CTAs only.
|
||||
- `--color-secondary` / `--color-secondary-foreground`: neutral support fill. Available for chips, tags, low-emphasis fills.
|
||||
- `--color-destructive` / `--color-destructive-foreground`: error and danger. The 404 page uses `text-(--color-destructive)` on the `fatal:` token, always paired with the word itself (color is never the sole signal).
|
||||
- `--color-ring`: keyboard focus ring color. Don't override per-component. If a default ring looks wrong, fix it at the token level.
|
||||
|
||||
**Structure**
|
||||
|
||||
- `--color-border`: soft container and divider lines. Used for the navbar bottom border, popover edges, card outlines, mobile-menu separators. Deliberately low-contrast (close to `--color-secondary`) so chrome reads as quiet boundary, not as a hard rule.
|
||||
- `--color-input`: input field borders. Similar weight to `--color-border` but kept as a separate token so form fields can drift independently if needed.
|
||||
|
||||
**The terminal frame is the exception.** `NotFound.tsx` wraps its faux-CLI output in a heavy outline so it actually looks like a terminal window — that frame uses `border-(--color-foreground)/80` (light) and the regular `--color-border` token (dark) directly, instead of the shared chrome token. Don't reuse this heavy outline elsewhere. If you need to introduce another heavy outline, promote a `--color-frame` token rather than inlining `--color-foreground`.
|
||||
|
||||
**Peer-item rule**
|
||||
|
||||
Don't use foreground vs muted-foreground to imply "primary action" vs "secondary action" between two peer items (e.g. Sign in vs Register). Peer items get the same color. Differentiation comes from positioning, weight, or affordance, not arbitrary contrast. Active vs inactive _states_ of the same control (e.g. the selected theme tile in `SettingsMenu`) are a different case and may use the foreground/muted-foreground split to communicate selection.
|
||||
|
||||
**Ad-hoc colors**
|
||||
|
||||
The traffic-light cluster in the faux-terminal frame uses one ad-hoc value: the amber dot falls back to `oklch(0.795 0.184 86.047)` via `bg-(--color-warning,...)`. There is no `--color-warning` token defined, so the fallback always wins. This is deliberate. Promoting it to a real token would invite reuse, and warning is not a system-wide concern in the current UI. Leave it inline until a second site needs warning semantics, then define the token in both light and dark palettes.
|
||||
|
||||
## Surface chrome
|
||||
|
||||
The 404 page wraps its content in a faux-terminal frame (rounded border, traffic-light dots, monospace body). Reuse the same frame for any page that represents a Git/CLI state: error pages, command-output stubs, raw diff fallbacks. Don't reuse it for normal content pages.
|
||||
|
||||
Strings rendered inside a terminal frame stay in English across all locales, regardless of the active UI language. Real CLI output (`git`, `ls`, `cat`, etc.) doesn't localize. Faux-CLI that does loses authenticity and reads as a translated error page in a costume. Translate the surrounding prose (headings, descriptions, CTAs), but leave command names, prompts, error tokens like `fatal:`, and command output strings untouched.
|
||||
|
||||
## File naming
|
||||
|
||||
Two conventions coexist in `web/src/`:
|
||||
|
||||
- **shadcn primitives** in `components/ui/` use **lowercase** filenames (`popover.tsx`). This matches the `shadcn` CLI output and lets dropped-in components stay byte-identical to upstream.
|
||||
- **App components** anywhere else use **PascalCase** matching the exported component (`Footer.tsx`, `SettingsMenu.tsx`, `Landing.tsx`). This is the React community default.
|
||||
|
||||
Library modules in `lib/` are plain `.ts` files in lowercase (`i18n.ts`, `theme.ts`, `utils.ts`).
|
||||
|
||||
## Forms
|
||||
|
||||
Disable the entire form while a submit is in flight, not just the submit button. Wrap the body in `<fieldset disabled={submitting} className="contents">` — native `disabled` propagates to every nested input and button.
|
||||
|
||||
Anchor links inside the form aren't covered by `disabled`. For each, set `tabIndex={submitting ? -1 : N}`, `aria-disabled={submitting || undefined}`, `className={submitting ? "pointer-events-none opacity-50" : undefined}`, and an `onClick` that calls `e.preventDefault()` when submitting.
|
||||
|
||||
Swap the submit label to a present-continuous string ("Signing in…", "Verifying…") while submitting. Keep idle and active strings as separate locale keys.
|
||||
|
||||
## Accessibility
|
||||
|
||||
WCAG 2.2 AA is the floor. Apply these patterns in components:
|
||||
|
||||
- **Icon-only buttons need an accessible name.** Set `aria-label` on every button or link whose visible content is purely a glyph (settings cog, hamburger, social icons in the footer, language switcher trigger). The label is the action, not the icon name (`aria-label="Settings"`, not `"Cog icon"`).
|
||||
- **Decorative icons inside a labeled control** get `aria-hidden`. If the button already has visible text or a sibling label, mark the SVG `aria-hidden` so screen readers don't double-announce.
|
||||
- **Interactive states must be reachable by keyboard.** Anything that handles `onClick` must also be focusable (use a `<button>` or `<a>`, not a `<div>`). Tab order should follow visual order. Esc closes overlays. Click-outside also closes overlays, but Esc is mandatory and click-outside is convenience.
|
||||
- **Don't disable focus rings.** If the default ring is visually wrong, restyle via `--color-ring` or `focus-visible:` utilities. Never remove it. Sighted keyboard users need to see where focus is.
|
||||
- **Touch targets are 24×24 CSS px at minimum.** Compact chrome (settings cog, hamburger) uses `size-9` (36px). Full-width tap rows in popovers and the mobile menu use `px-2 py-1.5`, which yields ~28px in height. The full row width gives the tap area enough horizontal slack to clear the minimum comfortably.
|
||||
- **Color is never the sole signal.** The current-item indicator in the language list is a ✓ icon, not just a color shift. The destructive token is paired with the word `fatal:` in the 404 terminal, not just red text. The theme toggle has Sun/Moon/Monitor icons alongside the label.
|
||||
- **Respect `prefers-reduced-motion`.** Popover animations from `tw-animate-css` honor this by default. If hand-rolling animations, gate them behind `motion-safe:`.
|
||||
- **Test before merging:** tab through the new UI with the keyboard only; resize to 375px; toggle dark mode; check focus rings are visible against both themes.
|
||||
@@ -0,0 +1,44 @@
|
||||
import js from "@eslint/js";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist", "eslint.config.js", ".prettierrc.js", "vite.config.ts", "scripts/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importPlugin.flatConfigs.typescript,
|
||||
{
|
||||
rules: {
|
||||
"import/no-unresolved": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
globals: {
|
||||
document: "readonly",
|
||||
window: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{{.WebContext}}
|
||||
<link rel="shortcut icon" href="/img/favicon.png" />
|
||||
<title>Gogs</title>
|
||||
<style>
|
||||
/* Inlined so the page paints with the right theme background before the main stylesheet arrives. */
|
||||
html {
|
||||
background-color: #fffffe;
|
||||
}
|
||||
html.dark {
|
||||
background-color: #16161a;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Inlined to run before paint and avoid theme flash. Treat any value
|
||||
// other than "light" or "dark" (missing, corrupted, "system", etc.)
|
||||
// as a follow-the-system signal so the OS preference still wins.
|
||||
(function () {
|
||||
var saved = localStorage.getItem("gogs-theme");
|
||||
var explicit = saved === "light" || saved === "dark";
|
||||
var dark = saved === "dark" || (!explicit && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
$schema: "https://moonrepo.dev/schemas/project.json"
|
||||
|
||||
language: "typescript"
|
||||
stack: "frontend"
|
||||
id: "web"
|
||||
|
||||
fileGroups:
|
||||
sources:
|
||||
- "src/**/*"
|
||||
- "index.html"
|
||||
configs:
|
||||
- "package.json"
|
||||
- "tsconfig*.json"
|
||||
- "vite.config.*"
|
||||
|
||||
tasks:
|
||||
install:
|
||||
command: "pnpm install"
|
||||
inputs:
|
||||
- "package.json"
|
||||
- "/pnpm-lock.yaml"
|
||||
- "/pnpm-workspace.yaml"
|
||||
options:
|
||||
runFromWorkspaceRoot: true
|
||||
|
||||
dev:
|
||||
command: "pnpm run dev"
|
||||
preset: "server"
|
||||
deps:
|
||||
- "install"
|
||||
|
||||
build:
|
||||
command: "pnpm run build"
|
||||
inputs:
|
||||
- "@group(sources)"
|
||||
- "@group(configs)"
|
||||
outputs:
|
||||
- "/public/dist"
|
||||
deps:
|
||||
- "install"
|
||||
|
||||
lint:
|
||||
command: "pnpm run lint"
|
||||
inputs:
|
||||
- "@group(sources)"
|
||||
- "@group(configs)"
|
||||
deps:
|
||||
- "install"
|
||||
- "format"
|
||||
|
||||
format:
|
||||
command: "pnpm run format"
|
||||
inputs:
|
||||
- "@group(sources)"
|
||||
- "@group(configs)"
|
||||
- ".prettierrc.*"
|
||||
- ".prettierignore"
|
||||
deps:
|
||||
- "install"
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "gogs-web",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"extract-locales": "node scripts/extract-locales.mjs",
|
||||
"predev": "node scripts/extract-locales.mjs",
|
||||
"prebuild": "node scripts/extract-locales.mjs",
|
||||
"prelint": "node scripts/extract-locales.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@fontsource-variable/geist-mono": "^5.2.8",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@tanstack/react-router": "^1.137.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.2.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,143 @@
|
||||
// Extracts the subset of keys the SPA needs from conf/locale/locale_*.ini and
|
||||
// writes them as JSON under web/src/locales/. Run with `node scripts/extract-locales.mjs`
|
||||
// after adding a new key or changing source translations.
|
||||
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "../..");
|
||||
const inDir = join(repoRoot, "conf/locale");
|
||||
const outDir = join(here, "..", "src/locales");
|
||||
|
||||
// Keys pulled from Gogs's INI files. Add new entries here when the SPA needs
|
||||
// another translation. Locales missing a key fall back to en-US via react-i18next.
|
||||
const REUSED_KEYS = [
|
||||
"app_desc",
|
||||
"home",
|
||||
"dashboard",
|
||||
"issues",
|
||||
"pull_requests",
|
||||
"explore",
|
||||
"help",
|
||||
"register",
|
||||
"sign_in",
|
||||
"sign_out",
|
||||
"create_new",
|
||||
"new_repo",
|
||||
"new_migrate",
|
||||
"new_org",
|
||||
"signed_in_as",
|
||||
"user_profile_and_more",
|
||||
"your_profile",
|
||||
"your_settings",
|
||||
"admin_panel",
|
||||
"settings",
|
||||
"language",
|
||||
"page_not_found",
|
||||
"internal_server_error",
|
||||
"theme",
|
||||
"theme_light",
|
||||
"theme_dark",
|
||||
"theme_system",
|
||||
"username",
|
||||
"username_placeholder",
|
||||
"new_username_placeholder",
|
||||
"email",
|
||||
"email_placeholder",
|
||||
"password",
|
||||
"password_placeholder",
|
||||
"captcha",
|
||||
"captcha_placeholder",
|
||||
"captcha_image_alt",
|
||||
"refresh_captcha",
|
||||
"click_to_refresh_captcha",
|
||||
"auth_source",
|
||||
"local",
|
||||
"forget_password",
|
||||
"send_reset_email",
|
||||
"reset_password_email_submitting",
|
||||
"reset_password_email_failed",
|
||||
"reset_password_email_sent",
|
||||
"disable_register_mail",
|
||||
"disable_register_prompt",
|
||||
"reset_password_resend_limited",
|
||||
"non_local_account",
|
||||
"create_new_account",
|
||||
"register_hepler_msg",
|
||||
"sign_up",
|
||||
"sign_up_now",
|
||||
"sign_up_submitting",
|
||||
"sign_up_failed",
|
||||
"sign_in_submitting",
|
||||
"sign_in_failed",
|
||||
"show_password",
|
||||
"hide_password",
|
||||
"back_to_sign_in",
|
||||
"reset_password",
|
||||
"invalid_code",
|
||||
"reset_password_submit",
|
||||
"reset_password_submitting",
|
||||
"reset_password_failed",
|
||||
"new_password",
|
||||
"new_password_placeholder",
|
||||
"confirm_password",
|
||||
"confirm_password_placeholder",
|
||||
"confirm_new_password",
|
||||
"confirm_new_password_placeholder",
|
||||
"password_mismatch",
|
||||
"mfa_title",
|
||||
"mfa_passcode",
|
||||
"mfa_passcode_placeholder",
|
||||
"mfa_recovery_code",
|
||||
"mfa_recovery_code_placeholder",
|
||||
"mfa_use_recovery_code",
|
||||
"mfa_use_passcode",
|
||||
"mfa_verify",
|
||||
"mfa_verifying",
|
||||
"mfa_session_expired",
|
||||
"mfa_verify_failed",
|
||||
"activate_your_account",
|
||||
"resend_rate_limited",
|
||||
"send_activation_email",
|
||||
"check_activation_email",
|
||||
"activation_email_pending",
|
||||
"activation_email_sent",
|
||||
"sending_activation_email",
|
||||
"send_activation_email_failed",
|
||||
"activating_account",
|
||||
];
|
||||
|
||||
// Lightweight INI parser: handles `key = value` and `key=value`, ignores
|
||||
// comments, and flattens sections into a single namespace. Gogs's locale
|
||||
// files group keys under sections like [status] (e.g. status.page_not_found
|
||||
// resolves to a key named "page_not_found" inside [status]), but downstream
|
||||
// callers reference keys by their bare name, so the section header is
|
||||
// dropped here. First occurrence wins on collisions.
|
||||
function parseIni(text) {
|
||||
const out = {};
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith(";") || line.startsWith("#") || line.startsWith("[")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1).trim();
|
||||
if (key && !(key in out)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const files = readdirSync(inDir).filter((f) => f.startsWith("locale_") && f.endsWith(".ini"));
|
||||
for (const file of files) {
|
||||
const lang = file.slice("locale_".length, -".ini".length);
|
||||
const parsed = parseIni(readFileSync(join(inDir, file), "utf8"));
|
||||
const out = {};
|
||||
for (const key of REUSED_KEYS) {
|
||||
if (parsed[key]) out[key] = parsed[key];
|
||||
}
|
||||
writeFileSync(join(outDir, `${lang}.json`), JSON.stringify(out, null, 2) + "\n", "utf8");
|
||||
console.log(`wrote ${lang}.json (${Object.keys(out).length} keys)`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { UserInfo } from "@/lib/user-info";
|
||||
|
||||
import { AppRouter } from "./router";
|
||||
|
||||
export function App({ user }: { user: UserInfo | null }) {
|
||||
return <AppRouter user={user} />;
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,106 @@
|
||||
The fonts in this directory and the fontsource packages bundled into the
|
||||
built web assets are licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
Copyright holders:
|
||||
|
||||
Geist (Sans)
|
||||
Copyright 2024 The Geist Project Authors
|
||||
https://github.com/vercel/geist-font
|
||||
|
||||
Geist Mono
|
||||
Copyright 2024 The Geist Project Authors
|
||||
https://github.com/vercel/geist-font
|
||||
|
||||
Geist Pixel
|
||||
Copyright (c) 2023 Vercel, in collaboration with basement.studio
|
||||
https://github.com/vercel/geist-font
|
||||
|
||||
The full text of the license is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,50 @@
|
||||
import { subUrl } from "@/lib/url";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-8 border-t border-(--color-border)">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-5 gap-y-2 px-4 py-3 text-xs text-(--color-muted-foreground) sm:px-6">
|
||||
<span>© {new Date().getFullYear()} Gogs®</span>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
<a
|
||||
href="https://github.com/gogs/gogs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub"
|
||||
className="inline-flex size-8 items-center justify-center rounded-md hover:bg-(--color-surface) hover:text-(--color-foreground)"
|
||||
>
|
||||
<GitHubIcon />
|
||||
</a>
|
||||
<a
|
||||
href="https://twitter.com/GogsHQ"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Twitter"
|
||||
className="inline-flex size-8 items-center justify-center rounded-md hover:bg-(--color-surface) hover:text-(--color-foreground)"
|
||||
>
|
||||
<TwitterIcon />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href={subUrl("/assets/librejs/librejs.html")} className="hidden" data-jslicense="1">
|
||||
JavaScript Licenses
|
||||
</a>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.28-.01-1.04-.02-2.05-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.1-.75.08-.74.08-.74 1.21.09 1.85 1.24 1.85 1.24 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.94 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23A11.5 11.5 0 0 1 12 5.8c1.02.01 2.05.14 3.01.4 2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.62-2.81 5.63-5.49 5.93.43.37.81 1.1.81 2.22 0 1.61-.01 2.9-.01 3.3 0 .32.22.7.83.58A12 12 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TwitterIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
HelpCircle,
|
||||
Import,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Menu,
|
||||
Plus,
|
||||
UserCog,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { SettingsMenu } from "@/components/SettingsMenu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { subUrl } from "@/lib/url";
|
||||
import { useUserInfo } from "@/lib/use-user-info";
|
||||
import type { UserInfo } from "@/lib/user-info";
|
||||
|
||||
export function Navbar() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const user = useUserInfo();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-(--color-border) bg-(--color-background)">
|
||||
<nav className="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4 text-sm sm:gap-4 sm:px-6">
|
||||
<a href={subUrl("/")} className="flex shrink-0 items-center" aria-label="Gogs">
|
||||
<img src={subUrl("/img/favicon.png")} alt="" width="28" height="28" className="size-7" />
|
||||
</a>
|
||||
|
||||
<div className="hidden flex-1 items-center gap-1 sm:flex">
|
||||
{user ? (
|
||||
<>
|
||||
<NavLink href="/">{t("dashboard")}</NavLink>
|
||||
<NavLink href="/issues">{t("issues")}</NavLink>
|
||||
<NavLink href="/pulls">{t("pull_requests")}</NavLink>
|
||||
<NavLink href="/explore/repos">{t("explore")}</NavLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NavLink href="/" spa>
|
||||
{t("home")}
|
||||
</NavLink>
|
||||
<NavLink href="/explore/repos">{t("explore")}</NavLink>
|
||||
<NavLink href="https://gogs.io" external>
|
||||
{t("help")}
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden shrink-0 items-center gap-1 sm:flex">
|
||||
<SettingsMenu />
|
||||
{user ? (
|
||||
<>
|
||||
<CreateMenu canCreateOrganization={user.canCreateOrganization} />
|
||||
<UserMenu user={user} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NavLink href="/user/sign-in" spa>
|
||||
{t("sign_in")}
|
||||
</NavLink>
|
||||
<NavLink href="/user/sign-up" spa>
|
||||
{t("register")}
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1 sm:hidden">
|
||||
<SettingsMenu />
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
aria-label="Open menu"
|
||||
className="inline-flex size-9 cursor-pointer items-center justify-center rounded-md text-(--color-foreground) hover:bg-(--color-surface)"
|
||||
>
|
||||
<Menu className="size-[18px]" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56 p-1" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<ul className="flex flex-col text-sm">
|
||||
{user ? (
|
||||
<>
|
||||
<li className="px-2 py-1.5 text-xs text-(--color-muted-foreground)">
|
||||
{t("signed_in_as")} <strong className="text-(--color-foreground)">{user.username}</strong>
|
||||
</li>
|
||||
<MobileLink href={`/${user.username}`} onClick={() => setOpen(false)}>
|
||||
{t("your_profile")}
|
||||
</MobileLink>
|
||||
<MobileLink href="/user/settings" onClick={() => setOpen(false)}>
|
||||
{t("your_settings")}
|
||||
</MobileLink>
|
||||
{user.isAdmin && (
|
||||
<MobileLink href="/admin" onClick={() => setOpen(false)}>
|
||||
{t("admin_panel")}
|
||||
</MobileLink>
|
||||
)}
|
||||
<li className="my-1 h-px bg-(--color-border)" />
|
||||
<MobileLink href="/" onClick={() => setOpen(false)}>
|
||||
{t("dashboard")}
|
||||
</MobileLink>
|
||||
<MobileLink href="/issues" onClick={() => setOpen(false)}>
|
||||
{t("issues")}
|
||||
</MobileLink>
|
||||
<MobileLink href="/pulls" onClick={() => setOpen(false)}>
|
||||
{t("pull_requests")}
|
||||
</MobileLink>
|
||||
<MobileLink href="/explore/repos" onClick={() => setOpen(false)}>
|
||||
{t("explore")}
|
||||
</MobileLink>
|
||||
<li className="my-1 h-px bg-(--color-border)" />
|
||||
<MobileLink href="/repo/create" onClick={() => setOpen(false)}>
|
||||
{t("new_repo")}
|
||||
</MobileLink>
|
||||
<MobileLink href="/repo/migrate" onClick={() => setOpen(false)}>
|
||||
{t("new_migrate")}
|
||||
</MobileLink>
|
||||
{user.canCreateOrganization && (
|
||||
<MobileLink href="/org/create" onClick={() => setOpen(false)}>
|
||||
{t("new_org")}
|
||||
</MobileLink>
|
||||
)}
|
||||
<li className="my-1 h-px bg-(--color-border)" />
|
||||
<MobileLink href="https://gogs.io" external onClick={() => setOpen(false)}>
|
||||
{t("help")}
|
||||
</MobileLink>
|
||||
<li>
|
||||
<SignOutForm>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full cursor-pointer rounded-sm px-2 py-1.5 text-left text-(--color-foreground) hover:bg-(--color-surface)"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("sign_out")}
|
||||
</button>
|
||||
</SignOutForm>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MobileLink href="/" spa onClick={() => setOpen(false)}>
|
||||
{t("home")}
|
||||
</MobileLink>
|
||||
<MobileLink href="/explore/repos" onClick={() => setOpen(false)}>
|
||||
{t("explore")}
|
||||
</MobileLink>
|
||||
<MobileLink href="https://gogs.io" external onClick={() => setOpen(false)}>
|
||||
{t("help")}
|
||||
</MobileLink>
|
||||
<li className="my-1 h-px bg-(--color-border)" />
|
||||
<MobileLink href="/user/sign-in" spa onClick={() => setOpen(false)}>
|
||||
{t("sign_in")}
|
||||
</MobileLink>
|
||||
<MobileLink href="/user/sign-up" spa onClick={() => setOpen(false)}>
|
||||
{t("register")}
|
||||
</MobileLink>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateMenu({ canCreateOrganization }: { canCreateOrganization: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
aria-label={t("create_new")}
|
||||
className="inline-flex h-9 cursor-pointer items-center gap-1 rounded-md px-2 text-(--color-muted-foreground) hover:bg-(--color-surface) hover:text-(--color-foreground)"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
<ChevronDown className="size-3" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56 p-1" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<MenuLink href="/repo/create" icon={<Plus className="size-4" aria-hidden />} onSelect={() => setOpen(false)}>
|
||||
{t("new_repo")}
|
||||
</MenuLink>
|
||||
<MenuLink href="/repo/migrate" icon={<Import className="size-4" aria-hidden />} onSelect={() => setOpen(false)}>
|
||||
{t("new_migrate")}
|
||||
</MenuLink>
|
||||
{canCreateOrganization && (
|
||||
<MenuLink
|
||||
href="/org/create"
|
||||
icon={<Building2 className="size-4" aria-hidden />}
|
||||
onSelect={() => setOpen(false)}
|
||||
>
|
||||
{t("new_org")}
|
||||
</MenuLink>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({ user }: { user: UserInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
aria-label={t("user_profile_and_more")}
|
||||
className="inline-flex h-9 cursor-pointer items-center gap-1 rounded-md px-1 hover:bg-(--color-surface)"
|
||||
>
|
||||
{user.avatarURL ? (
|
||||
<img src={user.avatarURL} alt="" width="24" height="24" className="size-6 rounded-full" />
|
||||
) : (
|
||||
<UserIcon className="size-5" aria-hidden />
|
||||
)}
|
||||
<ChevronDown className="size-3 text-(--color-muted-foreground)" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-60 p-1" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="px-2 pt-2 pb-1 text-xs text-(--color-muted-foreground)">
|
||||
{t("signed_in_as")} <strong className="text-(--color-foreground)">{user.username}</strong>
|
||||
</div>
|
||||
<div className="my-1 h-px bg-(--color-border)" />
|
||||
<MenuLink
|
||||
href={`/${user.username}`}
|
||||
icon={<UserIcon className="size-4" aria-hidden />}
|
||||
onSelect={() => setOpen(false)}
|
||||
>
|
||||
{t("your_profile")}
|
||||
</MenuLink>
|
||||
<MenuLink
|
||||
href="/user/settings"
|
||||
icon={<UserCog className="size-4" aria-hidden />}
|
||||
onSelect={() => setOpen(false)}
|
||||
>
|
||||
{t("your_settings")}
|
||||
</MenuLink>
|
||||
<MenuLink
|
||||
href="https://gogs.io"
|
||||
external
|
||||
icon={<HelpCircle className="size-4" aria-hidden />}
|
||||
onSelect={() => setOpen(false)}
|
||||
>
|
||||
{t("help")}
|
||||
</MenuLink>
|
||||
{user.isAdmin && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-(--color-border)" />
|
||||
<MenuLink
|
||||
href="/admin"
|
||||
icon={<LayoutDashboard className="size-4" aria-hidden />}
|
||||
onSelect={() => setOpen(false)}
|
||||
>
|
||||
{t("admin_panel")}
|
||||
</MenuLink>
|
||||
</>
|
||||
)}
|
||||
<div className="my-1 h-px bg-(--color-border)" />
|
||||
<SignOutForm>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-(--color-foreground) hover:bg-(--color-surface)"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<LogOut className="size-4" aria-hidden />
|
||||
{t("sign_out")}
|
||||
</button>
|
||||
</SignOutForm>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuLink({
|
||||
href,
|
||||
external,
|
||||
icon,
|
||||
onSelect,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={external ? href : subUrl(href)}
|
||||
onClick={onSelect}
|
||||
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-(--color-foreground) hover:bg-(--color-surface)"
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
external,
|
||||
spa,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
spa?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const className = "inline-flex rounded-md px-3 py-1.5 text-(--color-foreground) hover:bg-(--color-surface)";
|
||||
if (spa) {
|
||||
return (
|
||||
<Link to={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={external ? href : subUrl(href)}
|
||||
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutForm({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<form
|
||||
action={subUrl("/api/web/user/sign-out")}
|
||||
method="POST"
|
||||
className="inline"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void signOut();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
let redirectTo = subUrl("/");
|
||||
try {
|
||||
const res = await fetch(subUrl("/api/web/user/sign-out"), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.ok && res.status !== 204) {
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
redirectTo?: string;
|
||||
} | null;
|
||||
if (body?.redirectTo) {
|
||||
redirectTo = body.redirectTo;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("signOut: request failed", err);
|
||||
}
|
||||
window.location.assign(redirectTo);
|
||||
}
|
||||
|
||||
function MobileLink({
|
||||
href,
|
||||
external,
|
||||
spa,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
spa?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const className = "flex w-full rounded-sm px-2 py-1.5 text-(--color-foreground) hover:bg-(--color-surface)";
|
||||
return (
|
||||
<li>
|
||||
{spa ? (
|
||||
<Link to={href} onClick={onClick} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
href={external ? href : subUrl(href)}
|
||||
onClick={onClick}
|
||||
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function PasswordInput({
|
||||
inputRef,
|
||||
id,
|
||||
value,
|
||||
tabIndex,
|
||||
placeholder,
|
||||
show,
|
||||
onToggleShow,
|
||||
disabled,
|
||||
describedBy,
|
||||
invalid,
|
||||
autoFocus,
|
||||
onChange,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
id: string;
|
||||
value: string;
|
||||
tabIndex: number;
|
||||
placeholder: string;
|
||||
show: boolean;
|
||||
onToggleShow: () => void;
|
||||
disabled: boolean;
|
||||
describedBy?: string;
|
||||
invalid: boolean;
|
||||
autoFocus?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={id}
|
||||
type={show ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
autoFocus={autoFocus}
|
||||
tabIndex={tabIndex}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={tabIndex + 1}
|
||||
disabled={disabled}
|
||||
onClick={onToggleShow}
|
||||
aria-label={show ? t("hide_password") : t("show_password")}
|
||||
aria-pressed={show}
|
||||
className="absolute inset-y-0 right-0 flex w-10 cursor-pointer items-center justify-center rounded-r-md text-(--color-muted-foreground) outline-none hover:text-(--color-foreground) focus-visible:text-(--color-foreground) focus-visible:ring-1 focus-visible:ring-(--color-ring) disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{show ? <EyeOff className="size-4" aria-hidden /> : <Eye className="size-4" aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user