Compare commits

...

68 Commits

Author SHA1 Message Date
Joe Chen 0114e18bd3 31231 2026-05-28 17:20:19 -04:00
Joe Chen c4821cfe31 31231 2026-05-28 17:10:08 -04:00
Joe Chen 9327909209 1231 2026-05-28 16:53:38 -04:00
Joe Chen 8f9a1cf2c1 312 2026-05-28 16:40:47 -04:00
Joe Chen bc6c1ddf07 231 2026-05-28 16:31:27 -04:00
Joe Chen 65cce4eafe 312 2026-05-28 16:22:27 -04:00
Joe Chen 7102695655 213 2026-05-28 16:04:53 -04:00
Joe Chen 32573b844c 213 2026-05-28 16:00:32 -04:00
Joe Chen 98cb5a3db4 1231 2026-05-28 15:54:47 -04:00
Joe Chen a5d2c0ee36 111 2026-05-28 14:09:49 -04:00
Joe Chen 2d78fd22dc 213 2026-05-28 12:47:43 -04:00
Joe Chen c81f418856 1231 2026-05-28 12:25:48 -04:00
Joe Chen d259b87cfd 2131 2026-05-28 12:06:54 -04:00
Joe Chen f7449a8c85 21312 2026-05-28 12:00:53 -04:00
Joe Chen 9e1525bdf8 12312 2026-05-27 23:55:24 -04:00
unknwon c604d23be5 web(api): standardize repo handlers on flamego.Context
Drop `*http.Request` from handlers that only needed it for the request
context or `URL.Query()`, and replace `params := c.Params()` indexing
with `c.Param(...)` lookups. Every handler in webapi_repo.go now takes
just `(c flamego.Context, user *database.User)`.
2026-05-27 22:28:55 -04:00
Joe Chen 22f84f7ee9 111 2026-05-27 22:19:26 -04:00
unknwon c6b90ea5ab web(api): rename repo header fields to match GitHub-style naming
Apply review feedback on PR #8295. Flatten `repoHeaderCounts` into
`repoHeader` and rename fields to be more descriptive on the JSON wire
(`isViewerAdmin`, `issuesEnabled`, `pullRequestsEnabled`, `wikiEnabled`,
`watches`, `openPullRequests`, `isViewerWatching`, `hasViewerStarred`).
Apply the same naming to `repoActionResponse`. Rename `getRepoRaw` ->
`getRepoRawFile` with `{file}` param. Update the TS types and consumers
in `RepoHeader.tsx` to match.
2026-05-27 21:39:04 -04:00
Joe Chen 552fde5e8c 231 2026-05-27 21:25:27 -04:00
Joe Chen 87a7e5a80b 111 2026-05-27 16:48:31 -04:00
Joe Chen 6188e01752 111 2026-05-27 16:15:11 -04:00
Joe Chen 8c17948ddf 1231 2026-05-27 15:31:21 -04:00
Joe Chen a91b0551bd 111 2026-05-27 15:26:56 -04:00
Joe Chen 91e8a36578 1211 2026-05-27 14:20:54 -04:00
Joe Chen 5e191aa5c8 2131 2026-05-27 13:13:27 -04:00
unknwon c2a424a678 web(diff): tighten commit metadata row on commit page
Merge the author and parent/commit/buttons rows into a single
wrap-friendly flex line on desktop. Drop the committer line, since
showing it twice (author + committer) is rarely useful for the
common case where they match. Render each parent SHA as its own
clickable chip so multi-parent merge commits link to every parent.
Align View patch and Browse files to the left on mobile.
2026-05-27 09:47:15 -04:00
unknwon 10c829f798 web(diff): add per-file "Expand all lines" via raw file fetch
The commit diff page only ships the patch hunks, so unmodified context
between hunks is invisible. Add a per-file "Expand all lines" toggle so
the reader can pull in the full file when the surrounding code matters.

Backend:
- Migrate `repo.SingleDownload` to a new Flamego `getRepoRaw` handler.
  Same URL shape (`/{owner}/{name}/raw/{ref}/{path}`) so external
  consumers (`curl`, scripts) keep working. Bridged from the Macaron
  router via `flamegoBridger` so the legacy path doesn't double-route
  through `RepoRef` middleware. The ref segment accepts a branch, tag,
  or commit SHA; commit SHAs match first (the common case from the
  React diff page).
- Delete `repo.SingleDownload` and the legacy `m.Get("/raw/*", ...)`
  Macaron handler. `repo.ServeBlob` stays because `internal/route/api/v1`
  still uses it for the public REST API.

Frontend:
- Add an `UnfoldVertical` icon button to each file header. Click fetches
  the pre + post file contents in parallel via the legacy raw URL,
  calls `parseDiffFromFile` to upgrade the `FileDiffMetadata` to
  `isPartial: false`, and stores the result keyed by item id.
- The `items` useMemo swaps in the upgraded `fileDiff` when present and
  bumps the item `version` so Pierre's `CodeView` re-renders that file.
  Set `expandUnchanged: true` globally so non-partial files immediately
  render all context lines.
- Show a spinner during fetch, hide the button once expansion succeeds.
  Skip the button for added/deleted files (no opposite side to expand).
- Added/deleted files preserve the old behaviour (no expansion).
2026-05-27 03:51:16 -04:00
unknwon c535b64bb9 web: wire commit diff page to real data with TanStack Query
Migrate the React commit diff page off of mocked repo metadata and onto
live web API endpoints, and take over the legacy `/owner/repo/commit/{sha}`
URL so the React page is the canonical commit view.

Backend:
- Split `webapi.go` into `webapi.go` (shared infra), `webapi_user.go`
  (user handlers), and `webapi_repo.go` (repo handlers).
- Add `GET /api/web/{owner}/{name}/info` returning repo header data
  (avatar, visibility, counts, mirror, viewer state). Mirrors legacy
  `RepoAssignment` access logic: admin shortcut + partial-public masking.
- Add `GET /api/web/{owner}/{name}/commit/{sha}` returning commit
  metadata only. Patch text lives on the existing `.diff` URL so it
  avoids JSON-string escaping and caches independently.
- Migrate `repo.RawDiff` to Flamego `getRepoCommitRawDiff`. Now supports
  `?whitespace=` for the React diff toggle. Public URL unchanged.
- Add `POST/DELETE /api/web/{owner}/{name}/watch` and `.../star` returning
  the new viewer state + count so the client can update without refetch.
- Delete legacy `repo.Diff` and `repo.DiffJSON`. Add a SPA pass-through
  Macaron route at `/owner/repo/commit/{sha}` with the legacy
  `[a-f0-9]{7,40}` SHA regex.

Frontend:
- Install `@tanstack/react-query` and wire `QueryClientProvider` in
  `router.tsx`. Pass `queryClient` through router context so loaders can
  prefetch via `ensureQueryData`.
- Add `lib/queries/repo.ts` with `repoInfoQuery` + watch/star mutations.
- Move `CommitDiff.tsx` → `pages/repo/Commit.tsx` and `CommitDiff.search.ts`
  → `pages/repo/Commit.search.ts`. Rename `CommitDiff` → `RepoCommit`,
  `CommitDiffPage` → `RepoCommitPage`, etc.
- Change route from `/$owner/$repo/_diff/$sha` to
  `/$owner/$repo/commit/$sha`. Enforce SHA regex via TanStack `params.parse`
  and convert API 404s to router `notFound()` so they render the NotFound
  page instead of ServerError.
- Loader fetches metadata + raw diff in parallel (plus repo info via
  Query cache), assembles them into `RepoCommitPage`.
- Replace `RepoHeader`'s `RepoHeaderRepo` interface with the live
  `RepoInfo` type. Watch/Star buttons fire `useMutation` with optimistic
  cache updates via `setQueryData`. Anonymous users see sign-in links.
- Swap the "Public"/"Private" pill for a Globe/Lock icon with tooltip.
- Add a collapsible desktop file tree. The toolbar's "Showing N changed
  files" row owns a single toggle icon that opens the Sheet on mobile
  and toggles the persistent sidebar on desktop. State persists to
  localStorage.
- Hide the always-on "Verified" badge until commit signature
  verification lands.
2026-05-27 02:57:35 -04:00
Joe Chen c3577dc6fa 213 2026-05-26 13:18:29 -04:00
unknwon d7c3f16f7a web(diff): use PanelLeftOpen icon for mobile file-tree trigger 2026-05-26 12:02:25 -04:00
unknwon eb24142b83 web: polish commit diff page chrome and search UX
- DiffSearch: walk hunks by addition/deletionCount so matches on context
  lines and pure-deletion hunks are no longer dropped.
- DiffSearch: nudge popup up to top-1 so it sits closer to the toolbar.
- RepoHeader: add per-repo avatar slot (mocked to favicon for now),
  fold mobile tabs past the third into a hamburger overflow, swap
  Issues icon from Clock to CircleDot, nudge avatar down 2px to
  optically center the off-center favicon glyph.
- CommitDiff: render the authored timestamp as a relative string with
  RFC1123 tooltip (matches Gogs's TimeSince template helper); helper
  lives in web/src/lib/relative-time.ts.
- CommitDiff: inject GitHub-style yellow into Pierre's selected-line
  background overrides so search matches read clearly in both themes.
- AGENTS.md: note that chrome-devtools MCP should run headless.
2026-05-26 12:02:25 -04:00
unknwon dcad796c73 web: build out commit diff page on @pierre/diffs
Adds the full commit diff experience around the @pierre/diffs CodeView
and @pierre/trees FileTree spike from the prior commit:

- RepoHeader, DiffToolbar, FileHeaderMenu, ResizableSidebar components
  for the page chrome and per-file actions
- Sheet and Tooltip shadcn primitives
- CommitDiff.search.ts encodes diff toggles in the URL via TanStack
  Router validation so the view is shareable
- Sticky workspace lock that pins the toolbar plus tree plus diff to
  the viewport once the user scrolls past the commit metadata
- Whitespace mode wired through to git via the diff API's new
  whitespace query (ignore-all, ignore-change)
- Per-file collapse, status filter, unified/split toggle, wrap, expand
  all and collapse all
- New --color-success, --color-diff-added, --color-diff-removed
  tokens documented in DESIGN.md, replacing ad-hoc Tailwind palette
  references
2026-05-26 12:02:25 -04:00
Joe Chen 9b7d8ebd9d WIP: Pierre diff 2026-05-26 12:01:58 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 878caa7378 ci: notarize macOS release archives (#8297) 2026-05-24 23:08:45 -04:00
ᴊᴏᴇ ᴄʜᴇɴ adea243ee8 feat(web): migrate account activation page to React (#8296) 2026-05-24 22:35:41 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 44f0222a71 web: migrate /user/sign-out to Flamego (#8294) 2026-05-24 11:16:57 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 26483c41c6 feat(web): add React sign-up page with Flamego captcha (#8291) 2026-05-23 23:33:41 -04:00
dependabot[bot] 403db931cf mod: bump filippo.io/edwards25519 from 1.1.0 to 1.1.1 (#8292) 2026-05-23 22:25:56 -04:00
dependabot[bot] cd2f94a85b mod: bump github.com/redis/go-redis/v9 from 9.5.1 to 9.5.5 (#8293) 2026-05-23 22:25:41 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 4935e7a63b web: move password reset to React (#8290) 2026-05-23 21:55:22 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 71dfd3c7ac chore: fix up README image positions
[skip ci]
2026-05-22 16:31:17 -04:00
ᴊᴏᴇ ᴄʜᴇɴ ecb04beadd chore: align-center README images
[skip ci]
2026-05-22 16:28:22 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 83a48c286d fix: remove forgeable remember-me cookie, persist sessions by default (#8289) 2026-05-22 16:24:39 -04:00
ᴊᴏᴇ ᴄʜᴇɴ f739682e9a Move sign-in MFA step to React with /api/web/user/mfa (#8288) 2026-05-22 15:33:06 -04:00
ᴊᴏᴇ ᴄʜᴇɴ d54f98f5a4 feat(web): adopt Pierre theme palette and reorder sign-in tab stops (#8287) 2026-05-22 11:19:38 -04:00
ᴊᴏᴇ ᴄʜᴇɴ e7d0cb646d Update README banner for dark mode (#8286) 2026-05-22 00:56:13 -04:00
ᴊᴏᴇ ᴄʜᴇɴ dd6be39208 feat: React-based sign-in page with /api/web/user/sign-in (#8285) 2026-05-22 00:28:27 -04:00
ᴊᴏᴇ ᴄʜᴇɴ c93373baec feat: add /api/web/user/sign-out and nest user info under /user/info (#8284) 2026-05-21 15:05:19 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 90790b2966 Embed WebContext and add /api/web/user-info endpoint (#8282) 2026-05-21 14:43:09 -04:00
ᴊᴏᴇ ᴄʜᴇɴ e9310ea08f chore: remove Packager.io build support (#8281) 2026-05-20 22:55:08 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 75f99c9435 chore: remove Taskfile usage (#8280) 2026-05-20 19:22:50 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 343007e78a ci(moon): set default VCS branch to main (#8279) 2026-05-20 17:49:52 -04:00
ᴊᴏᴇ ᴄʜᴇɴ b67c13c6bb feat: introduce React web frontend and migrate home + 404 pages (#8276) 2026-05-20 17:45:31 -04:00
ᴊᴏᴇ ᴄʜᴇɴ a3c9f4acef Replace Docker latest on main with an edge tag (#8278) 2026-05-20 15:47:42 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 4c80cbc7eb refactor: inline disallowed-username regex in UsersStore.Authenticate (#8274) 2026-05-19 12:52:06 -04:00
ᴊᴏᴇ ᴄʜᴇɴ bfec14a857 refactor: render mail templates with html/template directly (#8272) 2026-05-19 12:20:13 -04:00
ᴊᴏᴇ ᴄʜᴇɴ cc8036e081 chore: remove DeepSource configuration (#8275)
[skip ci]
2026-05-19 12:11:37 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 765c0e96db refactor: replace toolbox middleware with a minimal /healthcheck handler (#8271) 2026-05-18 22:37:13 -04:00
ᴊᴏᴇ ᴄʜᴇɴ f3e563d854 refactor: prepare email package for web framework migration (#8270) 2026-05-18 22:22:46 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 199cf4fd5b security: don't follow redirects on webhook delivery (#8263) 2026-05-18 21:08:46 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 0089c4c8e5 auth: trust reverse proxy auth header only from configured proxies (#8264) 2026-05-18 13:42:46 -04:00
ᴊᴏᴇ ᴄʜᴇɴ 6734dd46c3 docs: add changelog entry for GHSA-3qq3-668m-v9mj (#8266)
[skip ci]
2026-05-17 20:59:17 -04:00
ᴊᴏᴇ ᴄʜᴇɴ a5d3439e2d chore: replace reflect.Ptr with reflect.Pointer (#8262) 2026-05-17 20:39:09 -04:00
dependabot[bot] d7571322a0 mod: bump golang.org/x/image from 0.36.0 to 0.38.0 (#8218)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 16:43:28 -04:00
dependabot[bot] edc83e6ab2 mod: bump github.com/cockroachdb/errors from 1.12.0 to 1.13.0 (#8245)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 08:48:53 -04:00
dependabot[bot] 7297aee50d mod: bump github.com/Azure/go-ntlmssp from 0.0.0-20221128193559-754e69321358 to 0.1.1 (#8238)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 22:09:12 -04:00
dependabot[bot] 27e92f8463 mod: bump github.com/jackc/pgx/v5 from 5.9.0 to 5.9.2 (#8237)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 21:57:16 -04:00
dependabot[bot] b36ba5b60e mod: bump github.com/jackc/pgx/v5 from 5.6.0 to 5.9.0 (#8233)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 18:58:35 -04:00
195 changed files with 17243 additions and 2136 deletions
-26
View File
@@ -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
View File
@@ -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
-1
View File
@@ -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
+66 -71
View File
@@ -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() }}
+2 -6
View File
@@ -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
+85 -15
View File
@@ -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 }}
+54
View File
@@ -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
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
$schema: "https://moonrepo.dev/schemas/tasks.json"
taskOptions:
outputStyle: "stream"
+9
View File
@@ -0,0 +1,9 @@
$schema: "https://moonrepo.dev/schemas/workspace.json"
projects:
gogs: "."
web: "web"
vcs:
client: "git"
defaultBranch: "main"
-1
View File
@@ -1 +0,0 @@
main
-1
View File
@@ -1 +0,0 @@
web: ./gogs web -p ${PORT:=3000}
-24
View File
@@ -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
-35
View File
@@ -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
+18 -3
View File
@@ -9,25 +9,40 @@ 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
- 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.
- Every 5xx response must log the error directly inside the handler, do not log errors in a shared helper.
## 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
- Use `gh` CLI to access information on github.com that is not publicly available.
- Run the Chrome DevTools MCP in headless mode so it does not steal focus from the user's foreground browser session. After finishing any task that used the Chrome DevTools MCP, kill all `chrome-devtools-mcp` processes with `pkill -f chrome-devtools-mcp`.
## Source code control
- 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+15 -4
View File
@@ -1,8 +1,19 @@
![gogs-brand](https://user-images.githubusercontent.com/2946214/146899259-6a8b58ad-8d6e-40d2-ab02-79dc6aadabbf.png)
[![GitHub Workflow Status](https://img.shields.io/github/checks-status/gogs/gogs/main?logo=github&style=for-the-badge)](https://github.com/gogs/gogs/actions?query=branch%3Amain) [![Sourcegraph](https://img.shields.io/badge/view%20on-Sourcegraph-brightgreen.svg?style=for-the-badge&logo=sourcegraph)](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
-99
View File
@@ -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
+13
View File
@@ -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{
+89
View File
@@ -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
}
+88
View File
@@ -0,0 +1,88 @@
package web
import (
"net/http"
"github.com/cockroachdb/errors"
"github.com/flamego/flamego"
log "unknwon.dev/clog/v2"
"gogs.io/gogs/internal/database"
"gogs.io/gogs/internal/ptrx"
)
// repoContext is the request-scoped viewer of the repository. Viewer can be an
// authenticated or anonymous user.
type repoContext struct {
Owner *database.User
Repo *database.Repository
ViewerID int64
viewerAccess database.AccessMode
}
func (c *repoContext) ViewerCanRead() bool {
return c.viewerAccess >= database.AccessModeRead
}
func (c *repoContext) ViewerCanWrite() bool {
return c.viewerAccess >= database.AccessModeWrite
}
func (c *repoContext) ViewerCanAdminister() bool {
return c.viewerAccess >= database.AccessModeAdmin
}
// withRepoContext injects the repoContext of the repository derived from the
// route.
func withRepoContext(c flamego.Context, user *database.User) {
ctx := c.Request().Context()
w := c.ResponseWriter()
ownerName := c.Param("owner")
repoName := c.Param("repo")
owner, err := database.Handle.Users().GetByUsername(ctx, ownerName)
if err != nil {
if database.IsErrUserNotExist(err) {
writeErrorResponse(w, http.StatusNotFound, errors.New("repository does not exist"))
return
}
log.Error("repoContext: get user by username %q: %v", ownerName, err)
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "get owner"))
return
}
repo, err := database.Handle.Repositories().GetByName(ctx, owner.ID, repoName)
if err != nil {
if database.IsErrRepoNotExist(err) {
writeErrorResponse(w, http.StatusNotFound, errors.New("repository does not exist"))
return
}
log.Error("repoContext: get repo by name %q/%q: %v", ownerName, repoName, err)
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "get repo"))
return
}
viewer := ptrx.Deref(user, database.User{})
var viewerAccess database.AccessMode
if viewer.IsAdmin {
viewerAccess = database.AccessModeOwner
} else {
viewerAccess = database.Handle.Permissions().AccessMode(
ctx,
viewer.ID,
repo.ID,
database.AccessModeOptions{
OwnerID: owner.ID,
Private: repo.IsPrivate,
},
)
}
c.Map(&repoContext{
Owner: owner,
Repo: repo,
ViewerID: viewer.ID,
viewerAccess: viewerAccess,
})
}
+252 -165
View File
@@ -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()
@@ -538,10 +412,9 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
c.Data["PageIsViewFiles"] = true
})
// FIXME: Should use c.Repo.PullRequest to unify template, currently we have inconsistent URL
// for PR in same repository. After select branch on the page, the URL contains redundant head user name.
// e.g. /org1/test-repo/compare/master...org1:develop
// which should be /org1/test-repo/compare/master...develop
// FIXME: Should use c.Repo.PullRequest to unify the template. Same-repo PR URLs include a
// redundant head user, e.g. /org1/test-repo/compare/master...org1:develop should be
// /org1/test-repo/compare/master...develop.
m.Combo("/compare/*", repo.MustAllowPulls).Get(repo.CompareAndPullRequest).
Post(bindIgnErr(form.NewIssue{}), repo.CompareAndPullRequestPost)
@@ -610,12 +483,14 @@ func runWeb(_ stdctx.Context, cmd *cli.Command) error {
m.Group("", func() {
m.Get("/src/*", repo.Home)
m.Get("/raw/*", repo.SingleDownload)
m.Get("/commits/*", repo.RefCommits)
m.Get("/commit/:sha([a-f0-9]{7,40})$", repo.Diff)
m.Get("/forks", repo.Forks)
}, repo.MustBeNotBare, context.RepoRef())
m.Get("/commit/:sha([a-f0-9]{7,40})\\.:ext(patch|diff)", repo.MustBeNotBare, repo.RawDiff)
// Bridged to Flamego to skip the legacy `RepoRef` middleware, which double-resolves the ref.
m.Get("/raw/*", flamegoBridger(webHandler))
m.Get("/commit/:sha([a-f0-9]{7,40})\\.:ext(patch|diff)", flamegoBridger(webHandler))
// Constrain SHA shape so non-matching `/commit/...` paths 404 instead of loading the SPA with a bad param.
m.Get("/commit/:sha([a-f0-9]{7,40})$", repo.MustBeNotBare, func(c *context.Context) { c.ServeWeb() })
m.Get("/compare/:before([a-z0-9]{40})\\.\\.\\.:after([a-z0-9]{40})", repo.MustBeNotBare, context.RepoRef(), repo.CompareDiff)
}, ignSignIn, context.RepoAssignment())
@@ -634,6 +509,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 +523,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 +535,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 +549,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 +587,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 +644,226 @@ 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.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.Get("/redirect", getRedirect)
// The captcha middleware writes the response. This route exists so the request reaches it.
f.Get("/captcha/image.jpeg", func() {})
f.Group("/{owner}/{repo}", func() {
f.Get("/commit/{sha: /[0-9a-f]{7,40}/}.{format: /(diff|patch)/}", getRepoCommitRaw)
f.Get("/raw/{ref}/{filepath: **}", getRepoRawFile)
}, withRepoContext)
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 &, so the payload cannot break out of the surrounding <script>.
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 != "" {
// Prefix Vite's absolute root paths with the subpath for 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"))
}
+182
View File
@@ -0,0 +1,182 @@
package web
import (
"encoding/json"
"net/http"
"net/url"
"path"
"strconv"
"github.com/cockroachdb/errors"
"github.com/flamego/flamego"
"github.com/gogs/git-module"
"gogs.io/gogs/internal/conf"
"gogs.io/gogs/internal/gitx"
"gogs.io/gogs/internal/repox"
"gogs.io/gogs/internal/tool"
log "unknwon.dev/clog/v2"
)
// whitespaceFlag maps the `whitespace` query value to its `git diff` flag.
// `ignore-change` (`-b`) still surfaces added/removed blank lines, unlike
// `ignore-all` (`-w`). Empty or unknown values disable whitespace handling.
func whitespaceFlag(v string) string {
switch v {
case "ignore-all":
return "-w"
case "ignore-change":
return "-b"
default:
return ""
}
}
func writeErrorResponse(w http.ResponseWriter, code int, err error) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
message := err.Error()
// Match the JSON-API ReturnHandler: in prod, never leak 5xx detail.
if code >= http.StatusInternalServerError && conf.IsProdMode() {
message = "Internal server error"
}
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func getRepoCommitRaw(c flamego.Context, repoCtx *repoContext) {
w := c.ResponseWriter()
if !repoCtx.ViewerCanRead() {
writeErrorResponse(w, http.StatusNotFound, errors.New("repository does not exist"))
return
}
owner := repoCtx.Owner
repo := repoCtx.Repo
sha := c.Param("sha")
format := c.Param("format")
gitRepo, err := git.Open(repox.RepositoryPath(owner.Name, repo.Name))
if err != nil {
log.Error("getRepoCommitRaw: open repository %q/%q: %v", owner.Name, repo.Name, err)
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "open repository"))
return
}
if _, err = gitRepo.CatFileCommit(sha); err != nil {
if gitx.IsErrRevisionNotExist(err) {
writeErrorResponse(w, http.StatusNotFound, errors.New("commit does not exist"))
return
}
log.Error("getRepoCommitRaw: cat-file commit %q in %q/%q: %v", sha, owner.Name, repo.Name, err)
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "cat-file commit"))
return
}
var rawOpts []git.RawDiffOptions
if flag := whitespaceFlag(c.Request().URL.Query().Get("whitespace")); flag != "" {
rawOpts = append(rawOpts, git.RawDiffOptions{
CommandOptions: git.CommandOptions{Args: []string{flag}},
})
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err = gitRepo.RawDiff(sha, git.RawDiffFormat(format), w, rawOpts...); err != nil {
log.Error("getRepoCommitRaw: get raw diff %s: %v", sha, err)
}
}
// resolveRef resolves ref as a commit SHA, then a branch name, then a tag
// name, in that order. A branch and tag of the same name resolve to the branch.
func resolveRef(gitRepo *git.Repository, ref string) (*git.Commit, error) {
commit, err := gitRepo.CatFileCommit(ref)
if err == nil {
return commit, nil
}
if !gitx.IsErrRevisionNotExist(err) {
return nil, errors.Wrap(err, "cat-file commit")
}
commit, err = gitRepo.BranchCommit(ref)
if err == nil {
return commit, nil
}
if !gitx.IsErrRevisionNotExist(err) {
return nil, errors.Wrap(err, "get branch commit")
}
commit, err = gitRepo.TagCommit(ref)
if err != nil {
return nil, errors.Wrap(err, "get tag commit")
}
return commit, nil
}
func getRepoRawFile(c flamego.Context, repoCtx *repoContext) {
w := c.ResponseWriter()
if !repoCtx.ViewerCanRead() {
writeErrorResponse(w, http.StatusNotFound, errors.New("repository does not exist"))
return
}
owner := repoCtx.Owner
repo := repoCtx.Repo
rawRef := c.Param("ref")
filepath := c.Param("filepath")
ref, err := url.PathUnescape(rawRef)
if err != nil {
writeErrorResponse(w, http.StatusNotFound, errors.New("ref does not exist"))
return
}
gitRepo, err := git.Open(repox.RepositoryPath(owner.Name, repo.Name))
if err != nil {
log.Error("getRepoRawFile: open repository %q/%q: %v", owner.Name, repo.Name, err)
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "open repository"))
return
}
commit, err := resolveRef(gitRepo, ref)
if err != nil {
if gitx.IsErrRevisionNotExist(err) {
writeErrorResponse(w, http.StatusNotFound, errors.New("ref does not exist"))
return
}
log.Error("getRepoRawFile: resolve ref %q in %q/%q: %v", ref, owner.Name, repo.Name, err)
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "resolve ref"))
return
}
blob, err := commit.Blob(filepath)
if err != nil {
if gitx.IsErrRevisionNotExist(err) || errors.Is(err, git.ErrNotBlob) {
writeErrorResponse(w, http.StatusNotFound, errors.New("file does not exist"))
return
}
log.Error("getRepoRawFile: blob %s:%s in %q/%q: %v", commit.ID, filepath, owner.Name, repo.Name, err)
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "get blob"))
return
}
data, err := blob.Bytes()
if err != nil {
log.Error("getRepoRawFile: read blob %s:%s: %v", commit.ID, filepath, err)
writeErrorResponse(w, http.StatusInternalServerError, errors.Wrap(err, "read blob"))
return
}
if pathCommit, err := commit.CommitByPath(git.CommitByRevisionOptions{Path: filepath}); err == nil && pathCommit != nil {
w.Header().Set("Last-Modified", pathCommit.Committer.When.Format(http.TimeFormat))
}
render, _ := strconv.ParseBool(c.Request().URL.Query().Get("render"))
switch {
case !tool.IsTextFile(data) && !tool.IsImageFile(data):
w.Header().Set("Content-Disposition", `attachment; filename="`+path.Base(filepath)+`"`)
w.Header().Set("Content-Transfer-Encoding", "binary")
case tool.IsTextFile(data) && (!conf.Repository.EnableRawFileRenderMode || !render):
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
_, _ = w.Write(data)
}
+219
View File
@@ -0,0 +1,219 @@
package web
import (
stdctx "context"
"encoding/json"
"net/http"
"reflect"
"regexp"
"strings"
"github.com/cockroachdb/errors"
"github.com/flamego/binding"
"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"
"gogs.io/gogs/internal/context"
"gogs.io/gogs/internal/database"
)
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 enforceWebAPIMaxBodySize(c flamego.Context) {
r := c.Request().Request
r.Body = http.MaxBytesReader(c.ResponseWriter(), r.Body, 4*1024) // 4 KiB
}
// 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.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)
})
f.Group("/{owner}/{repo}", func() {
f.Get("/header", getRepoHeader)
f.Get("/commit/{sha: /[0-9a-f]{7,40}/}", getRepoCommit)
f.Combo("/watch").Post(postRepoWatch).Delete(deleteRepoWatch)
f.Combo("/star").Post(postRepoStar).Delete(deleteRepoStar)
}, withRepoContext)
}, enforceWebAPIMaxBodySize)
}
// 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}
}
+248
View File
@@ -0,0 +1,248 @@
package web
import (
"context"
"net/http"
"time"
"github.com/cockroachdb/errors"
"github.com/flamego/flamego"
"github.com/gogs/git-module"
log "unknwon.dev/clog/v2"
"gogs.io/gogs/internal/conf"
"gogs.io/gogs/internal/database"
"gogs.io/gogs/internal/gitx"
"gogs.io/gogs/internal/repox"
"gogs.io/gogs/internal/strx"
"gogs.io/gogs/internal/tool"
)
type repoHeader struct {
ID int64 `json:"id"`
Owner string `json:"owner"`
Name string `json:"name"`
AvatarURL string `json:"avatarURL"`
Visibility string `json:"visibility"`
MirrorOf string `json:"mirrorOf,omitempty"`
WatchCount int `json:"watchCount"`
StarCount int `json:"starCount"`
ForkCount int `json:"forkCount"`
IssuesEnabled bool `json:"issuesEnabled"`
OpenIssueCount int `json:"openIssueCount"`
PullRequestsEnabled bool `json:"pullRequestsEnabled"`
OpenPullRequestCount int `json:"openPullRequestCount"`
WikiEnabled bool `json:"wikiEnabled"`
ViewerCanAdminister bool `json:"viewerCanAdminister"`
ViewerIsWatching bool `json:"viewerIsWatching"`
ViewerIsStarring bool `json:"viewerIsStarring"`
}
func getRepoHeader(repoCtx *repoContext) (statusCode int, resp *repoHeader, err error) {
owner := repoCtx.Owner
repo := repoCtx.Repo
issuesEnabled := repo.EnableIssues
wikiEnabled := repo.EnableWiki
if !repoCtx.ViewerCanRead() {
if !repo.IsPartialPublic() {
return http.StatusNotFound, nil, errors.New("repository does not exist")
}
issuesEnabled = repo.CanGuestViewIssues()
wikiEnabled = repo.CanGuestViewWiki()
}
visibility := "public"
if repo.IsPrivate {
visibility = "private"
}
resp = &repoHeader{
ID: repo.ID,
Owner: owner.Name,
Name: repo.Name,
AvatarURL: strx.Coalesce(repo.AvatarLink(), owner.AvatarURL()),
Visibility: visibility,
WatchCount: repo.NumWatches,
StarCount: repo.NumStars,
ForkCount: repo.NumForks,
IssuesEnabled: issuesEnabled,
OpenIssueCount: repo.NumIssues - repo.NumClosedIssues,
PullRequestsEnabled: repo.AllowsPulls(),
OpenPullRequestCount: repo.NumPulls - repo.NumClosedPulls,
WikiEnabled: wikiEnabled,
ViewerCanAdminister: repoCtx.ViewerCanAdminister(),
ViewerIsWatching: database.IsWatching(repoCtx.ViewerID, repo.ID),
ViewerIsStarring: database.IsStarring(repoCtx.ViewerID, repo.ID),
}
if repo.IsMirror {
mirror, err := database.GetMirrorByRepoID(repo.ID)
if err != nil {
log.Error("getRepoHeader: get mirror by repo ID %d: %v", repo.ID, err)
} else if mirror != nil {
resp.MirrorOf = mirror.Address()
}
}
return http.StatusOK, resp, nil
}
type repoCommitSignature struct {
Name string `json:"name"`
Email string `json:"email"`
When time.Time `json:"when"`
AvatarURL string `json:"avatarURL"`
ProfileURL string `json:"profileURL,omitempty"`
}
type repoCommit struct {
SHA string `json:"sha"`
Subject string `json:"subject"`
Body string `json:"body"`
Author repoCommitSignature `json:"author"`
Parents []string `json:"parents"`
}
func getRepoCommit(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoCommit, err error) {
if !repoCtx.ViewerCanRead() {
return http.StatusNotFound, nil, errors.New("repository does not exist")
}
ctx := c.Request().Context()
owner := repoCtx.Owner
repo := repoCtx.Repo
commitID := c.Param("sha")
gitRepo, err := git.Open(repox.RepositoryPath(owner.Name, repo.Name))
if err != nil {
log.Error("getRepoCommit: open repository %q/%q: %v", owner.Name, repo.Name, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "open repository")
}
commit, err := gitRepo.CatFileCommit(commitID)
if err != nil {
if gitx.IsErrRevisionNotExist(err) {
return http.StatusNotFound, nil, nil
}
log.Error("getRepoCommit: cat-file commit %q in %q/%q: %v", commitID, owner.Name, repo.Name, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "cat-file commit")
}
parents := make([]string, commit.ParentsCount())
for i := 0; i < commit.ParentsCount(); i++ {
sha, err := commit.ParentID(i)
if err != nil {
log.Error("getRepoCommit: parent ID %d for %q in %q/%q: %v", i, commitID, owner.Name, repo.Name, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "parent ID")
}
parents[i] = sha.String()
}
toSignature := func(s *git.Signature) repoCommitSignature {
sig := repoCommitSignature{
Name: s.Name,
Email: s.Email,
When: s.When.UTC(),
AvatarURL: tool.AvatarLink(s.Email),
}
if u, err := database.Handle.Users().GetByEmail(ctx, s.Email); err == nil && u != nil {
sig.ProfileURL = conf.Server.Subpath + "/" + u.Name
}
return sig
}
subject := commit.Summary()
var body string
if msg := commit.Message; len(msg) > len(subject) {
body = msg[len(subject):]
}
return http.StatusOK, &repoCommit{
SHA: commitID,
Subject: subject,
Body: body,
Author: toSignature(commit.Author),
Parents: parents,
}, nil
}
type repoWatchResponse struct {
WatchCount int `json:"watchCount"`
}
func repoWatchAction(ctx context.Context, repoCtx *repoContext, watching bool) (statusCode int, resp *repoWatchResponse, err error) {
if repoCtx.ViewerCanRead() {
return http.StatusNotFound, nil, errors.New("repository does not exist")
}
repo := repoCtx.Repo
if watching {
err = database.Handle.Repositories().Watch(ctx, repoCtx.ViewerID, repo.ID)
} else {
err = database.WatchRepo(repoCtx.ViewerID, repo.ID, false)
}
if err != nil {
log.Error("repoWatchAction: set watching=%t for user %d on repo %d: %v", watching, repoCtx.ViewerID, repo.ID, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "watch repo")
}
updated, err := database.Handle.Repositories().GetByName(ctx, repo.OwnerID, repo.Name)
if err != nil {
log.Error("repoWatchAction: reload repo %d (%q): %v", repo.ID, repo.Name, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "reload repo")
}
return http.StatusOK, &repoWatchResponse{
WatchCount: updated.NumWatches,
}, nil
}
func postRepoWatch(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoWatchResponse, err error) {
return repoWatchAction(c.Request().Context(), repoCtx, true)
}
func deleteRepoWatch(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoWatchResponse, err error) {
return repoWatchAction(c.Request().Context(), repoCtx, false)
}
type repoStarResponse struct {
StarCount int `json:"starCount"`
}
func repoStarAction(ctx context.Context, repoCtx *repoContext, starring bool) (statusCode int, resp *repoStarResponse, err error) {
if !repoCtx.ViewerCanRead() {
return http.StatusNotFound, nil, errors.New("repository does not exist")
}
repo := repoCtx.Repo
if starring {
err = database.Handle.Repositories().Star(ctx, repoCtx.ViewerID, repo.ID)
} else {
err = database.StarRepo(repoCtx.ViewerID, repo.ID, false)
}
if err != nil {
log.Error("repoStarAction: set starred=%t for user %d on repo %d: %v", starring, repoCtx.ViewerID, repo.ID, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "star repo")
}
updated, err := database.Handle.Repositories().GetByName(ctx, repo.OwnerID, repo.Name)
if err != nil {
log.Error("repoStarAction: reload repo %d (%q): %v", repo.ID, repo.Name, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "reload repo")
}
return http.StatusOK, &repoStarResponse{
StarCount: updated.NumStars,
}, nil
}
func postRepoStar(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoStarResponse, err error) {
return repoStarAction(c.Request().Context(), repoCtx, true)
}
func deleteRepoStar(c flamego.Context, repoCtx *repoContext) (statusCode int, resp *repoStarResponse, err error) {
return repoStarAction(c.Request().Context(), repoCtx, false)
}
+514
View File
@@ -0,0 +1,514 @@
package web
import (
stdctx "context"
"encoding/hex"
"net/http"
"os"
"strconv"
"time"
"github.com/cockroachdb/errors"
"github.com/flamego/cache"
"github.com/flamego/captcha"
"github.com/flamego/session"
"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/database"
"gogs.io/gogs/internal/email"
"gogs.io/gogs/internal/tool"
"gogs.io/gogs/internal/userx"
)
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
}
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
}
+62
View File
@@ -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
}
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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
+151 -46
View File
@@ -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,53 @@ 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
close = Close
show_more = Show more
show_less = Show less
resize_sidebar = Resize sidebar
more = More
more_tabs = More tabs
more_actions = More actions
[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 +134,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 +169,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
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 = Confirm 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 +244,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 +257,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 +286,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.
@@ -458,7 +509,32 @@ unwatch = Unwatch
watch = Watch
unstar = Unstar
star = Star
starred = Starred
fork = Fork
mirror_of = mirror of
sign_in_to_watch = Sign in to watch this repository
sign_in_to_star = Sign in to star this repository
sign_in_to_fork = Sign in to fork this repository
watch_this_repository = Watch this repository
unwatch_this_repository = Unwatch this repository
star_this_repository = Star this repository
unstar_this_repository = Unstar this repository
fork_this_repository = Fork this repository
visibility_private = This repository is private
visibility_public = This repository is public
view_watchers = View watchers
view_stargazers = View stargazers
view_forks = View forks
browse_files = Browse files
view_history = View history
view_raw = View raw
copy_file_path = Copy file path
copy_full_sha = Copy full SHA
renamed_from = Renamed from
authored = authored
parents = parents
diff_label = diff
patch_label = patch
no_desc = No Description
quick_guide = Quick Guide
@@ -887,9 +963,39 @@ diff.show_split_view = Split View
diff.show_unified_view = Unified View
diff.stats_desc = <strong> %d changed files</strong> with <strong>%d additions</strong> and <strong>%d deletions</strong>
diff.bin = BIN
diff.view_file = View File
diff.view_file = View file
diff.file_suppressed = File diff suppressed because it is too large
diff.too_many_files = Some files were not shown because too many files changed in this diff
diff.showing_changed_files = Showing <count>{count} changed files</count>
diff.additions = additions
diff.deletions = deletions
diff.unified = Unified
diff.split = Split
diff.diff_settings = Diff settings
diff.whitespace = Whitespace
diff.show_whitespace = Show whitespace
diff.ignore_whitespace_changes = Ignore whitespace changes
diff.ignore_all_whitespace = Ignore all whitespace
diff.display = Display
diff.wrap_long_lines = Wrap long line
diff.expand_all_files = Expand all files
diff.collapse_all_files = Collapse all files
show_file_tree = Show file tree
hide_file_tree = Hide file tree
expand_all_directories = Expand all directories
collapse_all_directories = Collapse all directories
search_files = Search files
search_hide = Hide search
search_diff = Search in diff
search_previous_match = Previous match
search_next_match = Next match
commit_parent = parent
commit_label = commit
view_file = View file
diff.expand_file = Expand file
diff.collapse_file = Collapse file
diff.expand_all_lines = Expand all lines
diff.all_lines_expanded = All lines expanded
release.releases = Releases
release.new_release = New Release
@@ -1085,7 +1191,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 +1224,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 +1354,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 +1388,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
-20
View File
@@ -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
+1 -1
View File
@@ -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"`.
+9 -5
View File
@@ -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
-1
View File
@@ -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>
+34 -17
View File
@@ -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
+81 -24
View File
@@ -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=
+15 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"`
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+48
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -261,7 +261,7 @@ func RepoAssignment(pages ...bool) macaron.Handler {
if c.IsLogged {
c.Data["IsWatchingRepo"] = database.IsWatching(c.User.ID, repo.ID)
c.Data["IsStaringRepo"] = database.IsStaring(c.User.ID, repo.ID)
c.Data["IsStaringRepo"] = database.IsStarring(c.User.ID, repo.ID)
}
// repo is bare and display enable
+11 -5
View File
@@ -331,7 +331,7 @@ func (r *Repository) RelAvatarLink() string {
// AvatarLink returns repository avatar absolute link.
func (r *Repository) AvatarLink() string {
link := r.RelAvatarLink()
if link[0] == '/' && link[1] != '/' {
if len(link) > 2 && link[0] == '/' && link[1] != '/' {
return conf.Server.ExternalURL + strings.TrimPrefix(link, conf.Server.Subpath)[1:]
}
return link
@@ -2362,6 +2362,9 @@ type Watch struct {
}
func isWatching(e Engine, userID, repoID int64) bool {
if userID <= 0 {
return false
}
has, _ := e.Get(&Watch{0, userID, repoID})
return has
}
@@ -2484,7 +2487,7 @@ type Star struct {
// Deprecated: Use Stars.Star instead.
func StarRepo(userID, repoID int64, star bool) (err error) {
if star {
if IsStaring(userID, repoID) {
if IsStarring(userID, repoID) {
return nil
}
if _, err = x.Insert(&Star{UserID: userID, RepoID: repoID}); err != nil {
@@ -2494,7 +2497,7 @@ func StarRepo(userID, repoID int64, star bool) (err error) {
}
_, err = x.Exec("UPDATE `user` SET num_stars = num_stars + 1 WHERE id = ?", userID)
} else {
if !IsStaring(userID, repoID) {
if !IsStarring(userID, repoID) {
return nil
}
if _, err = x.Delete(&Star{0, userID, repoID}); err != nil {
@@ -2507,8 +2510,11 @@ func StarRepo(userID, repoID int64, star bool) (err error) {
return err
}
// IsStaring checks if user has starred given repository.
func IsStaring(userID, repoID int64) bool {
// IsStarring checks if user has starred given repository.
func IsStarring(userID, repoID int64) bool {
if userID <= 0 {
return false
}
has, _ := x.Get(&Star{0, userID, repoID})
return has
}
-6
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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")
}
+124
View File
@@ -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" }
+2 -2
View File
@@ -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()
}
-29
View File
@@ -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)
}
// __________________________________________.___ _______ ________ _________
// / _____/\_ _____/\__ ___/\__ ___/| |\ \ / _____/ / _____/
// \_____ \ | __)_ | | | | | |/ | \/ \ ___ \_____ \
+16 -3
View File
@@ -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") == "" {
+10
View File
@@ -0,0 +1,10 @@
package ptrx
// Deref safely dereferences a pointer. If pointer is nil, returns default value,
// otherwise returns dereferenced value.
func Deref[T any](v *T, defaultValue T) T {
if v != nil {
return *v
}
return defaultValue
}
+35
View File
@@ -0,0 +1,35 @@
package ptrx
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDeref(t *testing.T) {
t.Run("nil pointer returns default", func(t *testing.T) {
assert.Equal(t, 42, Deref(nil, 42))
assert.Equal(t, "", Deref(nil, ""))
assert.Equal(t, "fallback", Deref(nil, "fallback"))
assert.Equal(t, false, Deref(nil, false))
})
t.Run("non-nil pointer returns dereferenced value", func(t *testing.T) {
intVal := 7
assert.Equal(t, 7, Deref(&intVal, 0))
strVal := "hello"
assert.Equal(t, "hello", Deref(&strVal, "default"))
boolVal := true
assert.Equal(t, true, Deref(&boolVal, false))
})
t.Run("zero value pointer returns zero value", func(t *testing.T) {
zeroInt := 0
assert.Equal(t, 0, Deref(&zeroInt, 99))
emptyStr := ""
assert.Equal(t, "", Deref(&emptyStr, "fallback"))
})
}
+2 -21
View File
@@ -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))
}
+4 -2
View File
@@ -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")
}
-70
View File
@@ -111,76 +111,6 @@ func tryGetUserByEmail(ctx gocontext.Context, email string) *database.User {
return user
}
func Diff(c *context.Context) {
c.PageIs("Diff")
c.RequireHighlightJS()
userName := c.Repo.Owner.Name
repoName := c.Repo.Repository.Name
commitID := c.Params(":sha")
commit, err := c.Repo.GitRepo.CatFileCommit(commitID)
if err != nil {
c.NotFoundOrError(gitx.NewError(err), "get commit by ID")
return
}
diff, err := gitx.RepoDiff(c.Repo.GitRepo,
commitID, conf.Git.MaxDiffFiles, conf.Git.MaxDiffLines, conf.Git.MaxDiffLineChars,
git.DiffOptions{Timeout: time.Duration(conf.Git.Timeout.Diff) * time.Second},
)
if err != nil {
c.NotFoundOrError(gitx.NewError(err), "get diff")
return
}
parents := make([]string, commit.ParentsCount())
for i := 0; i < commit.ParentsCount(); i++ {
sha, err := commit.ParentID(i)
if err != nil {
c.NotFound()
return
}
parents[i] = sha.String()
}
setEditorconfigIfExists(c)
if c.Written() {
return
}
c.RawTitle(commit.Summary() + " · " + tool.ShortSHA1(commitID))
c.Data["CommitID"] = commitID
c.Data["IsSplitStyle"] = c.Query("style") == "split"
c.Data["Username"] = userName
c.Data["Reponame"] = repoName
c.Data["IsImageFile"] = commit.IsImageFile
c.Data["IsImageFileByIndex"] = commit.IsImageFileByIndex
c.Data["Commit"] = commit
c.Data["Author"] = tryGetUserByEmail(c.Req.Context(), commit.Author.Email)
c.Data["Diff"] = diff
c.Data["Parents"] = parents
c.Data["DiffNotAvailable"] = diff.NumFiles() == 0
c.Data["SourcePath"] = conf.Server.Subpath + "/" + path.Join(userName, repoName, "src", commitID)
c.Data["RawPath"] = conf.Server.Subpath + "/" + path.Join(userName, repoName, "raw", commitID)
if commit.ParentsCount() > 0 {
c.Data["BeforeSourcePath"] = conf.Server.Subpath + "/" + path.Join(userName, repoName, "src", parents[0])
c.Data["BeforeRawPath"] = conf.Server.Subpath + "/" + path.Join(userName, repoName, "raw", parents[0])
}
c.Success(DIFF)
}
func RawDiff(c *context.Context) {
if err := c.Repo.GitRepo.RawDiff(
c.Params(":sha"),
git.RawDiffFormat(c.Params(":ext")),
c.Resp,
); err != nil {
c.NotFoundOrError(gitx.NewError(err), "get raw diff")
return
}
}
type userCommit struct {
User *database.User
*git.Commit
-14
View File
@@ -9,7 +9,6 @@ import (
"gogs.io/gogs/internal/conf"
"gogs.io/gogs/internal/context"
"gogs.io/gogs/internal/gitx"
"gogs.io/gogs/internal/tool"
)
@@ -43,16 +42,3 @@ func ServeBlob(c *context.Context, blob *git.Blob) error {
return serveData(c, path.Base(c.Repo.TreePath), p)
}
func SingleDownload(c *context.Context) {
blob, err := c.Repo.Commit.Blob(c.Repo.TreePath)
if err != nil {
c.NotFoundOrError(gitx.NewError(err), "get blob")
return
}
if err = ServeBlob(c, blob); err != nil {
c.Error(err, "serve blob")
return
}
}
+22 -7
View File
@@ -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
+75
View File
@@ -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)
})
}
}
+2 -2
View File
@@ -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)
}
-644
View File
@@ -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)
}
+50
View File
@@ -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")
+10
View File
@@ -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 {
+17
View File
@@ -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
+146
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
{
"name": "gogs",
"private": true,
"packageManager": "pnpm@11.1.3"
}
+4789
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
packages:
- "web"
onlyBuiltDependencies:
- esbuild
+17
View File
@@ -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) {
+9
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
//go:build prod
package public
import "embed"
//go:embed all:dist
var WebAssets embed.FS
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"shadcn": {
"source": "shadcn/ui",
"sourceType": "github",
"skillPath": "skills/shadcn/SKILL.md",
"computedHash": "80a6226e78f6d1fe464214ae0ef449d49d8ffaa3e7704f011e9b418c678ad4d1"
}
}
}
+2 -4
View File
@@ -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}}
+2 -5
View File
@@ -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>
+2 -3
View File
@@ -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 -->
+21
View File
@@ -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.
-350
View File
@@ -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" .}}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
-8
View File
@@ -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" .}}
-38
View File
@@ -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" .}}
-34
View File
@@ -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" .}}
-66
View File
@@ -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" .}}
-31
View File
@@ -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" .}}
-56
View File
@@ -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" .}}
-28
View File
@@ -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" .}}
+2
View File
@@ -0,0 +1,2 @@
*.tsbuildinfo
.vite
+12
View File
@@ -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,
};
+103
View File
@@ -0,0 +1,103 @@
# 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-success`: affirmative state for signature verification badges and copy-confirm checkmarks. Lighter in dark mode (`#4ade80`) than light (`#15803d`) so it reads on both backgrounds. Always pair with a label or icon, never color alone.
- `--color-diff-added` / `--color-diff-removed`: diff change markers (the +/- dots in the diff toolbar stats row, and any future per-line tints). Separate from `--color-success`/`--color-destructive` so the diff palette can drift toward the universal git green/red without dragging the success/error semantics along.
- `--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.
## Interactive affordances
Tailwind 4's preflight removes the browser default `cursor: pointer` on `<button>`. Without it, controls visually read as static text. Apply `cursor-pointer` on every interactive element that isn't a plain link: buttons, custom clickable rows, menu triggers, anything whose `onClick` activates an action. `<a href>` keeps the link cursor automatically.
When in doubt, hover-test the new control: if the cursor is still an I-beam or arrow, add `cursor-pointer`.
## Tooltips
Use the `Tooltip` component from `@/components/ui/tooltip` (built on `@radix-ui/react-tooltip`) for any hover hint. Never use the native HTML `title` attribute: it renders unstyled, has inconsistent timing across browsers, and is invisible on touch. The tooltip provider lives at the router root, so `Tooltip`/`TooltipTrigger`/`TooltipContent` work anywhere downstream.
The tooltip is supplementary information, not a substitute for an accessible name. Icon-only buttons still need `aria-label`. The tooltip just makes the same label visible to sighted users.
## 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. Exception: edge-seam affordances like the resize handle in `ResizableSidebar` stay visually thin (48px) and rely on a keyboard fallback (`tabIndex={0}` plus arrow-key handlers) for accessibility. The handle is desktop-only (`lg:block`), so the 24px tap floor for touch users does not apply.
- **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.
+44
View File
@@ -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 }],
},
},
];
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html>
<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>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+59
View File
@@ -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"

Some files were not shown because too many files have changed in this diff Show More