Compare commits

..

63 Commits

Author SHA1 Message Date
Thomas Miceli
8a6f2d82ff v1.12.2
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-04-28 16:37:03 +07:00
John71
bfd75a9d58 fix(Gitea): Field avatar_url not found in Gitea JSON response by using gothic provided user data and removing our API call (#674)
Co-authored-by: John71 <john71@noreply.git.john71.fr>
2026-04-26 19:45:26 +08:00
dependabot[bot]
eef6029c95 Bump softprops/action-gh-release from 2 to 3 (#691)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 19:32:59 +08:00
Thomas Miceli
c60094c778 Update Go & JS deps / Remove Dependabot for those deps (#694)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-04-26 19:28:17 +08:00
Johannes Kirchner
f67bff59c3 feat: add environment variables and secrets to statefulset and deployment (#644)
* feat: add environment variables and secrets to statefulset

* feat: add env and envFromSecrets to deplyoment container
2026-04-26 18:52:21 +08:00
dependabot[bot]
ec26888487 Bump github.com/labstack/echo-contrib from 0.17.4 to 0.18.0 (#667)
Bumps [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib) from 0.17.4 to 0.18.0.
- [Release notes](https://github.com/labstack/echo-contrib/releases)
- [Commits](https://github.com/labstack/echo-contrib/compare/v0.17.4...v0.18.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo-contrib
  dependency-version: 0.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 11:36:13 +08:00
dependabot[bot]
57d76151fd Bump golang.org/x/crypto from 0.48.0 to 0.49.0 (#669)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/crypto/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 11:33:38 +08:00
dependabot[bot]
c2ee390841 Bump github.com/go-webauthn/webauthn from 0.16.0 to 0.16.1 (#671)
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.16.0 to 0.16.1.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.16.0...v0.16.1)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.16.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 11:33:26 +08:00
dependabot[bot]
d6fc346e70 Bump @codemirror/view from 6.39.16 to 6.40.0 (#670)
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.39.16 to 6.40.0.
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.39.16...6.40.0)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-version: 6.40.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 11:33:09 +08:00
dependabot[bot]
4e977077ba Bump nodemon from 3.1.11 to 3.1.14 (#672)
Bumps [nodemon](https://github.com/remy/nodemon) from 3.1.11 to 3.1.14.
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.11...v3.1.14)

---
updated-dependencies:
- dependency-name: nodemon
  dependency-version: 3.1.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 11:32:51 +08:00
dependabot[bot]
f865b2b099 Bump @codemirror/commands from 6.10.1 to 6.10.3 (#664)
Bumps [@codemirror/commands](https://github.com/codemirror/commands) from 6.10.1 to 6.10.3.
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/compare/6.10.1...6.10.3)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.10.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 11:32:24 +08:00
Thomas Miceli
d26221de54 Translated using Weblate (Ukrainian) (#659)
Currently translated at 69.4% (243 of 350 strings)

Translation: Opengist/Opengist
Translate-URL: http://tr.opengist.io/projects/_/opengist/uk/

Co-authored-by: Anonymous <noreply@weblate.org>
2026-03-13 11:17:23 +08:00
Thomas Miceli
e91139d3ec Improve code search + tests (#663)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
Co-authored-by: Qiang Zhou <zhouqiang.loaded@bytedance.com>
Co-authored-by: theodoruszq <theodoruszq@gmail.com>
2026-03-13 11:16:10 +08:00
Webysther Sperandio
279da52899 feat: search all fields (#622)
*  feat(search): search all feature

- add Description field to Gist struct and index it
- extend SearchGistMetadata with Description and Content
- update Bleve and Meilisearch to index and search Description
- modify ParseSearchQueryStr to parse description: and content: keywords
- update templates and i18n for new search options

* Fix test

* Set content by default

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>

* Config to define default searchable fields

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>

---------

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
Co-authored-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-12 00:55:23 +08:00
dependabot[bot]
5ad01a3304 Bump @codemirror/view from 6.39.11 to 6.39.16 (#653)
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.39.11 to 6.39.16.
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.39.11...6.39.16)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-version: 6.39.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 00:52:53 +08:00
dependabot[bot]
1944502d14 Bump marked from 17.0.3 to 17.0.4 (#652)
Bumps [marked](https://github.com/markedjs/marked) from 17.0.3 to 17.0.4.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v17.0.3...v17.0.4)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 17.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 00:52:39 +08:00
dependabot[bot]
2d7261ac83 Bump docker/setup-qemu-action from 3 to 4 (#654)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 00:52:12 +08:00
dependabot[bot]
50f2980c10 Bump docker/login-action from 3 to 4 (#655)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 00:52:03 +08:00
dependabot[bot]
2e68b6893b Bump docker/build-push-action from 6 to 7 (#656)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 00:51:52 +08:00
dependabot[bot]
9b68f08c62 Bump docker/metadata-action from 5 to 6 (#657)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 00:51:21 +08:00
dependabot[bot]
dfabdb403a Bump docker/setup-buildx-action from 3 to 4 (#658)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 00:51:09 +08:00
Thomas Miceli
4da067ab60 Rebuild search index in admin options (#661)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-12 00:50:44 +08:00
dependabot[bot]
a8339ff6bd Bump github-markdown-css from 5.8.1 to 5.9.0 (#651) 2026-03-11 23:07:34 +07:00
dependabot[bot]
7a5cdd1565 Bump @codemirror/lang-javascript from 6.2.4 to 6.2.5 (#650) 2026-03-11 23:07:02 +07:00
dependabot[bot]
00dcb53e3a Bump katex from 0.16.33 to 0.16.38 (#649) 2026-03-11 23:06:40 +07:00
Thomas Miceli
f8b3bbce6a Rebuild search index in admin options (#647)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-09 07:30:28 +08:00
Johannes Kirchner
a697b0f273 fix: port template string and updateStrategy indentation (#643) 2026-03-09 05:50:07 +08:00
Thomas Miceli
33cbfb0904 Bump meili version (#646)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-09 05:40:16 +08:00
dependabot[bot]
dfea4eb435 Bump github.com/meilisearch/meilisearch-go from 0.36.0 to 0.36.1 (#634)
Bumps [github.com/meilisearch/meilisearch-go](https://github.com/meilisearch/meilisearch-go) from 0.36.0 to 0.36.1.
- [Release notes](https://github.com/meilisearch/meilisearch-go/releases)
- [Commits](https://github.com/meilisearch/meilisearch-go/compare/v0.36.0...v0.36.1)

---
updated-dependencies:
- dependency-name: github.com/meilisearch/meilisearch-go
  dependency-version: 0.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 16:25:22 +08:00
Thomas Miceli
d796eeba98 Make gists username/urls case insensitive in URLS (#641)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-03 15:28:49 +08:00
dependabot[bot]
4ab38f24c8 Bump marked from 17.0.1 to 17.0.3 (#635)
Bumps [marked](https://github.com/markedjs/marked) from 17.0.1 to 17.0.3.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v17.0.1...v17.0.3)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 17.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:25:30 +08:00
dependabot[bot]
e1d1b01d40 Bump github.com/go-webauthn/webauthn from 0.15.0 to 0.16.0 (#636)
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.15.0 to 0.16.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.15.0...v0.16.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:25:00 +08:00
dependabot[bot]
3c967729cc Bump github.com/gabriel-vasile/mimetype from 1.4.12 to 1.4.13 (#637)
Bumps [github.com/gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype) from 1.4.12 to 1.4.13.
- [Release notes](https://github.com/gabriel-vasile/mimetype/releases)
- [Commits](https://github.com/gabriel-vasile/mimetype/compare/v1.4.12...v1.4.13)

---
updated-dependencies:
- dependency-name: github.com/gabriel-vasile/mimetype
  dependency-version: 1.4.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:20:40 +08:00
dependabot[bot]
36bc576893 Bump @codemirror/language from 6.12.1 to 6.12.2 (#638)
Bumps [@codemirror/language](https://github.com/codemirror/language) from 6.12.1 to 6.12.2.
- [Changelog](https://github.com/codemirror/language/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/language/compare/6.12.1...6.12.2)

---
updated-dependencies:
- dependency-name: "@codemirror/language"
  dependency-version: 6.12.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:20:28 +08:00
dependabot[bot]
c074d60d1d Bump @tailwindcss/vite from 4.1.18 to 4.2.1 (#640)
Bumps [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) from 4.1.18 to 4.2.1.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.1/packages/@tailwindcss-vite)

---
updated-dependencies:
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:20:16 +08:00
dependabot[bot]
840a852ed2 Bump tailwindcss from 4.1.18 to 4.2.1 (#639)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.1.18 to 4.2.1.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.1/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:20:04 +08:00
dependabot[bot]
34c0b0b3e2 Bump katex from 0.16.28 to 0.16.33 (#633)
Bumps [katex](https://github.com/KaTeX/KaTeX) from 0.16.28 to 0.16.33.
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.28...v0.16.33)

---
updated-dependencies:
- dependency-name: katex
  dependency-version: 0.16.33
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:19:23 +08:00
dependabot[bot]
093a4cb4a8 Bump github.com/labstack/echo/v4 from 4.15.0 to 4.15.1 (#632)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.15.0 to 4.15.1.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.15.0...v4.15.1)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:18:29 +08:00
dependabot[bot]
f037206f41 Bump golang.org/x/text from 0.33.0 to 0.34.0 (#631)
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.33.0 to 0.34.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.33.0...v0.34.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:17:37 +08:00
Thomas Miceli
6c22adba4e Fix async-loaded gist embed scripts (#630)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-03 00:10:28 +08:00
Thomas Miceli
bb63ecd048 Remove windows tests in CI for now (#629) 2026-03-02 16:59:43 +08:00
Thomas Miceli
6a61b720ab Improve test suite (#628)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-02 15:43:24 +08:00
Thomas Miceli
829cd68879 CSRF skipper only for GET *.js request (#627)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-02 15:05:45 +08:00
Thomas Miceli
42490f2995 fix uuid
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-02-26 06:42:00 +07:00
awkj
f83018ebf2 support UTF-8, show no English Text (#625) 2026-02-26 02:13:09 +08:00
Thomas Miceli
b097cfcbc0 Clean file path names on file creation (#624) 2026-02-25 23:30:26 +08:00
Thomas Miceli
7b1048ec30 Display a form to create an Opengist account coming from a OAuth provider (#623) 2026-02-08 16:32:24 +08:00
Joel Godfrey
ce39df1030 Update cheat-sheet.md with missing OIDC group configs (#616)
oidc.group-claim-name and oidc.admin-group are missing from the cheat-sheet
2026-02-05 02:23:59 +08:00
Thomas Miceli
07ba04244b Update CI helm 2026-02-03 16:12:07 +07:00
Thomas Miceli
4d29a50e64 v1.12.1 2026-02-03 15:59:29 +07:00
Thomas Miceli
3a4602d412 Translated using Weblate (Russian) (#605)
Currently translated at 100.0% (341 of 341 strings)

Translation: Opengist/Opengist
Translate-URL: http://tr.opengist.io/projects/_/opengist/ru/

Co-authored-by: FunNikita <mainik1111@icloud.com>
2026-02-03 16:56:04 +08:00
Thomas Miceli
2e10c1732a Add images and binary content on gist preview (#615) 2026-02-03 16:55:44 +08:00
Thomas Miceli
fe04c03acb Improve security on raw files endpoint (#613) 2026-02-03 02:11:39 +08:00
Thomas Miceli
2a1554d063 Fix renderable text files with different mimetypes (#612) 2026-02-03 01:59:24 +08:00
Thomas Miceli
b7dbdde66b Allow Access Tokens with Required Login (#611) 2026-02-02 19:31:07 +08:00
Thomas Miceli
b7278b60ab Update CI 2026-01-31 20:51:40 +07:00
Thomas Miceli
84c6a41340 Update CI 2026-01-29 02:01:27 +07:00
Thomas Miceli
6bd8df6a74 v1.12.0 2026-01-27 22:28:20 +07:00
Thomas Miceli
b48103c06a Translated using Weblate (Russian) (#604)
Currently translated at 58.9% (201 of 341 strings)

Translation: Opengist/Opengist
Translate-URL: http://tr.opengist.io/projects/_/opengist/ru/

Co-authored-by: FunNikita <mainik1111@icloud.com>
2026-01-27 23:27:11 +08:00
Thomas Miceli
48f2c4f5c8 Update Go + JS deps (#603) 2026-01-27 15:02:37 +08:00
Thomas Miceli
5ddea2265d Add access tokens (#602) 2026-01-27 14:43:12 +08:00
Nova Cat
1128a81071 Ignore TCP errors (#601) 2026-01-27 13:49:37 +08:00
Thomas Miceli
145bf9d81a Move Prom metrics to a dedicated port + improve Helm chart (#599) 2026-01-26 17:28:51 +08:00
117 changed files with 6727 additions and 4749 deletions

View File

@@ -1,17 +1,9 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/"
schedule:

View File

@@ -28,20 +28,16 @@ jobs:
npx tailwindcss -i .vitepress/theme/style.css -o .vitepress/theme/theme.css -c .vitepress/tailwind.config.js
npm run docs:build
- name: Deploy to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "docs/.vitepress/dist/*"
target: ${{ secrets.SERVER_PATH }}
- name: Update remote docs
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
${{ secrets.UPDATE_DOCS }}
- name: Push to docs repository
run: |
git clone https://${{ secrets.STATIC_REPO_TOKEN }}@github.com/${{ secrets.STATIC_REPO }}.git target-repo
rm -rf target-repo/srv/opengist
mkdir -p target-repo/srv/opengist
cp -r docs/.vitepress/dist/* target-repo/srv/opengist/
cd target-repo
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Deploy docs from ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit"
git pull --rebase
git push

View File

@@ -83,6 +83,18 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
meilisearch:
image: getmeili/meilisearch:latest
ports:
- 47700:7700
env:
MEILI_NO_ANALYTICS: true
MEILI_ENV: development
options: >-
--health-cmd "curl -sf http://localhost:7700/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -94,13 +106,15 @@ jobs:
- name: Run tests
run: make test TEST_DB_TYPE=${{ matrix.database }}
env:
OG_TEST_MEILI_HOST: http://localhost:47700
test:
name: Test
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
os: ["ubuntu-latest", "macOS-latest"]
go: ["1.25"]
database: ["sqlite"]
runs-on: ${{ matrix.os }}

View File

@@ -34,20 +34,16 @@ jobs:
helm repo index --url https://helm.opengist.io --merge index.yaml .
fi
- name: Deploy to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "./helm/*.tgz,./helm/index.yaml"
target: ${{ secrets.HELM_SERVER_PATH }}
- name: Update remote helm repository
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
${{ secrets.UPDATE_HELM_REPO }}
- name: Push to docs repository
run: |
git clone https://${{ secrets.STATIC_REPO_TOKEN }}@github.com/${{ secrets.STATIC_REPO }}.git target-repo
mkdir -p target-repo/helm
cp helm/*.tgz target-repo/srv/helm/
cp helm/index.yaml target-repo/srv/helm/
cd target-repo
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Deploy helm chart from ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit"
git pull --rebase
git push

View File

@@ -22,7 +22,7 @@ jobs:
run: make all_crosscompile
- name: Upload Release Assets
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: |
build/*.tar.gz
@@ -42,7 +42,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/thomiceli/opengist
@@ -54,26 +54,26 @@ jobs:
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -1,5 +1,64 @@
# Changelog
## [1.12.2](https://github.com/thomiceli/opengist/compare/v1.12.1...v1.12.2) - 2026-03-14
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- Search all fields (#622)
- Display a form to create an Opengist account coming from a OAuth provider (#623)
- Rebuild search index in admin options (#647)
### Fixed
- Clean file path names on file creation (#624)
- Support UTF-8 on gist download (#625)
- CSRF skipper only for GET *.js request (#627)
- Async-loaded gist embed scripts (#630)
- Make gists username/urls case insensitive in URLS (#641)
- Improve code search and index tests (#663)
- Translation strings (#659)
- Gitea avatar URL on OAuth (#674)
### [Helm Chart](helm/opengist)
- Add environment variables and secrets to statefulset (#644)
> Admins of Opengist instances may want to run "Rebuild search index" in the admin panel.
## [1.12.1](https://github.com/thomiceli/opengist/compare/v1.12.0...v1.12.1) - 2026-02-03
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- More translation strings (#605)
### Fixed
- Allow Access Tokens with Required Login (#611)
- Make text files renderable with mimetypes different than text/plain (#612)
- Improve security on raw files endpoint (#613)
> Admins of Opengist instances may want to run "Synchronize all gists previews" in the admin panel.
## [1.12.0](https://github.com/thomiceli/opengist/compare/v1.11.1...v1.12.0) - 2026-01-27
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- Access tokens (#602)
- Fuzzy search for gist search (#555)
- Allow Unicode letters/numbers in topics (#597)
- Resize editor height (#600)
- More translation strings (#516) (#604)
### Fixed
- Don't panic on Go TCP errors (#601)
### Other
- Reduce footprint of Docker image (#515)
- Update Go + JS deps (#603)
- Configure Dependabot for updates on Go and NPM (#449)
### [Helm Chart](helm/opengist)
- Use existing pvc claim of provided (#547)
- Adds StatefulSet support (#549)
- Move Prom metrics to a dedicated port + support ServiceMonitor (#599)
## [1.11.1](https://github.com/thomiceli/opengist/compare/v1.11.0...v1.11.1) - 2025-09-30
See here how to [update](https://opengist.io/docs/update) Opengist.

View File

@@ -8,11 +8,11 @@ RUN apk update && \
musl-dev \
libstdc++
COPY --from=golang:1.25-alpine3.22 /usr/local/go/ /usr/local/go/
COPY --from=golang:1.25.6-alpine3.22 /usr/local/go/ /usr/local/go/
ENV PATH="/usr/local/go/bin:${PATH}"
ENV CGO_ENABLED=0
COPY --from=node:24.9.0-alpine3.22 /usr/local/ /usr/local/
COPY --from=node:24.13.0-alpine3.22 /usr/local/ /usr/local/
ENV NODE_PATH="/usr/local/lib/node_modules"
ENV PATH="/usr/local/bin:${PATH}"
@@ -31,7 +31,7 @@ RUN apk add --no-cache \
gnupg \
xz
EXPOSE 6157 2222 16157
EXPOSE 6157 6158 2222 16157
RUN git config --global --add safe.directory /opengist
RUN make install
@@ -46,7 +46,7 @@ FROM base AS build
RUN make
FROM alpine:3.22 as prod
FROM alpine:3.22 AS prod
RUN apk update && \
apk add --no-cache \
@@ -64,7 +64,7 @@ COPY --from=build --chown=opengist:opengist /opengist/config.yml /config.yml
COPY --from=build --chown=opengist:opengist /opengist/opengist .
COPY --from=build --chown=opengist:opengist /opengist/docker ./docker
EXPOSE 6157 2222
EXPOSE 6157 6158 2222
VOLUME /opengist
HEALTHCHECK --interval=60s --timeout=30s --start-period=15s --retries=3 CMD curl -f http://localhost:6157/healthcheck || exit 1
ENTRYPOINT ["./docker/entrypoint.sh"]

View File

@@ -38,7 +38,7 @@ It is similar to [GitHub Gist](https://gist.github.com/), but open-source and co
Docker [images](https://github.com/thomiceli/opengist/pkgs/container/opengist) are available for each release :
```shell
docker pull ghcr.io/thomiceli/opengist:1.11
docker pull ghcr.io/thomiceli/opengist:1.12
```
It can be used in a `docker-compose.yml` file :
@@ -50,7 +50,7 @@ It can be used in a `docker-compose.yml` file :
```yml
services:
opengist:
image: ghcr.io/thomiceli/opengist:1.11
image: ghcr.io/thomiceli/opengist:1.12
container_name: opengist
restart: unless-stopped
ports:
@@ -77,9 +77,9 @@ Download the archive for your system from the release page [here](https://github
```shell
# example for linux amd64
wget https://github.com/thomiceli/opengist/releases/download/v1.11.1/opengist1.11.1-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.12.2/opengist1.12.2-linux-amd64.tar.gz
tar xzvf opengist1.11.1-linux-amd64.tar.gz
tar xzvf opengist1.12.2-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`

View File

@@ -32,6 +32,10 @@ index.meili.host:
# Set the API key for the Meiliseach server
index.meili.api-key:
# Set the default search fields. Can contain multiple fields (e.g., `content,username`).
# Fields: content,user,title,description,filename,extension,language,topic. Default: content
search.default: content
# Default branch name used by Opengist when initializing Git repositories.
# If not set, uses the Git default branch name. See https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch
git.default-branch:
@@ -55,9 +59,15 @@ http.git-enabled: true
# File permissions for Unix socket (octal format). Default: 0666
unix-socket-permissions: 0666
# Enable or disable the metrics endpoint (either `true` or `false`). Default: false
# Enable or disable the Prometheus metrics server (either `true` or `false`). Default: false
metrics.enabled: false
# The host on which the metrics server should bind. Default: 0.0.0.0
metrics.host: 0.0.0.0
# The port on which the metrics server should listen. Default: 6158
metrics.port: 6158
# SSH built-in server configuration
# Note: it is not using the SSH daemon from your machine (yet)

View File

@@ -55,6 +55,7 @@ export default defineConfig({
text: 'Usage', base: '/docs/usage', items: [
{text: 'Init via Git', link: '/init-via-git'},
{text: 'Embed Gist', link: '/embed'},
{text: 'Access Tokens', link: '/access-tokens'},
{text: 'Gist as JSON', link: '/gist-json'},
{text: 'Import Gists from Github', link: '/import-from-github-gist'},
{text: 'Git push options', link: '/git-push-options'},
@@ -84,7 +85,7 @@ export default defineConfig({
{icon: 'github', link: 'https://github.com/thomiceli/opengist'}
],
editLink: {
pattern: 'https://github.com/thomiceli/opengist/edit/stable/docs/:path'
pattern: 'https://github.com/thomiceli/opengist/edit/v.1.12.2/docs/:path'
},
// @ts-ignore
lastUpdated: true,

View File

@@ -19,7 +19,7 @@ export default {
<div class="mx-auto lg:text-center">
<img class="rotating h-36 mx-auto my-8 " src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg" alt="" >
<a target="_blank" href="https://github.com/thomiceli/opengist/releases" class="inline-flex items-center rounded-full bg-indigo-100 hover:bg-indigo-200 px-4 py-1.5 text-lg font-medium text-indigo-700">
<span class="pr-1">Released 1.11</span>
<span class="pr-1">Released 1.12</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
</svg>

View File

@@ -15,13 +15,16 @@ aside: false
| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
| search.default | OG_SEARCH_DEFAULT | `content` | Set the default search fields. Can contain multiple fields (e.g., `content,username`). Fields: `content,user,title,description,filename,extension,language,topic`. |
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) |
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
| unix-socket-permissions | OG_UNIX_SOCKET_PERMISSIONS | `0666` | File permissions for Unix socket (octal format). |
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics endpoint at `/metrics` (`true` or `false`) |
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics server (`true` or `false`) |
| metrics.host | OG_METRICS_HOST | `0.0.0.0` | The host on which the metrics server should bind. |
| metrics.port | OG_METRICS_PORT | `6158` | The port on which the metrics server should listen. |
| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) |
| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. |
| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. |
@@ -41,6 +44,8 @@ aside: false
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
| oidc.group-claim-name | OG_OIDC_GROUP_CLAIM_NAME | none | Name of the claim containing the groups. |
| oidc.admin-group | OG_OIDC_ADMIN_GROUP | none | Name of the group that should receive admin rights. |
| ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled |
| ldap.bind-dn | OG_LDAP_BIND_DN | none | Bind DN to authenticate against the LDAP. e.g: cn=read-only-admin,dc=example,dc=com |
| ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. |

View File

@@ -27,7 +27,7 @@ Usage via command line :
./opengist --config /path/to/config.yml
```
You can start by copying and/or modifying the provided [config.yml](https://github.com/thomiceli/opengist/blob/stable/config.yml) file.
You can start by copying and/or modifying the provided [config.yml](https://github.com/thomiceli/opengist/blob/master/config.yml) file.
## Configuration via Environment Variables

View File

@@ -4,10 +4,10 @@ Opengist offers built-in support for Prometheus metrics to help you monitor the
## Enabling metrics
By default, the metrics endpoint is disabled for security and performance reasons. To enable it, update your configuration as stated in the [configuration cheat sheet](cheat-sheet.md):
By default, the metrics server is disabled for security and performance reasons. To enable it, update your configuration as stated in the [configuration cheat sheet](cheat-sheet.md):
```yaml
metrics.enabled = true
metrics.enabled: true
```
Alternatively, you can use the environment variable:
@@ -16,7 +16,25 @@ Alternatively, you can use the environment variable:
OG_METRICS_ENABLED=true
```
Once enabled, metrics are available at the /metrics endpoint.
Once enabled, metrics are available on a separate server at `http://0.0.0.0:6158/metrics` by default.
## Configuration
The metrics server runs on a separate port from the main application. By default, it binds to `0.0.0.0` (all interfaces) on port `6158`.
| Config Key | Environment Variable | Default | Description |
|----------------|---------------------|-------------|------------------------------------------------|
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable the metrics server |
| metrics.host | OG_METRICS_HOST | `0.0.0.0` | The host on which the metrics server binds |
| metrics.port | OG_METRICS_PORT | `6158` | The port on which the metrics server listens |
Example configuration:
```yaml
metrics.enabled: true
metrics.host: 0.0.0.0
metrics.port: 6158
```
## Available metrics
@@ -36,14 +54,6 @@ These standard metrics follow the Prometheus naming convention and include label
## Security Considerations
The metrics endpoint exposes information about your Opengist instance that might be sensitive in some environments. Consider using a reverse proxy with authentication for the `/metrics` endpoint if your Opengist instance is publicly accessible.
The metrics server binds to `0.0.0.0` by default, making it accessible on all network interfaces. This default works well for containerized deployments (Docker, Kubernetes) where network isolation is handled at the infrastructure level.
Example with Nginx:
```shell
location /metrics {
auth_basic "Metrics";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://localhost:6157/metrics;
}
```
For bare-metal or VM deployments where the metrics port may be exposed, consider restricting to localhost by setting `metrics.host: 127.0.0.1` to only allow local access.

View File

@@ -25,8 +25,8 @@ Opengist is now running on port 6157, you can browse http://localhost:6157
Requirements:
* [Git](https://git-scm.com/downloads) (2.28+)
* [Go](https://go.dev/doc/install) (1.23+)
* [Node.js](https://nodejs.org/en/download/) (16+)
* [Go](https://go.dev/doc/install) (1.25+)
* [Node.js](https://nodejs.org/en/download/) (20+)
* [Make](https://linux.die.net/man/1/make) (optional, but easier)
```shell

View File

@@ -4,9 +4,9 @@ Download the archive for your system from the release page [here](https://github
```shell
# example for linux amd64
wget https://github.com/thomiceli/opengist/releases/download/v1.11.1/opengist1.11.1-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.12.2/opengist1.12.2-linux-amd64.tar.gz
tar xzvf opengist1.11.1-linux-amd64.tar.gz
tar xzvf opengist1.12.2-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`

View File

@@ -2,15 +2,15 @@
Requirements:
* [Git](https://git-scm.com/downloads) (2.28+)
* [Go](https://go.dev/doc/install) (1.23+)
* [Node.js](https://nodejs.org/en/download/) (16+)
* [Go](https://go.dev/doc/install) (1.25+)
* [Node.js](https://nodejs.org/en/download/) (20+)
* [Make](https://linux.die.net/man/1/make) (optional, but easier)
```shell
git clone https://github.com/thomiceli/opengist
cd opengist
git checkout v1.11.1 # optional, to checkout the latest release
git checkout v1.12.2 # optional, to checkout the latest release
make
./opengist

View File

@@ -27,9 +27,9 @@ Stop the running instance; then like your first installation of Opengist, downlo
```shell
# example for linux amd64
wget https://github.com/thomiceli/opengist/releases/download/v1.11.1/opengist1.11.1-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.12.2/opengist1.12.2-linux-amd64.tar.gz
tar xzvf opengist1.11.1-linux-amd64.tar.gz
tar xzvf opengist1.12.2-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`

View File

@@ -0,0 +1,26 @@
# Access tokens
Access tokens are used to access your private gists and their raw content. For now, it is the only use while a future API is being developed.
## Creating an access token
To create an access token, follow these steps:
1. Go to Settings
2. Select the "Access Tokens" menu
3. Choose a name for your token, the scope and an expiration date (optional), then click "Create Access Token"
## Using an access token
Once you have created an access token, you can use it to access your private gists with it.
Replace `<token>` with your actual access token in the following examples.
```shell
# Access raw content of a private gist, latest revision for "file.txt". Note: this URL is obtained from the "Raw" button on the gist page.
curl -H "Authorization: Token <token>" \
http://opengist.example.com/user/gist/raw/HEAD/file.txt
# Access the JSON representation of a private gist. See "Gist as JSON" documentation for more details.
curl -H "Authorization: Token <token>" \
http://opengist.example.com/user/gist.json
```

113
go.mod
View File

@@ -1,36 +1,36 @@
module github.com/thomiceli/opengist
go 1.25.1
go 1.25.5
require (
github.com/Kunde21/markdownfmt/v3 v3.1.0
github.com/alecthomas/chroma/v2 v2.21.1
github.com/alecthomas/chroma/v2 v2.23.1
github.com/blevesearch/bleve/v2 v2.5.7
github.com/dustin/go-humanize v1.0.1
github.com/gabriel-vasile/mimetype v1.4.12
github.com/gabriel-vasile/mimetype v1.4.13
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.15.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.17.0
github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.4.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/labstack/echo-contrib v0.17.4
github.com/labstack/echo/v4 v4.15.0
github.com/labstack/echo-contrib v0.50.1
github.com/labstack/echo/v4 v4.15.1
github.com/markbates/goth v1.82.0
github.com/meilisearch/meilisearch-go v0.35.1
github.com/meilisearch/meilisearch-go v0.36.2
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/rs/zerolog v1.34.0
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
github.com/yuin/goldmark v1.7.15
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-emoji v1.0.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/mermaid v0.6.0
golang.org/x/crypto v0.46.0
golang.org/x/text v0.32.0
golang.org/x/crypto v0.50.0
golang.org/x/text v0.36.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
@@ -38,88 +38,89 @@ require (
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/RoaringBitmap/roaring/v2 v2.18.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.26 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/blevesearch/bleve_index_api v1.3.11 // indirect
github.com/blevesearch/geo v0.2.5 // indirect
github.com/blevesearch/go-faiss v1.0.35 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
github.com/blevesearch/mmap-go v1.2.0 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.4.7 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.8 // indirect
github.com/blevesearch/vellum v1.2.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.3 // indirect
github.com/blevesearch/zapx/v12 v12.4.3 // indirect
github.com/blevesearch/zapx/v13 v13.4.3 // indirect
github.com/blevesearch/zapx/v14 v14.4.3 // indirect
github.com/blevesearch/zapx/v15 v15.4.3 // indirect
github.com/blevesearch/zapx/v16 v16.3.4 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/gorilla/mux v1.8.1 // 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.7.6 // 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/json-iterator/go v1.1.12 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/leodido/go-urn v1.4.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-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.66.10 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
)

267
go.sum
View File

@@ -1,64 +1,63 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
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=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0=
github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc=
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/RoaringBitmap/roaring/v2 v2.18.0 h1:h7sS0VqCkfBMGgcHaudJFB4FE6Td71H6svRB2poRnGY=
github.com/RoaringBitmap/roaring/v2 v2.18.0/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
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/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
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/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/bleve_index_api v1.3.11 h1:x29vbV8OjWfLcrDVd7Lr1q+BkLNS0JWNEig0MCVnKH4=
github.com/blevesearch/bleve_index_api v1.3.11/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE=
github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4=
github.com/blevesearch/go-faiss v1.0.35 h1:DJF8yKgH8/n+Oo4E047Jy4oDeRivOugfMb6Cih//JjI=
github.com/blevesearch/go-faiss v1.0.35/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
github.com/blevesearch/scorch_segment_api/v2 v2.4.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw=
github.com/blevesearch/scorch_segment_api/v2 v2.4.7/go.mod h1://IJ7tG3QCf0cWW/aVSXqy77tc1AvLu3fcJLYEvOAFs=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY=
github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo=
github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE=
github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM=
github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU=
github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0=
github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -70,9 +69,8 @@ github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCf
github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -81,59 +79,58 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
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-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.0 h1:8tFdaByIF7EgAg0W849Wt5q+213f1drsV2ggC0t80wM=
github.com/go-webauthn/webauthn v0.17.0/go.mod h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -151,14 +148,16 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
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.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
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=
@@ -187,27 +186,24 @@ 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/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/labstack/echo-contrib v0.50.1 h1:W9cZZ9viA4TDdFtm8cuA+XGFwOcnfbjJpl7VgfsRLHE=
github.com/labstack/echo-contrib v0.50.1/go.mod h1:8r/++U/Fw/QniApFnzunLanKaviPfBX7fX7/2QX0qOk=
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meilisearch/meilisearch-go v0.35.1 h1:5H2FeY5eR4HSkaZMJIoefNzOj3XX1+5dd7ZfhAfzeMg=
github.com/meilisearch/meilisearch-go v0.35.1/go.mod h1:cUVJZ2zMqTvvwIMEEAdsWH+zrHsrLpAw6gm8Lt1MXK0=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meilisearch/meilisearch-go v0.36.2 h1:MYaMPCpdLh2aYPt+zK+19mLoA4dfBY3S1L7T0FADCjU=
github.com/meilisearch/meilisearch-go v0.36.2/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -217,9 +213,10 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -228,17 +225,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -246,6 +242,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -259,8 +257,8 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.15 h1:xYJWgq3Qd8qsaZpj5pHKoEI4mosqVZi/qRpq/MdKyyk=
github.com/yuin/goldmark v1.7.15/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
@@ -273,41 +271,34 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/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.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
@@ -316,28 +307,30 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -0,0 +1,34 @@
# Helm Chart Changelog
# 0.7.0 - 2026-03-14
- Bump Opengist image to 1.12.2
- Add environment variables and secrets to statefulset
## 0.6.0 - 2026-02-03
- Bump Opengist image to 1.12.1
## 0.5.0 - 2026-01-27
- Bump Opengist image to 1.12.0
- Add StatefulSet support
- Add Prometheus ServiceMonitor support if Opengist metrics are enabled
- New service for metrics endpoint, dissociated from the main service
- Use existing pvc claim of provided
## 0.4.0 - 2025-09-30
- Bump Opengist image to 1.11.1
## 0.3.0 - 2025-09-21
- Bump Opengist image to 1.11.0
## 0.2.0 - 2025-05-10
- Add `deployment.env[]` in values
## 0.1.0 - 2025-04-06
- Initial release, with Opengist image 1.10.0

View File

@@ -4,6 +4,6 @@ dependencies:
version: 16.7.27
- name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.17.1
digest: sha256:ad702e35f258fed1f804d3e48b071767499f5730e099a8c461610950e5182368
generated: "2025-09-21T04:49:08.679554149+02:00"
version: 0.26.0
digest: sha256:7182bad3df032b3cb21a793ea6b027eaa96e142ff207b607b62df974bc82de90
generated: "2026-03-09T03:39:04.820136+07:00"

View File

@@ -2,8 +2,8 @@ apiVersion: v2
name: opengist
description: Opengist Helm chart for Kubernetes
type: application
version: 0.5.0
appVersion: 1.11.1
version: 0.6.0
appVersion: 1.12.2
home: https://opengist.io
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg
sources:
@@ -15,5 +15,5 @@ dependencies:
condition: postgresql.enabled
- name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.17.1
version: 0.26.0
condition: meilisearch.enabled

View File

@@ -1,11 +1,12 @@
# Opengist Helm Chart
![Version: 0.5.0](https://img.shields.io/badge/Version-0.5.0-informational?style=flat-square) ![AppVersion: 1.11.1](https://img.shields.io/badge/AppVersion-1.11.1-informational?style=flat-square)
![Version: 0.6.0](https://img.shields.io/badge/Version-0.6.0-informational?style=flat-square) ![AppVersion: 1.12.2](https://img.shields.io/badge/AppVersion-1.12.2-informational?style=flat-square)
Opengist Helm chart for Kubernetes.
Opengist Helm chart for Kubernetes. Check [CHANGELOG.md](CHANGELOG.md) for release notes.
* [Install](#install)
* [Configuration](#configuration)
* [Metrics & Monitoring](#metrics--monitoring)
* [Dependencies](#dependencies)
* [Meilisearch Indexer](#meilisearch-indexer)
* [PostgreSQL Database](#postgresql-database)
@@ -47,6 +48,76 @@ If defined, this existing secret will be used instead of creating a new one.
configExistingSecret: <name of the secret>
```
## Metrics & Monitoring
Opengist exposes Prometheus metrics on a separate port (default: `6158`). The metrics server runs independently from the main HTTP server for security.
### Enabling Metrics
To enable metrics, set `metrics.enabled: true` in your Opengist config:
```yaml
config:
metrics.enabled: true
```
This will:
1. Start a metrics server on port 6158 inside the container
2. Create a Kubernetes Service exposing the metrics ports
### Available Metrics
| Metric Name | Type | Description |
|-------------|------|-------------|
| `opengist_users_total` | Gauge | Total number of registered users |
| `opengist_gists_total` | Gauge | Total number of gists |
| `opengist_ssh_keys_total` | Gauge | Total number of SSH keys |
| `opengist_request_duration_seconds_*` | Histogram | HTTP request duration metrics |
### ServiceMonitor for Prometheus Operator
If you're using [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator), you can enable automatic service discovery with a ServiceMonitor:
```yaml
config:
metrics.enabled: true
service:
metrics:
serviceMonitor:
enabled: true
labels:
release: prometheus # match your Prometheus serviceMonitorSelector
```
### Manual Prometheus Configuration
If you're not using Prometheus Operator, you can configure Prometheus to scrape the metrics endpoint directly:
```yaml
scrape_configs:
- job_name: 'opengist'
static_configs:
- targets: ['opengist-metrics:6158']
metrics_path: /metrics
```
Or use Kubernetes service discovery:
```yaml
scrape_configs:
- job_name: 'opengist'
kubernetes_sd_configs:
- role: service
relabel_configs:
- source_labels: [__meta_kubernetes_service_label_app_kubernetes_io_component]
regex: metrics
action: keep
- source_labels: [__meta_kubernetes_service_label_app_kubernetes_io_name]
regex: opengist
action: keep
```
## Dependencies
### Meilisearch Indexer

View File

@@ -63,10 +63,28 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if or .Values.deployment.env .Values.deployment.envFromSecrets }}
env:
{{- if .Values.deployment.env }}
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
{{- range .Values.deployment.envFromSecrets }}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName }}
key: {{ .secretKey }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.http.port }}
protocol: TCP
{{- if index .Values.config "metrics.enabled" }}
- name: metrics
containerPort: {{ .Values.service.metrics.port }}
protocol: TCP
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}

View File

@@ -25,7 +25,7 @@
{{- $user := default "" .Values.postgresql.global.postgresql.auth.username }}
{{- $pass := default "" .Values.postgresql.global.postgresql.auth.password }}
{{- $db := default "" .Values.postgresql.global.postgresql.auth.database }}
{{- $port := default 5432 .Values.postgresql.global.postgresql.service.ports.postgresql }}
{{- $port := int (default 5432 .Values.postgresql.global.postgresql.service.ports.postgresql) }}
{{- if or (eq $user "") (eq $pass "") (eq $db "") }}
{{- fail "postgresql.enabled=true requires username/password/database (postgresql.global.postgresql.auth.*) or set config.db-uri manually" }}
{{- end }}

View File

@@ -0,0 +1,41 @@
{{- if and (index .Values.config "metrics.enabled") .Values.service.metrics.serviceMonitor.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "opengist.fullname" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- with .Values.service.metrics.serviceMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.service.metrics.serviceMonitor.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
endpoints:
- port: metrics
{{- with .Values.service.metrics.serviceMonitor.interval }}
interval: {{ . }}
{{- end }}
{{- with .Values.service.metrics.serviceMonitor.scrapeTimeout }}
scrapeTimeout: {{ . }}
{{- end }}
path: /metrics
{{- with .Values.service.metrics.serviceMonitor.relabelings }}
relabelings:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.service.metrics.serviceMonitor.metricRelabelings }}
metricRelabelings:
{{- toYaml . | nindent 8 }}
{{- end }}
namespaceSelector:
matchNames:
- {{ .Values.namespace | default .Release.Namespace }}
selector:
matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: metrics
{{- end }}

View File

@@ -84,7 +84,7 @@ spec:
serviceName: {{ include "opengist.fullname" . }}-http
podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }}
updateStrategy:
{{- toYaml .Values.statefulSet.updateStrategy | nindent 2 }}
{{- toYaml .Values.statefulSet.updateStrategy | nindent 4 }}
selector:
matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }}
@@ -131,6 +131,19 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if or .Values.deployment.env .Values.deployment.envFromSecrets }}
env:
{{- if .Values.deployment.env }}
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
{{- range .Values.deployment.envFromSecrets }}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName }}
key: {{ .secretKey }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.http.port }}
@@ -140,6 +153,11 @@ spec:
containerPort: {{ .Values.service.ssh.port }}
protocol: TCP
{{- end }}
{{- if index .Values.config "metrics.enabled" }}
- name: metrics
containerPort: {{ .Values.service.metrics.port }}
protocol: TCP
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}

View File

@@ -0,0 +1,32 @@
{{- if index .Values.config "metrics.enabled" }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "opengist.fullname" . }}-metrics
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
app.kubernetes.io/component: metrics
{{- with .Values.service.metrics.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.service.metrics.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.metrics.type }}
{{- if .Values.service.metrics.clusterIP }}
clusterIP: {{ .Values.service.metrics.clusterIP }}
{{- end }}
ports:
- port: {{ .Values.service.metrics.port }}
targetPort: metrics
protocol: TCP
name: metrics
{{- if and (eq .Values.service.metrics.type "NodePort") .Values.service.metrics.nodePort }}
nodePort: {{ .Values.service.metrics.nodePort }}
{{- end }}
selector:
{{- include "opengist.selectorLabels" . | nindent 4 }}
{{- end }}

View File

@@ -8,6 +8,7 @@ namespace: ""
config:
log-level: "warn"
log-output: "stdout"
metrics.enabled: false
## If defined, the existing secret will be used instead of creating a new one.
## The secret must contain a key named `config.yml` with the YAML configuration.
@@ -17,7 +18,7 @@ configExistingSecret: ""
image:
repository: ghcr.io/thomiceli/opengist
pullPolicy: Always
tag: "1.11.1"
tag: "1.12.2"
digest: ""
imagePullSecrets: []
# - name: "image-pull-secret"
@@ -101,6 +102,26 @@ service:
loadBalancerSourceRanges: []
externalTrafficPolicy:
# A metrics K8S service on port 6158 is created when the Opengist config metrics.enabled: true
metrics:
type: ClusterIP
clusterIP:
port: 6158
nodePort:
labels: {}
annotations: {}
# A service monitor can be used to work with your Prometheus setup.
serviceMonitor:
enabled: true
labels: {}
# release: kube-prom-stack
interval:
scrapeTimeout:
annotations: {}
relabelings: []
metricRelabelings: []
## HTTP Ingress for Opengist
## ref: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress:
@@ -237,6 +258,13 @@ autoscaling:
## Additional deployment configuration
deployment:
env: []
## Load environment variables from specific secret keys
## Each entry creates an env.valueFrom.secretKeyRef in the container spec
## ref: https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-environment-variables
envFromSecrets: []
# - name: OG_OIDC_SECRET
# secretName: opengist-oidc-client-secret
# secretKey: client_secret
terminationGracePeriodSeconds: 60
labels: {}
annotations: {}

View File

@@ -150,7 +150,12 @@ func resetHooks() {
}
func indexGists() {
log.Info().Msg("Indexing all Gists...")
log.Info().Msg("Rebuilding index from scratch...")
if err := index.ResetIndex(); err != nil {
log.Error().Err(err).Msg("Cannot reset index")
return
}
gists, err := db.GetAllGistsRows()
if err != nil {
log.Error().Err(err).Msg("Cannot get gists")

View File

@@ -2,16 +2,15 @@ package oauth
import (
gocontext "context"
gojson "encoding/json"
"net/http"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/gitea"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"io"
"net/http"
)
type GiteaProvider struct {
@@ -80,34 +79,11 @@ func (p *GiteaCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
func (p *GiteaCallbackProvider) UpdateUserDB(user *db.User) {
user.GiteaID = p.User.UserID
user.AvatarURL = p.User.AvatarURL
}
resp, err := http.Get(urlJoin(config.C.GiteaUrl, "/api/v1/users/", p.User.UserID))
if err != nil {
log.Error().Err(err).Msg("Cannot get user from Gitea")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("Cannot read Gitea response body")
return
}
var result map[string]interface{}
err = gojson.Unmarshal(body, &result)
if err != nil {
log.Error().Err(err).Msg("Cannot unmarshal Gitea response body")
return
}
field, ok := result["avatar_url"]
if !ok {
log.Error().Msg("Field 'avatar_url' not found in Gitea JSON response")
return
}
user.AvatarURL = field.(string)
func (p *GiteaCallbackProvider) IsAdmin() bool {
return false
}
func NewGiteaCallbackProvider(user *goth.User) CallbackProvider {

View File

@@ -77,6 +77,10 @@ func (p *GitHubCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = "https://avatars.githubusercontent.com/u/" + p.User.UserID + "?v=4"
}
func (p *GitHubCallbackProvider) IsAdmin() bool {
return false
}
func NewGitHubCallbackProvider(user *goth.User) CallbackProvider {
return &GitHubCallbackProvider{
User: user,

View File

@@ -111,6 +111,10 @@ func (p *GitLabCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = field.(string)
}
func (p *GitLabCallbackProvider) IsAdmin() bool {
return false
}
func NewGitLabCallbackProvider(user *goth.User) CallbackProvider {
return &GitLabCallbackProvider{
User: user,

View File

@@ -3,6 +3,8 @@ package oauth
import (
gocontext "context"
"errors"
"slices"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/openidConnect"
@@ -79,6 +81,31 @@ func (p *OIDCCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = p.User.AvatarURL
}
func (p *OIDCCallbackProvider) IsAdmin() bool {
if config.C.OIDCAdminGroup == "" {
return false
}
groupClaimName := config.C.OIDCGroupClaimName
if groupClaimName == "" {
return false
}
groups, ok := p.User.RawData[groupClaimName].([]interface{})
if !ok {
return false
}
var groupNames []string
for _, group := range groups {
if groupName, ok := group.(string); ok {
groupNames = append(groupNames, groupName)
}
}
return slices.Contains(groupNames, config.C.OIDCAdminGroup)
}
func NewOIDCCallbackProvider(user *goth.User) CallbackProvider {
return &OIDCCallbackProvider{
User: user,

View File

@@ -2,15 +2,16 @@ package oauth
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"io"
"net/http"
"net/url"
"strings"
)
const (
@@ -32,6 +33,7 @@ type CallbackProvider interface {
GetProviderUserID(user *db.User) bool
GetProviderUserSSHKeys() ([]string, error)
UpdateUserDB(user *db.User)
IsAdmin() bool
}
func DefineProvider(provider string, url string) (Provider, error) {
@@ -69,6 +71,29 @@ func CompleteUserAuth(ctx *context.Context) (CallbackProvider, error) {
return nil, fmt.Errorf("unsupported provider %s", user.Provider)
}
func NewCallbackProviderFromSession(provider string, userID string, nickname string, email string, avatarURL string) (CallbackProvider, error) {
user := &goth.User{
Provider: provider,
UserID: userID,
NickName: nickname,
Email: email,
AvatarURL: avatarURL,
}
switch provider {
case GitHubProviderString:
return NewGitHubCallbackProvider(user), nil
case GitLabProviderString:
return NewGitLabCallbackProvider(user), nil
case GiteaProviderString:
return NewGiteaCallbackProvider(user), nil
case OpenIDConnectString:
return NewOIDCCallbackProvider(user), nil
}
return nil, fmt.Errorf("unsupported provider %s", provider)
}
func urlJoin(base string, elem ...string) string {
joined, err := url.JoinPath(base, elem...)
if err != nil {

View File

@@ -2,6 +2,12 @@ package cli
import (
"fmt"
"os"
"os/signal"
"path"
"path/filepath"
"syscall"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/webauthn"
"github.com/thomiceli/opengist/internal/config"
@@ -9,13 +15,9 @@ import (
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/index"
"github.com/thomiceli/opengist/internal/ssh"
"github.com/thomiceli/opengist/internal/web/handlers/metrics"
"github.com/thomiceli/opengist/internal/web/server"
"github.com/urfave/cli/v2"
"os"
"os/signal"
"path"
"path/filepath"
"syscall"
)
var CmdVersion = cli.Command{
@@ -36,12 +38,18 @@ var CmdStart = cli.Command{
Initialize(ctx)
server := server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false)
go server.Start()
httpServer := server.NewServer(os.Getenv("OG_DEV") == "1")
go httpServer.Start()
go ssh.Start()
var metricsServer *metrics.Server
if config.C.MetricsEnabled {
metricsServer = metrics.NewServer()
go metricsServer.Start()
}
<-stopCtx.Done()
shutdown(server)
shutdown(httpServer, metricsServer)
return nil
},
}
@@ -131,7 +139,7 @@ func Initialize(ctx *cli.Context) {
}
}
func shutdown(server *server.Server) {
func shutdown(httpServer *server.Server, metricsServer *metrics.Server) {
log.Info().Msg("Shutting down database...")
if err := db.Close(); err != nil {
log.Error().Err(err).Msg("Failed to close database")
@@ -142,7 +150,11 @@ func shutdown(server *server.Server) {
index.Close()
}
server.Stop()
httpServer.Stop()
if metricsServer != nil {
metricsServer.Stop()
}
log.Info().Msg("Shutdown complete")
}

View File

@@ -2,7 +2,6 @@ package config
import (
"fmt"
"github.com/thomiceli/opengist/internal/session"
"io"
"net/url"
"os"
@@ -13,6 +12,8 @@ import (
"strings"
"time"
"github.com/thomiceli/opengist/internal/session"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
@@ -42,6 +43,7 @@ type config struct {
BleveDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` // deprecated
MeiliHost string `yaml:"index.meili.host" env:"OG_MEILI_HOST"`
MeiliAPIKey string `yaml:"index.meili.api-key" env:"OG_MEILI_API_KEY"`
SearchDefault string `yaml:"search.default" env:"OG_SEARCH_DEFAULT"`
GitDefaultBranch string `yaml:"git.default-branch" env:"OG_GIT_DEFAULT_BRANCH"`
@@ -80,6 +82,8 @@ type config struct {
OIDCAdminGroup string `yaml:"oidc.admin-group" env:"OG_OIDC_ADMIN_GROUP"`
MetricsEnabled bool `yaml:"metrics.enabled" env:"OG_METRICS_ENABLED"`
MetricsHost string `yaml:"metrics.host" env:"OG_METRICS_HOST"`
MetricsPort string `yaml:"metrics.port" env:"OG_METRICS_PORT"`
LDAPUrl string `yaml:"ldap.url" env:"OG_LDAP_URL"`
LDAPBindDn string `yaml:"ldap.bind-dn" env:"OG_LDAP_BIND_DN"`
@@ -108,6 +112,7 @@ func configWithDefaults() (*config, error) {
c.OpengistHome = ""
c.DBUri = "opengist.db"
c.Index = "bleve"
c.SearchDefault = "content"
c.SqliteJournalMode = "WAL"
@@ -128,6 +133,8 @@ func configWithDefaults() (*config, error) {
c.GiteaName = "Gitea"
c.MetricsEnabled = false
c.MetricsHost = "0.0.0.0"
c.MetricsPort = "6158"
return c, nil
}

125
internal/db/access_token.go Normal file
View File

@@ -0,0 +1,125 @@
package db
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"time"
)
const (
NoPermission = 0
ReadPermission = 1
ReadWritePermission = 2
)
type AccessToken struct {
ID uint `gorm:"primaryKey"`
Name string
TokenHash string `gorm:"uniqueIndex,size:64"` // SHA-256 hash of the token
CreatedAt int64
ExpiresAt int64 // 0 means no expiration
LastUsedAt int64
UserID uint
User User `validate:"-"`
ScopeGist uint // 0 = none, 1 = read, 2 = read+write
}
// GenerateToken creates a new random token and returns the plain text token.
// The token hash is stored in the AccessToken struct.
// The plain text token should be shown to the user once and never stored.
func (t *AccessToken) GenerateToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
plainToken := "og_" + hex.EncodeToString(bytes)
hash := sha256.Sum256([]byte(plainToken))
t.TokenHash = hex.EncodeToString(hash[:])
return plainToken, nil
}
func GetAccessTokenByID(tokenID uint) (*AccessToken, error) {
token := new(AccessToken)
err := db.
Where("id = ?", tokenID).
First(&token).Error
return token, err
}
func GetAccessTokenByToken(plainToken string) (*AccessToken, error) {
hash := sha256.Sum256([]byte(plainToken))
tokenHash := hex.EncodeToString(hash[:])
token := new(AccessToken)
err := db.
Preload("User").
Where("token_hash = ?", tokenHash).
First(&token).Error
return token, err
}
func GetAccessTokensByUserID(userID uint) ([]*AccessToken, error) {
var tokens []*AccessToken
err := db.
Where("user_id = ?", userID).
Order("created_at desc").
Find(&tokens).Error
return tokens, err
}
func (t *AccessToken) Create() error {
t.CreatedAt = time.Now().Unix()
return db.Create(t).Error
}
func (t *AccessToken) Delete() error {
return db.Delete(t).Error
}
func (t *AccessToken) UpdateLastUsed() error {
return db.Model(t).Update("last_used_at", time.Now().Unix()).Error
}
func (t *AccessToken) IsExpired() bool {
if t.ExpiresAt == 0 {
return false
}
return time.Now().Unix() > t.ExpiresAt
}
func (t *AccessToken) HasGistReadPermission() bool {
return t.ScopeGist >= ReadPermission
}
func (t *AccessToken) HasGistWritePermission() bool {
return t.ScopeGist >= ReadWritePermission
}
// -- DTO -- //
type AccessTokenDTO struct {
Name string `form:"name" validate:"required,max=255"`
ScopeGist uint `form:"scope_gist" validate:"min=0,max=2"`
ExpiresAt string `form:"expires_at"` // empty means no expiration, otherwise date format (YYYY-MM-DD)
}
func (dto *AccessTokenDTO) ToAccessToken() *AccessToken {
var expiresAt int64
if dto.ExpiresAt != "" {
// date input format: 2006-01-02, expires at end of day
if t, err := time.ParseInLocation("2006-01-02", dto.ExpiresAt, time.Local); err == nil {
expiresAt = t.Add(24*time.Hour - time.Second).Unix()
}
}
return &AccessToken{
Name: dto.Name,
ScopeGist: dto.ScopeGist,
ExpiresAt: expiresAt,
}
}

View File

@@ -155,7 +155,7 @@ func Setup(dbUri string) error {
return err
}
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}); err != nil {
return err
}
@@ -269,5 +269,5 @@ func DeprecationDBFilename() {
}
func TruncateDatabase() error {
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{})
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{})
}

View File

@@ -71,8 +71,10 @@ type Gist struct {
Uuid string
Title string
URL string
URLNormalized string
Preview string
PreviewFilename string
PreviewMimeType string
Description string
Private Visibility // 0: public, 1: unlisted, 2: private
UserID uint
@@ -97,6 +99,11 @@ type Like struct {
CreatedAt int64
}
func (gist *Gist) BeforeSave(_ *gorm.DB) error {
gist.URLNormalized = strings.ToLower(gist.URL)
return nil
}
func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
// Decrement fork counter if the gist was forked
err := tx.Model(&Gist{}).
@@ -109,7 +116,8 @@ func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
func GetGist(user string, gistUuid string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("(gists.uuid like ? OR gists.url = ?) AND users.username like ?", gistUuid+"%", gistUuid, user).
Where("(gists.uuid LIKE ? OR gists.url_normalized = ?) AND users.username_normalized = ?",
strings.ToLower(gistUuid)+"%", strings.ToLower(gistUuid), strings.ToLower(user)).
Joins("join users on gists.user_id = users.id").
First(&gist).Error
@@ -551,6 +559,7 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
if len(filesStr) == 0 {
gist.Preview = ""
gist.PreviewFilename = ""
gist.PreviewMimeType = ""
} else {
for _, fileStr := range filesStr {
file, err := gist.File("HEAD", fileStr, true)
@@ -562,6 +571,7 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
}
gist.Preview = ""
gist.PreviewFilename = file.Filename
gist.PreviewMimeType = file.MimeType.ContentType
if !file.MimeType.CanBeEdited() {
continue
@@ -724,6 +734,10 @@ type GistDTO struct {
Name []string `form:"name"`
Content []string `form:"content"`
Topics string `validate:"gisttopics" form:"topics"`
UploadedFilesUUID []string `validate:"omitempty,dive,required,uuid" form:"uploadedfile_uuid"`
UploadedFilesNames []string `validate:"omitempty,dive,required" form:"uploadedfile_filename"`
BinaryFileOldName []string `form:"binary_old_name"`
BinaryFileNewName []string `form:"binary_new_name"`
VisibilityDTO
}
@@ -807,6 +821,7 @@ func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
UserID: gist.UserID,
Visibility: gist.Private.Uint(),
Username: gist.User.Username,
Description: gist.Description,
Title: gist.Title,
Content: wholeContent,
Filenames: fileNames,

View File

@@ -2,7 +2,9 @@ package db
import (
"fmt"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type MigrationVersion struct {
@@ -12,40 +14,54 @@ type MigrationVersion struct {
func applyMigrations(dbInfo *databaseInfo) error {
switch dbInfo.Type {
case SQLite:
return applySqliteMigrations()
case PostgreSQL, MySQL:
return nil
case SQLite, PostgreSQL, MySQL:
return applyAllMigrations(dbInfo.Type)
default:
return fmt.Errorf("unknown database type: %s", dbInfo.Type)
}
}
func applySqliteMigrations() error {
// Create migration table if it doesn't exist
func applyAllMigrations(dbType databaseType) error {
if err := db.AutoMigrate(&MigrationVersion{}); err != nil {
log.Fatal().Err(err).Msg("Error creating migration version table")
return err
}
// Get the current migration version
var currentVersion MigrationVersion
db.First(&currentVersion)
// Define migrations
migrations := []struct {
Version uint
DBTypes []databaseType // nil = all types
Func func() error
}{
{1, v1_modifyConstraintToSSHKeys},
{2, v2_lowercaseEmails},
// Add more migrations here as needed
{1, []databaseType{SQLite}, v1_modifyConstraintToSSHKeys},
{2, []databaseType{SQLite}, v2_lowercaseEmails},
{3, nil, v3_normalizedColumns},
}
// Apply migrations
for _, m := range migrations {
if m.Version > currentVersion.Version {
if m.Version <= currentVersion.Version {
continue
}
// Skip migrations not intended for this DB type
if len(m.DBTypes) > 0 {
applicable := false
for _, t := range m.DBTypes {
if t == dbType {
applicable = true
break
}
}
if !applicable {
// Advance version so we don't retry on next startup
currentVersion.Version = m.Version
db.Save(&currentVersion)
continue
}
}
tx := db.Begin()
if err := tx.Error; err != nil {
log.Fatal().Err(err).Msg("Error starting transaction")
@@ -53,20 +69,20 @@ func applySqliteMigrations() error {
}
if err := m.Func(); err != nil {
log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version))
tx.Rollback()
log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version))
return err
} else {
if err = tx.Commit().Error; err != nil {
}
if err := tx.Commit().Error; err != nil {
log.Fatal().Err(err).Msg(fmt.Sprintf("Error committing migration %d:", m.Version))
return err
}
currentVersion.Version = m.Version
db.Save(&currentVersion)
log.Info().Msg(fmt.Sprintf("Migration %d applied successfully", m.Version))
}
}
}
return nil
}
@@ -112,3 +128,12 @@ func v2_lowercaseEmails() error {
copySQL := `UPDATE users SET email = lower(email);`
return db.Exec(copySQL).Error
}
func v3_normalizedColumns() error {
if err := db.Model(&User{}).Where("username_normalized = '' OR username_normalized IS NULL").
Updates(map[string]interface{}{"username_normalized": gorm.Expr("LOWER(username)")}).Error; err != nil {
return err
}
return db.Model(&Gist{}).Where("url_normalized = '' OR url_normalized IS NULL").
Updates(map[string]interface{}{"url_normalized": gorm.Expr("LOWER(url)")}).Error
}

View File

@@ -2,6 +2,8 @@ package db
import (
"encoding/json"
"strings"
"github.com/thomiceli/opengist/internal/git"
"gorm.io/gorm"
)
@@ -9,6 +11,7 @@ import (
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"`
UsernameNormalized string `gorm:"index"`
Password string
IsAdmin bool
CreatedAt int64
@@ -25,6 +28,12 @@ type User struct {
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
AccessTokens []AccessToken `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
}
func (user *User) BeforeSave(_ *gorm.DB) error {
user.UsernameNormalized = strings.ToLower(user.Username)
return nil
}
func (user *User) BeforeDelete(tx *gorm.DB) error {
@@ -72,6 +81,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
return err
}
err = tx.Where("user_id = ?", user.ID).Delete(&AccessToken{}).Error
if err != nil {
return err
}
err = tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
if err != nil {
return err
@@ -87,7 +101,7 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
func UserExists(username string) (bool, error) {
var count int64
err := db.Model(&User{}).Where("username like ?", username).Count(&count).Error
err := db.Model(&User{}).Where("username_normalized = ?", strings.ToLower(username)).Count(&count).Error
return count > 0, err
}
@@ -105,7 +119,7 @@ func GetAllUsers(offset int) ([]*User, error) {
func GetUserByUsername(username string) (*User, error) {
user := new(User)
err := db.
Where("username like ?", username).
Where("username_normalized = ?", strings.ToLower(username)).
First(&user).Error
return user, err
}
@@ -252,6 +266,11 @@ type UserDTO struct {
Password string `form:"password" validate:"required"`
}
type OAuthRegisterDTO struct {
Username string `form:"username" validate:"required,max=24,alphanumdash,notreserved"`
Email string `form:"email" validate:"omitempty,email"`
}
func (dto *UserDTO) ToUser() *User {
return &User{
Username: dto.Username,

19
internal/git/file.go Normal file
View File

@@ -0,0 +1,19 @@
package git
import (
"path/filepath"
"strings"
)
func CleanTreePathName(s string) string {
name := filepath.Base(s)
if name == "." || name == ".." {
return ""
}
name = strings.ReplaceAll(name, "/", "")
name = strings.ReplaceAll(name, "\\", "")
return name
}

View File

@@ -2,6 +2,7 @@ package git
import (
"fmt"
"net/http"
"strings"
"github.com/gabriel-vasile/mimetype"
@@ -10,35 +11,36 @@ import (
type MimeType struct {
ContentType string
extension string
golangContentType string // json, m3u, etc. still renderable as text
}
func (mt MimeType) IsText() bool {
return strings.Contains(mt.ContentType, "text/")
return strings.HasPrefix(mt.ContentType, "text/") || strings.HasPrefix(mt.golangContentType, "text/")
}
func (mt MimeType) IsCSV() bool {
return strings.Contains(mt.ContentType, "text/csv") &&
return strings.HasPrefix(mt.ContentType, "text/csv") &&
(strings.HasSuffix(mt.extension, ".csv"))
}
func (mt MimeType) IsImage() bool {
return strings.Contains(mt.ContentType, "image/")
return strings.HasPrefix(mt.ContentType, "image/")
}
func (mt MimeType) IsSVG() bool {
return strings.Contains(mt.ContentType, "image/svg+xml")
return strings.HasPrefix(mt.ContentType, "image/svg+xml")
}
func (mt MimeType) IsPDF() bool {
return strings.Contains(mt.ContentType, "application/pdf")
return strings.HasPrefix(mt.ContentType, "application/pdf")
}
func (mt MimeType) IsAudio() bool {
return strings.Contains(mt.ContentType, "audio/")
return strings.HasPrefix(mt.ContentType, "audio/")
}
func (mt MimeType) IsVideo() bool {
return strings.Contains(mt.ContentType, "video/")
return strings.HasPrefix(mt.ContentType, "video/")
}
func (mt MimeType) CanBeHighlighted() bool {
@@ -87,5 +89,5 @@ func (mt MimeType) RenderType() string {
}
func DetectMimeType(data []byte, extension string) MimeType {
return MimeType{mimetype.Detect(data).String(), extension}
return MimeType{mimetype.Detect(data).String(), extension, http.DetectContentType(data)}
}

View File

@@ -192,7 +192,7 @@ admin.actions.sync-db: 'Gists von der Datenbank synchronisieren'
admin.actions.git-gc: '„garbage collection“ bei allen git Repositories ausführen'
admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren'
admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories synchronisieren'
admin.actions.index-gists: 'Alle Gists Indexieren'
admin.actions.index-gists: 'Suchindex neu aufbauen'
admin.id: 'ID'
admin.user: 'Benutzer'
admin.delete: 'Löschen'
@@ -236,7 +236,7 @@ flash.admin.sync-db: 'Synchronisiere Repositories aus der Datenbank...'
flash.admin.git-gc: 'Sammle Repositories...'
flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...'
flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...'
flash.admin.index-gists: 'Indiziere alle Gists...'
flash.admin.index-gists: 'Suchindex wird neu aufgebaut...'
flash.auth.username-exists: 'Benutzername existiert bereits'
flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen'

View File

@@ -88,10 +88,12 @@ gist.search.found: gists found
gist.search.no-results: No gists found
gist.search.help.user: gists created by user
gist.search.help.title: gists with given title
gist.search.help.description: gists with given description
gist.search.help.filename: gists having files with given name
gist.search.help.extension: gists having files with given extension
gist.search.help.language: gists having files with given language
gist.search.help.topic: gists with given topic
gist.search.help.all: search all fields
gist.search.placeholder.title: Title
gist.search.placeholder.visibility: Visibility
gist.search.placeholder.public: Public
@@ -157,6 +159,7 @@ settings.password-label-title: Password
settings.header.account: Account
settings.header.mfa: MFA
settings.header.ssh: SSH
settings.header.tokens: Access tokens
settings.header.style: Style
settings.style.gist-code: Gist code
settings.style.no-soft-wrap: No Soft Wrap
@@ -169,6 +172,26 @@ settings.style.theme: Theme
settings.style.theme-light: Light
settings.style.theme-dark: Dark
settings.style.theme-auto: Auto
settings.create-token: Create access token
settings.create-token-help: Access tokens can be used to access the API
settings.token-name: Name
settings.token-permissions: Permissions
settings.token-gist-permission: Gists
settings.token-permission-none: No access
settings.token-permission-read: Read
settings.token-permission-read-write: Read & Write
settings.delete-token: Delete
settings.delete-token-confirm: Confirm deletion of access token
settings.token-created-at: Created
settings.token-never-used: Never used
settings.token-last-used: Last used
settings.token-expiration: Expiration
settings.token-expiration-help: Leave empty for no expiration
settings.token-expires-at: Expires
settings.token-no-expiration: No expiration
settings.token-expired: expired
settings.token-created: Token created, make sure to copy it now, you won't be able to see it again!
settings.token-deleted: Access token deleted
auth.signup-disabled: Administrator has disabled signing up
auth.login: Login
@@ -179,6 +202,13 @@ auth.password: Password
auth.register-instead: Register instead
auth.login-instead: Login instead
auth.oauth: Continue with %s account
auth.oauth.no-provider: OAuth provider not found
auth.oauth.complete-registration: Complete your registration
auth.oauth.complete-registration-button: Create account
auth.oauth.signing-in-with: Signing in with %s
auth.oauth.cancel: Cancel
auth.oauth.existing-account: Existing account?
auth.oauth.already-have-account: If you already have an Opengist account, login first and link your %s account from your settings.
auth.mfa: Multi-factor authentication
auth.mfa.passkey: Passkey
auth.mfa.passkeys: Passkeys
@@ -220,7 +250,7 @@ error.signup-disabled: Signing up is disabled
error.signup-disabled-form: Signing up via registration form is disabled
error.login-disabled-form: Logging in via login form is disabled
error.complete-oauth-login: "Cannot complete user auth: %s"
error.oauth-unsupported: Unsupported provider
error.oauth-unsupported: Unsupported OAuth2 provider
error.cannot-bind-data: Cannot bind data
error.invalid-number: Invalid number
error.invalid-character-unescaped: Invalid character unescaped
@@ -264,7 +294,7 @@ admin.actions.sync-db: Synchronize gists from database
admin.actions.git-gc: Garbage collect all git repositories
admin.actions.sync-previews: Synchronize all gists previews
admin.actions.reset-hooks: Reset Git server hooks for all repositories
admin.actions.index-gists: Index all gists
admin.actions.index-gists: Rebuild search index
admin.actions.sync-gist-languages: Synchronize all gists languages
admin.id: ID
admin.user: User
@@ -310,7 +340,7 @@ flash.admin.sync-db: Syncing repositories from database...
flash.admin.git-gc: Garbage collecting repositories...
flash.admin.sync-previews: Syncing Gist previews...
flash.admin.reset-hooks: Resetting Git server hooks for all repositories...
flash.admin.index-gists: Indexing all gists...
flash.admin.index-gists: Rebuilding search index...
flash.admin.sync-gist-languages: Syncing Gist languages...
flash.auth.username-exists: Username already exists
@@ -322,6 +352,8 @@ flash.auth.user-sshkeys-not-created: Could not create ssh key
flash.auth.must-be-logged-in: You must be logged in to access gists
flash.auth.passkey-registred: Passkey %s registered
flash.auth.passkey-deleted: Passkey deleted
flash.auth.oauth-session-expired: OAuth2 session expired, please try again
flash.auth.oauth-already-linked: This %s account is already linked to another user
flash.gist.visibility-changed: Gist visibility has been changed
flash.gist.deleted: Gist has been deleted

View File

@@ -213,7 +213,7 @@ admin.invitations: 'Invitaciones'
admin.invitations.create: 'Crear invitación'
admin.actions.sync-previews: 'Sincronizar todas las vistas previas de gists'
admin.actions.reset-hooks: 'Resetear los hooks de Git en todos los repositorios'
admin.actions.index-gists: 'Indexar todos los gists'
admin.actions.index-gists: 'Reconstruir índice de búsqueda'
admin.config-link-overriden: 'sobrescrito'
admin.invitations.help: 'Las invitaciones se pueden usar para crear una cuenta aunque el registro esté deshabilitado.'
admin.invitations.max_uses: 'Cantidad máxima de usos'
@@ -231,7 +231,7 @@ flash.admin.sync-db: 'Sincronizando repositorios desde la base de datos...'
flash.admin.git-gc: 'Recolectando basura en los repositorios...'
flash.admin.sync-previews: 'Sincronizando vistas previas de gists...'
flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...'
flash.admin.index-gists: 'Indexando todos los gists...'
flash.admin.index-gists: 'Reconstruyendo índice de búsqueda...'
flash.auth.username-exists: 'El nombre de usuario ya existe'
flash.auth.invalid-credentials: 'Credenciales incorrectas'
flash.auth.account-linked-oauth: 'Cuenta vinculada a %s'

View File

@@ -193,7 +193,7 @@ admin.actions.reset-hooks: Réinitialiser les hooks de Git pour tous les dépôt
gist.new.url: URL
gist.search.no-results: Aucun gist trouvé
settings.unlink-gitlab-account: Détacher le compte GitLab
admin.actions.index-gists: Indexer tous les gists
admin.actions.index-gists: Reconstruire l'index de recherche
gist.new.preview: 'Aperçu'
gist.new.create-a-new-gist: 'Créer un nouveau gist'
gist.edit.edit-gist: 'Modifier %s'
@@ -231,7 +231,7 @@ flash.admin.sync-db: 'Synchronisation des dépôts à partir de la base de donn
flash.admin.git-gc: 'Nettoyage des dépôts...'
flash.admin.sync-previews: 'Synchronisation des aperçus du Gist...'
flash.admin.reset-hooks: 'Réinitialisation des hooks du serveur Git pour tous les dépôts...'
flash.admin.index-gists: 'Indexation de tous les gists...'
flash.admin.index-gists: 'Reconstruction de l''index de recherche...'
flash.auth.username-exists: 'Nom d''utilisateur déjà utilisé'
flash.auth.invalid-credentials: 'Identifiants non valides'
flash.auth.account-linked-oauth: 'Compte lié à %s'

View File

@@ -170,7 +170,7 @@ admin.actions.sync-db: Gistek szinkronizálása az adatbázissal
admin.actions.git-gc: Használatlan git repository-k eltávolítása
admin.actions.sync-previews: Gist előnézetek szinkronizálása
admin.actions.reset-hooks: Git server hook-ok alaphelyzetbe állítása minden repository-nál
admin.actions.index-gists: Gistek indexelése
admin.actions.index-gists: Keresési index újraépítése
admin.id: Azonosító
admin.user: Felhasználó
admin.delete: Törlés

View File

@@ -191,7 +191,7 @@ admin.actions.sync-db: 'Sincronizza gists dal database'
admin.actions.git-gc: 'Esegui la garbage collection da tutti i repositories'
admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists'
admin.actions.reset-hooks: 'Resetta tutti gli hook del server Git per tutti i repositories'
admin.actions.index-gists: 'Indicizza tutti i gists'
admin.actions.index-gists: 'Ricostruisci indice di ricerca'
admin.id: 'ID'
admin.user: 'Utente'
admin.delete: 'Elimina'
@@ -235,7 +235,7 @@ flash.admin.sync-db: 'Sincronizzando i repositories dal database...'
flash.admin.git-gc: 'Eseguendo il garbage collector dei repositories...'
flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...'
flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...'
flash.admin.index-gists: 'Indicizzando tutti i gists...'
flash.admin.index-gists: 'Ricostruzione indice di ricerca...'
flash.auth.username-exists: 'Il nome utente esiste già'
flash.auth.invalid-credentials: 'Credenziali errate'

View File

@@ -227,7 +227,7 @@ admin.actions.sync-db: 'Synchronizuj Gisty z bazy danych'
admin.actions.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git'
admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów'
admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytoriów'
admin.actions.index-gists: 'Indeksuj wszystkie Gisty'
admin.actions.index-gists: 'Przebuduj indeks wyszukiwania'
admin.id: 'ID'
admin.user: 'Użytkownik'
admin.delete: 'Usuń'
@@ -271,7 +271,7 @@ flash.admin.sync-db: 'Synchronizowanie repozytoriów z bazy danych...'
flash.admin.git-gc: 'Zbieranie śmieci w repozytoriach...'
flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...'
flash.admin.reset-hooks: 'Resetowanie hooków serwera Git dla wszystkich repozytoriów...'
flash.admin.index-gists: 'Indeksowanie wszystkich Gistów...'
flash.admin.index-gists: 'Przebudowywanie indeksu wyszukiwania...'
flash.auth.username-exists: 'Nazwa użytkownika już istnieje'
flash.auth.invalid-credentials: 'Niepoprawne dane logowania'

View File

@@ -159,19 +159,19 @@ admin.created_at: Создан
admin.config-link: Эти настройки могут быть %s файлом конфигурации YAML и/или переменными окружения.
admin.config-link-overriden: перекрыты
admin.disable-signup: Запретить регистрацию
admin.disable-signup_help: Запретить создание новых доступов
admin.disable-signup_help: Запретить создание новых доступов.
admin.require-login: Требовать авторизацию
admin.require-login_help: Запретить просмотр фрагментов без авторизации.
admin.disable-login: Запретить авторизацию по паролю
admin.disable-login_help: Запретить авторизацию с вводом пароля, форсировать внешнюю авторизацию через Gitea/GitHub.
admin.disable-gravatar: Запретить Gravatar
admin.disable-gravatar_help: Запретить использование Gravatar как провайдера изображений профиля.
admin.allow-gists-without-login:
admin.allow-gists-without-login_help:
admin.allow-gists-without-login: Разрешить доступ к отдельным фрагментам без авторизации
admin.allow-gists-without-login_help: Разрешает просматривать и скачивать отдельные фрагменты без входа, но требует авторизацию для поиска фрагментов.
admin.users.delete_confirm: Вы уверены что хотите удалить этого пользователя?
admin.gists.title: Название
admin.gists.private: Приватный
admin.gists.private: Приватный?
admin.gists.nb-files: Файлов
admin.gists.nb-likes: Понравилось
admin.gists.delete_confirm: Вы уверены что хотите удалить этот фрагмент?
@@ -183,77 +183,175 @@ gist.list.all-liked-by: 'Все фрагменты, понравившиеся %
gist.list.all-forked-by: 'Все фрагменты, ответвлённые %s'
gist.list.all-from: 'Все фрагменты от %s'
gist.search.found: 'фрагментов найдено'
gist.search.no-results: 'Не найден ни один фрагмент'
gist.search.no-results: 'Фрагменты не найдены'
gist.search.help.user: 'фрагментов создано пользователем'
gist.search.help.title: ''
gist.search.help.filename: ''
gist.search.help.extension: ''
gist.search.help.language: ''
gist.forks.for: ''
gist.likes.for: ''
gist.revision-of: ''
settings.link-gitlab-account: ''
settings.unlink-gitlab-account: ''
settings.change-username: ''
settings.create-password: ''
settings.create-password-help: ''
settings.change-password: ''
settings.change-password-help: ''
settings.password-label-title: ''
error.page-not-found: ''
error.bad-request: ''
error.signup-disabled: ''
error.signup-disabled-form: ''
error.login-disabled-form: ''
error.complete-oauth-login: ''
error.oauth-unsupported: ''
error.cannot-bind-data: ''
error.invalid-number: ''
error.invalid-character-unescaped: ''
admin.invitations: ''
admin.invitations.create: ''
admin.actions.sync-previews: ''
admin.actions.reset-hooks: ''
admin.actions.index-gists: ''
validation.should-not-be-empty: ''
admin.invitations.help: ''
admin.invitations.max_uses: ''
admin.invitations.expires_at: ''
admin.invitations.code: ''
admin.invitations.copy_link: ''
admin.invitations.uses: ''
admin.invitations.expired: ''
flash.admin.user-deleted: ''
flash.admin.gist-deleted: ''
flash.admin.invitation-created: ''
flash.admin.invitation-deleted: ''
flash.admin.sync-fs: ''
flash.admin.sync-db: ''
flash.admin.git-gc: ''
flash.admin.sync-previews: ''
flash.admin.reset-hooks: ''
flash.admin.index-gists: ''
flash.auth.username-exists: ''
flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: ''
flash.auth.account-unlinked-oauth: ''
flash.auth.user-sshkeys-not-retrievable: ''
flash.auth.user-sshkeys-not-created: ''
flash.auth.must-be-logged-in: ''
flash.gist.visibility-changed: ''
flash.gist.deleted: ''
flash.gist.fork-own-gist: ''
flash.gist.forked: ''
flash.user.email-updated: ''
flash.user.invalid-ssh-key: ''
flash.user.ssh-key-added: ''
flash.user.ssh-key-deleted: ''
flash.user.password-updated: ''
flash.user.username-updated: ''
validation.is-too-long: ''
validation.should-not-include-sub-directory: ''
validation.should-only-contain-alphanumeric-characters: ''
validation.should-only-contain-alphanumeric-characters-and-dashes: ''
validation.not-enough: ''
validation.invalid: ''
html.title.admin-panel: ''
gist.search.help.title: 'фрагментов с указанным заголовком'
gist.search.help.filename: 'фрагменты содержащие файлы с указанным именем'
gist.search.help.extension: 'фрагменты, содержащие файлы с указанным расширением'
gist.search.help.language: 'фрагменты, содержащие файлы с указанным языком'
gist.forks.for: 'Форки фрагмента %s'
gist.likes.for: 'Лайки фрагмента %s'
gist.revision-of: 'Ревизия фрагмента %s'
settings.link-gitlab-account: 'Привязать учётную запись Gitlab'
settings.unlink-gitlab-account: 'Отвязать учётную запись GitHub'
settings.change-username: 'Сменить имя пользователя'
settings.create-password: 'Создать пароль'
settings.create-password-help: 'Создайте пароль для входа в Opengist по HTTP'
settings.change-password: 'Сменить пароль'
settings.change-password-help: 'Смените пароль для входа в Opengist по HTTP'
settings.password-label-title: 'Пароль'
error.page-not-found: 'Страница не найдена'
error.bad-request: 'Неверный запрос'
error.signup-disabled: 'Регистрация недоступна'
error.signup-disabled-form: 'Регистрация через форму недоступна'
error.login-disabled-form: 'Авторизация через форму недоступна'
error.complete-oauth-login: 'Не удалось завершить авторизацию пользователя: %s'
error.oauth-unsupported: 'Провайдер OAuth не поддерживается'
error.cannot-bind-data: 'Не удалось обработать данные'
error.invalid-number: 'Некорректное числовое значение'
error.invalid-character-unescaped: 'Обнаружен неверный неэкранированный символ'
admin.invitations: 'Инвайты'
admin.invitations.create: 'Создать инвайт'
admin.actions.sync-previews: 'Обновить предпросмотры всех фрагментов'
admin.actions.reset-hooks: 'Сбросить хуки Git-сервера для всех репозиториев'
admin.actions.index-gists: 'Перестроить поисковый индекс'
validation.should-not-be-empty: 'Поле %s не должно быть пустым'
admin.invitations.help: 'Инвайты используются для создания аккаунта, даже когда регистрация запрещена.'
admin.invitations.max_uses: 'Максимальное количество использований'
admin.invitations.expires_at: 'Истекает'
admin.invitations.code: 'Код'
admin.invitations.copy_link: 'Скопировать ссылку'
admin.invitations.uses: 'Количество использований'
admin.invitations.expired: 'Истёк'
flash.admin.user-deleted: 'Пользователь удалён'
flash.admin.gist-deleted: 'Фрагмент удалён'
flash.admin.invitation-created: 'Приглашение создано'
flash.admin.invitation-deleted: 'Приглашение удалено'
flash.admin.sync-fs: 'Выполняется синхронизация репозиториев с файловой системой…'
flash.admin.sync-db: 'Выполняется синхронизация репозиториев с базой данных…'
flash.admin.git-gc: 'Сборка мусора в репозиториях…'
flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…'
flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…'
flash.admin.index-gists: 'Перестроение поискового индекса…'
flash.auth.username-exists: 'Такое имя пользователя уже занято'
flash.auth.invalid-credentials: 'Некорректные данные для входа'
flash.auth.account-linked-oauth: 'Учётная запись связана с %s'
flash.auth.account-unlinked-oauth: 'Учётная запись отключена от %s'
flash.auth.user-sshkeys-not-retrievable: 'Не удалось получить SSH-ключи пользователя'
flash.auth.user-sshkeys-not-created: 'Не удалось создать SSH-ключ'
flash.auth.must-be-logged-in: 'Для доступа к фрагментам необходимо войти в аккаунт'
flash.gist.visibility-changed: 'Видимость фрагмента изменена'
flash.gist.deleted: 'Фрагмент удалён'
flash.gist.fork-own-gist: 'Нельзя создать форк собственного фрагмента'
flash.gist.forked: 'Фрагмент создан как форк'
flash.user.email-updated: 'Адрес электронной почты обновлён'
flash.user.invalid-ssh-key: 'Неверный SSH-ключ'
flash.user.ssh-key-added: 'SSH-ключ добавлен'
flash.user.ssh-key-deleted: 'SSH-ключ удалён'
flash.user.password-updated: 'Пароль обновлён'
flash.user.username-updated: 'Имя пользователя обновлено'
validation.is-too-long: 'Поле %s слишком длинное'
validation.should-not-include-sub-directory: 'Поле %s не должно содержать подкаталоги'
validation.should-only-contain-alphanumeric-characters: 'Поле %s должно содержать только буквы и цифры'
validation.should-only-contain-alphanumeric-characters-and-dashes: 'Поле %s должно содержать только буквы, цифры и дефисы'
validation.not-enough: 'Недостаточно %s'
validation.invalid: 'Неверный %s'
html.title.admin-panel: 'Панель администратора'
settings.ssh-key-exists: SSH-ключ уже существует
gist.file-binary-edit: Этот файл является бинарным.
gist.preview-non-available: Предпросмотр недоступен
gist.file-raw: Не удалось отобразить файл.
gist.new.topics: Темы (через пробел)
gist.list.topic-results-topic: Все фрагменты с темой %s
gist.search.help.topic: фрагменты с заданной темой
gist.search.placeholder.title: Заголовок
gist.search.placeholder.visibility: Видимость
gist.search.placeholder.public: Публичный
gist.search.placeholder.unlisted: Скрытый
gist.search.placeholder.private: Приватный
gist.search.placeholder.language: Язык
gist.search.placeholder.all: Все
gist.search.placeholder.topics: Темы
gist.search.placeholder.search: Поиск
gist.new.drop-files: Перетащите файлы сюда или нажмите для загрузки
gist.new.any-file-type: Поддерживаются файлы любого типа
gist.delete.confirm: Вы уверены, что хотите удалить этот gist?
gist.list.topic-results: Все фрагменты с этой темой
gist.revision.binary-file-changes: Изменения в бинарных файлах не отображаются
admin.actions.sync-gist-languages: Обновить языки всех фрагментов
admin.invitations.delete_confirm: Вы хотите удалить это приглашение?
flash.auth.passkey-deleted: Ключ доступа удалён
flash.auth.passkey-registred: Ключ доступа %s зарегистрирован
validation.invalid-gist-topics: 'Некорректные темы фрагмента: они должны начинаться с буквы или цифры, быть не длиннее 50 символов и могут содержать дефисы'
settings.header.tokens: Токены доступа
settings.style.removed-lines-color: Цвет удалённых строк
settings.style.added-lines-color: Цвет добавленных строк
settings.style.git-lines-color: Цвет git-строк
settings.style.save-style: Сохранить оформление
settings.create-token: Создать токен доступа
settings.create-token-help: Токены доступа используются для доступа к API
settings.token-name: Название
settings.delete-token-confirm: Подтвердите удаление токена доступа
settings.token-permissions: Права доступа
settings.token-gist-permission: Фрагменты
settings.token-permission-none: Нет доступа
settings.token-permission-read: Чтение
settings.token-permission-read-write: Чтение и запись
settings.delete-token: Удалить
settings.token-created-at: Создан
settings.token-never-used: Не использовался
settings.token-expiration: Срок действия
settings.token-expiration-help: Оставьте пустым, чтобы срок действия не ограничивался
settings.token-expires-at: Истекает
settings.token-expired: истёк
settings.token-deleted: Токен доступа удалён
auth.mfa.delete-passkey-confirm: Подтвердите удаление ключа доступа
auth.mfa.use-passkey: Использовать ключ доступа
auth.mfa.bind-passkey: Добавить ключ доступа
auth.mfa.login-with-passkey: Войти с помощью ключа доступа
auth.mfa.waiting-for-passkey-input: Ожидание подтверждения в браузере…
auth.mfa.use-passkey-to-finish: Используйте ключ доступа для завершения аутентификации
auth.mfa.passkeys-help: Добавьте ключ доступа для входа в аккаунт и использования в качестве MFA.
auth.mfa.passkey-name: Название
auth.mfa.delete-passkey: Удалить
auth.mfa.passkey-added-at: Добавлен
auth.mfa.passkey-never-used: Никогда не использовался
auth.mfa.passkey-last-used: Последнее использование
auth.totp.already-enabled: TOTP уже включён
auth.totp.invalid-secret: Некорректный секретный ключ TOTP
auth.totp.invalid-code: Некорректный одноразовый код
auth.totp.code-used: Код восстановления %s уже был использован и больше недействителен. Вы можете отключить MFA или сгенерировать новые коды.
auth.totp.disabled: Двухфакторная аутентификация TOTP отключена
auth.totp.disable: Отключить TOTP
auth.totp.enter-code: Введите код из приложения Authenticator
auth.totp.submit: Подтвердить
auth.totp.proceed: Продолжить
auth.totp.scan-qr-code: Отсканируйте QR-код ниже в приложении-аутентификаторе для включения двухфакторной аутентификации или введите указанную строку и подтвердите кодом.
error.not-in-mfa-session: Пользователь не находится в MFA-сессии
error.no-file-uploaded: Файл не загружен
error.cannot-open-file: Не удалось открыть загруженный файл
auth.totp.help: TOTP — это метод двухфакторной аутентификации, использующий общий секрет для генерации одноразового пароля.
auth.totp.use: Использовать TOTP
auth.totp.regenerate-recovery-codes: Сгенерировать коды восстановления заново
auth.totp: Одноразовый пароль по времени (TOTP)
flash.admin.sync-gist-languages: Обновление языков фрагментов…
settings.token-created: Токен создан, обязательно сохраните его, повторно он показан не будет!
settings.token-last-used: Последнее использование
settings.token-no-expiration: Бессрочно
auth.totp.save-recovery-codes: Сохраните коды восстановления в безопасном месте. Они понадобятся для восстановления доступа к аккаунту при утере приложения-аутентификатора.
auth.totp.enter-recovery-key: или код восстановления, если вы потеряли устройство
settings.style.theme: Тема
settings.style.theme-light: Светлая тема
settings.style.theme-dark: Тёмная тема
settings.style.theme-auto: Авто
auth.mfa: Регистрация отключена администратором
auth.mfa.passkey: Вход
auth.mfa.passkeys: Ключи доступа
auth.totp.code: Код
settings.header.account: Аккаунт
settings.header.mfa: Двухфакторная аутентификация (MFA)
settings.header.ssh: SSH
settings.header.style: Тема оформления
settings.style.gist-code: Код фрагмента
settings.style.no-soft-wrap: Без переносов строк
settings.style.soft-wrap: Перенос строк

View File

@@ -191,7 +191,7 @@ admin.actions.sync-db: Gistleri veri tabanından senkronize et
admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et
admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla
admin.actions.index-gists: Tüm gistleri indeksle
admin.actions.index-gists: Arama dizinini yeniden oluştur
admin.id: ID
admin.user: Kullanıcı
admin.delete: Sil
@@ -234,7 +234,7 @@ flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor...
flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor...
flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor...
flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor...
flash.admin.index-gists: Tüm gistler indeksleniyor...
flash.admin.index-gists: Arama dizini yeniden oluşturuluyor...
flash.auth.username-exists: Kullanıcı adı zaten mevcut
flash.auth.invalid-credentials: Geçersiz kimlik bilgileri

View File

@@ -192,7 +192,7 @@ admin.actions.sync-db: Синхронізувати gists з базою дани
admin.actions.git-gc: Збір сміття з репозиторіїв Git
admin.actions.sync-previews: Синхронізувати всі gists перегляди
admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв
admin.actions.index-gists: Проіндексувати всі gists
admin.actions.index-gists: Перебудувати пошуковий індекс
admin.id: ID
admin.user: Користувач
admin.delete: Видалити
@@ -236,7 +236,7 @@ flash.admin.sync-db: Синхронізація репозиторіїв за б
flash.admin.git-gc: Збір сміття з репозиторіїв...
flash.admin.sync-previews: Синхронізація Gist переглядів...
flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв...
flash.admin.index-gists: Індексація всіх gists...
flash.admin.index-gists: Перебудова пошукового індексу...
flash.auth.username-exists: Це ім'я користувача вже існує
flash.auth.invalid-credentials: Недійсні облікові дані

View File

@@ -214,7 +214,7 @@ admin.invitations: '邀请'
admin.invitations.create: '创建邀请'
admin.actions.sync-previews: '同步所有 Gists 预览'
admin.actions.reset-hooks: '重置所有存储库的 Git 服务 hooks'
admin.actions.index-gists: '索引所有 Gists'
admin.actions.index-gists: '重建搜索索引'
admin.invitations.help: '即使在禁用注册功能的情况下,邀请功能也可用于创建帐户。'
admin.invitations.max_uses: '最多使用次数'
admin.invitations.expires_at: '过期时间'
@@ -231,7 +231,7 @@ flash.admin.sync-db: '正在从数据库同步存储库...'
flash.admin.git-gc: '正在进行存储库垃圾回收...'
flash.admin.sync-previews: '正在同步 Gist 预览...'
flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...'
flash.admin.index-gists: '正在索引所有 Gists...'
flash.admin.index-gists: '正在重建搜索索引...'
flash.auth.username-exists: '用户名已存在'
flash.auth.invalid-credentials: '无效的凭证'
flash.auth.account-linked-oauth: '帐户已关联到 %s'

View File

@@ -190,7 +190,7 @@ gist.search.no-results: 沒有找到任何 Gists
gist.search.help.title: Gists 的標題
gist.search.help.filename: Gists 的檔案名稱
gist.search.help.language: Gists 的程式語言
admin.actions.index-gists: 索引所有的 Gists
admin.actions.index-gists: 重建搜尋索引
gist.search.help.user: 由使用者建立的 Gists
gist.search.found: 已找到 Gists
gist.search.help.extension: Gists 的副檔名

View File

@@ -2,16 +2,21 @@ package index
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
"github.com/blevesearch/bleve/v2/analysis/token/length"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/search/query"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
)
type BleveIndexer struct {
@@ -52,14 +57,9 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
return nil, err
}
docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("GistID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("UserID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("Visibility", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("Content", bleve.NewTextFieldMapping())
mapping := bleve.NewIndexMapping()
// Token filters
if err = mapping.AddCustomTokenFilter("unicodeNormalize", map[string]any{
"type": unicodenorm.Name,
"form": unicodenorm.NFC,
@@ -67,21 +67,88 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
return nil, err
}
if err = mapping.AddCustomAnalyzer("gistAnalyser", map[string]interface{}{
"type": custom.Name,
"char_filters": []string{},
"tokenizer": unicode.Name,
"token_filters": []string{"unicodeNormalize", camelcase.Name, lowercase.Name},
if err = mapping.AddCustomTokenFilter("lengthMin2", map[string]interface{}{
"type": length.Name,
"min": 2.0,
}); err != nil {
return nil, err
}
docMapping.DefaultAnalyzer = "gistAnalyser"
// Analyzer: split mode (camelCase splitting for partial search)
// "CPUCard" -> ["cpu", "card"]
if err = mapping.AddCustomAnalyzer("codeSplit", map[string]interface{}{
"type": custom.Name,
"char_filters": []string{},
"tokenizer": unicode.Name,
"token_filters": []string{"unicodeNormalize", camelcase.Name, lowercase.Name, "lengthMin2"},
}); err != nil {
return nil, err
}
// Analyzer: exact mode (no camelCase splitting for full-word search)
// "CPUCard" -> ["cpucard"]
if err = mapping.AddCustomAnalyzer("codeExact", map[string]interface{}{
"type": custom.Name,
"char_filters": []string{},
"tokenizer": unicode.Name,
"token_filters": []string{"unicodeNormalize", lowercase.Name},
}); err != nil {
return nil, err
}
// Analyzer: keyword with lowercase (for Languages - single token, no splitting)
if err = mapping.AddCustomAnalyzer("lowercaseKeyword", map[string]interface{}{
"type": custom.Name,
"char_filters": []string{},
"tokenizer": "single",
"token_filters": []string{lowercase.Name},
}); err != nil {
return nil, err
}
// Document mapping
docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("GistID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("UserID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("Visibility", bleve.NewNumericFieldMapping())
// Content: dual indexing (exact + split)
// "Content" uses the property name so Bleve resolves its analyzer correctly
contentExact := bleve.NewTextFieldMapping()
contentExact.Name = "Content"
contentExact.Analyzer = "codeExact"
contentExact.Store = false
contentExact.IncludeTermVectors = true
contentSplit := bleve.NewTextFieldMapping()
contentSplit.Name = "ContentSplit"
contentSplit.Analyzer = "codeSplit"
contentSplit.Store = false
contentSplit.IncludeTermVectors = true
docMapping.AddFieldMappingsAt("Content", contentExact, contentSplit)
// Languages: keyword analyzer (preserves as single token)
languageFieldMapping := bleve.NewTextFieldMapping()
languageFieldMapping.Analyzer = "lowercaseKeyword"
docMapping.AddFieldMappingsAt("Languages", languageFieldMapping)
// All other text fields use codeSplit as default
docMapping.DefaultAnalyzer = "codeSplit"
mapping.DefaultMapping = docMapping
return bleve.New(i.path, mapping)
}
func (i *BleveIndexer) Reset() error {
i.Close()
if err := os.RemoveAll(i.path); err != nil {
return fmt.Errorf("failed to remove Bleve index directory: %w", err)
}
log.Info().Msg("Bleve index directory removed, re-creating index")
return i.Init()
}
func (i *BleveIndexer) Close() {
if i == nil || i.index == nil {
return
@@ -105,18 +172,111 @@ func (i *BleveIndexer) Remove(gistID uint) error {
return (*atomicIndexer.Load()).(*BleveIndexer).index.Delete(strconv.Itoa(int(gistID)))
}
func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
// Search returns a list of Gist IDs that match the given search metadata.
// The method returns an error if any.
//
// The queryMetadata parameter is used to filter the search results.
// For example, passing a non-empty Username will search for gists whose
// username matches the given string.
//
// If the "All" field in queryMetadata is non-empty, the method will
// search across all metadata fields with OR logic. Otherwise, the method
// will add each metadata field with AND logic.
//
// The page parameter is used to paginate the search results.
// The method returns the total number of search results in the second return
// value.
//
// The third return value is a map of language counts for the search results.
// The language counts are computed by asking Bleve to return the top 10
// facets for the "Languages" field.
func (i *BleveIndexer) Search(metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
var err error
var indexerQuery query.Query
if queryStr != "" {
// Use match query with fuzzy matching for more flexible content search
contentQuery := bleve.NewMatchQuery(queryStr)
contentQuery.SetField("Content")
contentQuery.SetFuzziness(2)
indexerQuery = contentQuery
} else {
contentQuery := bleve.NewMatchAllQuery()
indexerQuery = contentQuery
var indexerQuery query.Query = bleve.NewMatchAllQuery()
// Query factory
factoryQuery := func(field, value string) query.Query {
query := bleve.NewMatchPhraseQuery(value)
query.SetField(field)
return query
}
// Exact search
addQuery := func(field, value string) {
if value != "" && value != "." {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, factoryQuery(field, value))
}
}
// Query factory for text fields: exact match boosted + match query + prefix
factoryTextQuery := func(field, value string) query.Query {
exact := bleve.NewMatchPhraseQuery(value)
exact.SetField(field)
exact.SetBoost(2.0)
fuzzy := bleve.NewMatchQuery(value)
fuzzy.SetField(field)
fuzzy.SetFuzziness(1)
fuzzy.SetOperator(query.MatchQueryOperatorAnd)
queries := []query.Query{exact, fuzzy}
if len([]rune(value)) >= 2 {
prefix := bleve.NewPrefixQuery(strings.ToLower(value))
prefix.SetField(field)
prefix.SetBoost(1.5)
queries = append(queries, prefix)
}
if len([]rune(value)) >= 4 {
wildcard := bleve.NewWildcardQuery("*" + strings.ToLower(value) + "*")
wildcard.SetField(field)
wildcard.SetBoost(0.5)
queries = append(queries, wildcard)
}
return bleve.NewDisjunctionQuery(queries...)
}
// Query factory for Content: searches both exact (Content) and split (ContentSplit) fields
factoryContentQuery := func(value string) query.Query {
// Exact field (no camelCase split): matches "cpucard"
exactMatch := bleve.NewMatchQuery(value)
exactMatch.SetField("Content")
exactMatch.SetOperator(query.MatchQueryOperatorAnd)
exactMatch.SetBoost(2.0)
// Split field (camelCase split): matches "cpu", "card"
splitMatch := bleve.NewMatchQuery(value)
splitMatch.SetField("ContentSplit")
splitMatch.SetFuzziness(1)
splitMatch.SetOperator(query.MatchQueryOperatorAnd)
splitMatch.SetBoost(1.0)
queries := []query.Query{exactMatch, splitMatch}
if len([]rune(value)) >= 2 {
prefix := bleve.NewPrefixQuery(strings.ToLower(value))
prefix.SetField("Content")
prefix.SetBoost(1.5)
queries = append(queries, prefix)
}
if len([]rune(value)) >= 4 {
wildcard := bleve.NewWildcardQuery("*" + strings.ToLower(value) + "*")
wildcard.SetField("Content")
wildcard.SetBoost(0.5)
queries = append(queries, wildcard)
}
return bleve.NewDisjunctionQuery(queries...)
}
// Text field search
addTextQuery := func(field, value string) {
if value != "" && value != "." {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, factoryTextQuery(field, value))
}
}
// Visibility filtering: show public gists (Visibility=0) OR user's own gists
@@ -132,48 +292,62 @@ func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery)
indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery)
buildFieldQuery := func(field, value string) query.Query {
switch field {
case "Content":
return factoryContentQuery(value)
case "Title", "Description", "Filenames":
return factoryTextQuery(field, value)
case "Extensions":
return factoryQuery(field, "."+value)
default: // Username, Languages, Topics
return factoryQuery(field, value)
}
}
// Handle "All" field - search across all metadata fields with OR logic
if queryMetadata.All != "" {
allQueries := make([]query.Query, 0)
// Create match phrase queries for each field
fields := []struct {
field string
value string
}{
{"Username", queryMetadata.All},
{"Title", queryMetadata.All},
{"Extensions", "." + queryMetadata.All},
{"Filenames", queryMetadata.All},
{"Languages", queryMetadata.All},
{"Topics", queryMetadata.All},
if metadata.All != "" {
allQueries := make([]query.Query, 0, len(AllSearchFields))
for _, field := range AllSearchFields {
allQueries = append(allQueries, buildFieldQuery(field, metadata.All))
}
for _, f := range fields {
q := bleve.NewMatchPhraseQuery(f.value)
q.FieldVal = f.field
allQueries = append(allQueries, q)
}
// Combine all field queries with OR (disjunction)
allDisjunction := bleve.NewDisjunctionQuery(allQueries...)
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, allDisjunction)
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, bleve.NewDisjunctionQuery(allQueries...))
} else {
// Original behavior: add each metadata field with AND logic
addQuery := func(field, value string) {
if value != "" && value != "." {
q := bleve.NewMatchPhraseQuery(value)
q.FieldVal = field
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q)
}
addQuery("Username", metadata.Username)
addTextQuery("Title", metadata.Title)
addTextQuery("Description", metadata.Description)
addQuery("Extensions", "."+metadata.Extension)
addTextQuery("Filenames", metadata.Filename)
addQuery("Languages", metadata.Language)
addQuery("Topics", metadata.Topic)
if metadata.Content != "" {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, factoryContentQuery(metadata.Content))
}
addQuery("Username", queryMetadata.Username)
addQuery("Title", queryMetadata.Title)
addQuery("Extensions", "."+queryMetadata.Extension)
addQuery("Filenames", queryMetadata.Filename)
addQuery("Languages", queryMetadata.Language)
addQuery("Topics", queryMetadata.Topic)
// Handle default search fields from config with OR logic
if metadata.Default != "" {
var fields []string
for _, f := range strings.Split(config.C.SearchDefault, ",") {
f = strings.TrimSpace(f)
if f == "all" {
fields = AllSearchFields
break
}
if indexField, ok := SearchFieldMap[f]; ok {
fields = append(fields, indexField)
}
}
if len(fields) == 1 {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, buildFieldQuery(fields[0], metadata.Default))
} else if len(fields) > 1 {
defaultQueries := make([]query.Query, 0, len(fields))
for _, field := range fields {
defaultQueries = append(defaultQueries, buildFieldQuery(field, metadata.Default))
}
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, bleve.NewDisjunctionQuery(defaultQueries...))
}
}
}
languageFacet := bleve.NewFacetRequest("Languages", 10)
@@ -186,6 +360,8 @@ func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
s.Fields = []string{"GistID"}
s.IncludeLocations = false
log.Debug().Interface("searchRequest", s).Msg("Bleve search request")
results, err := (*atomicIndexer.Load()).(*BleveIndexer).index.Search(s)
if err != nil {
return nil, 0, nil, err

View File

@@ -4,33 +4,31 @@ import (
"os"
"path/filepath"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
)
// setupBleveIndexer creates a new BleveIndexer for testing
func setupBleveIndexer(t *testing.T) (*BleveIndexer, func()) {
func setupBleveIndexer(t *testing.T) (Indexer, func()) {
zerolog.SetGlobalLevel(zerolog.Disabled)
t.Helper()
// Create a temporary directory for the test index
tmpDir, err := os.MkdirTemp("", "bleve-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
require.NoError(t, err)
indexPath := filepath.Join(tmpDir, "test.index")
indexer := NewBleveIndexer(indexPath)
// Initialize the indexer
err = indexer.Init()
if err != nil {
os.RemoveAll(tmpDir)
t.Fatalf("Failed to initialize BleveIndexer: %v", err)
}
// Store in the global atomicIndexer since Add/Remove use it
var idx Indexer = indexer
atomicIndexer.Store(&idx)
// Return cleanup function
cleanup := func() {
atomicIndexer.Store(nil)
indexer.Close()
@@ -40,123 +38,50 @@ func setupBleveIndexer(t *testing.T) (*BleveIndexer, func()) {
return indexer, cleanup
}
func TestBleveIndexerAddGist(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
func TestBleveAddAndSearch(t *testing.T) { testAddAndSearch(t, setupBleveIndexer) }
func TestBleveAccessControl(t *testing.T) { testAccessControl(t, setupBleveIndexer) }
func TestBleveMetadataFilters(t *testing.T) { testMetadataFilters(t, setupBleveIndexer) }
func TestBleveAllFieldSearch(t *testing.T) { testAllFieldSearch(t, setupBleveIndexer) }
func TestBleveFuzzySearch(t *testing.T) { testFuzzySearch(t, setupBleveIndexer) }
func TestBleveContentSearch(t *testing.T) { testContentSearch(t, setupBleveIndexer) }
func TestBlevePagination(t *testing.T) { testPagination(t, setupBleveIndexer) }
func TestBleveLanguageFacets(t *testing.T) { testLanguageFacets(t, setupBleveIndexer) }
func TestBleveWildcardSearch(t *testing.T) { testWildcardSearch(t, setupBleveIndexer) }
func TestBleveMetadataOnlySearch(t *testing.T) { testMetadataOnlySearch(t, setupBleveIndexer) }
func TestBleveTitleFuzzySearch(t *testing.T) { testTitleFuzzySearch(t, setupBleveIndexer) }
func TestBleveMultiLanguageFacets(t *testing.T) { testMultiLanguageFacets(t, setupBleveIndexer) }
testIndexerAddGist(t, indexer)
}
func TestBleveIndexerAllFieldSearch(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerAllFieldSearch(t, indexer)
}
func TestBleveIndexerFuzzySearch(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerFuzzySearch(t, indexer)
}
func TestBleveIndexerSearchBasic(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerSearchBasic(t, indexer)
}
func TestBleveIndexerPagination(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerPagination(t, indexer)
}
// TestBleveIndexerInitAndClose tests Bleve-specific initialization and closing
func TestBleveIndexerInitAndClose(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bleve-init-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
func TestBlevePersistence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bleve-persist-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
indexPath := filepath.Join(tmpDir, "test.index")
indexer := NewBleveIndexer(indexPath)
// Test initialization
err = indexer.Init()
if err != nil {
t.Fatalf("Failed to initialize BleveIndexer: %v", err)
}
// Create and populate index
indexer1 := NewBleveIndexer(indexPath)
require.NoError(t, indexer1.Init())
if indexer.index == nil {
t.Fatal("Expected index to be initialized, got nil")
}
var idx Indexer = indexer1
atomicIndexer.Store(&idx)
// Test closing
indexer.Close()
g := newGist(1, 1, 0, "persistent data survives restart")
require.NoError(t, indexer1.Add(g))
// Test reopening the same index
indexer1.Close()
atomicIndexer.Store(nil)
// Reopen at same path
indexer2 := NewBleveIndexer(indexPath)
err = indexer2.Init()
if err != nil {
t.Fatalf("Failed to reopen BleveIndexer: %v", err)
}
require.NoError(t, indexer2.Init())
defer indexer2.Close()
if indexer2.index == nil {
t.Fatal("Expected reopened index to be initialized, got nil")
}
}
// TestBleveIndexerUnicodeSearch tests that Unicode content can be indexed and searched
func TestBleveIndexerUnicodeSearch(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
// Add a gist with Unicode content
gist := &Gist{
GistID: 100,
UserID: 100,
Visibility: 0,
Username: "testuser",
Title: "Unicode Test",
Content: "Hello world with unicode characters: café résumé naïve",
Filenames: []string{"test.txt"},
Extensions: []string{".txt"},
Languages: []string{"Text"},
Topics: []string{"unicode"},
CreatedAt: 1234567890,
UpdatedAt: 1234567890,
}
err := indexer.Add(gist)
if err != nil {
t.Fatalf("Failed to add gist: %v", err)
}
// Search for unicode content
gistIDs, total, _, err := indexer.Search("café", SearchGistMetadata{}, 100, 1)
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if total == 0 {
t.Skip("Unicode search may require specific index configuration")
return
}
found := false
for _, id := range gistIDs {
if id == 100 {
found = true
break
}
}
if !found {
t.Log("Unicode gist not found in search results, but other results were returned")
}
idx = indexer2
atomicIndexer.Store(&idx)
defer atomicIndexer.Store(nil)
ids, total, _, err := indexer2.Search(SearchGistMetadata{Content: "persistent"}, 1, 1)
require.NoError(t, err)
require.Equal(t, uint64(1), total, "data should survive close+reopen")
require.Equal(t, uint(1), ids[0])
}

View File

@@ -1,10 +1,24 @@
package index
var AllSearchFields = []string{"Username", "Title", "Description", "Filenames", "Extensions", "Languages", "Topics", "Content"}
var SearchFieldMap = map[string]string{
"user": "Username",
"title": "Title",
"description": "Description",
"filename": "Filenames",
"extension": "Extensions",
"language": "Languages",
"topic": "Topics",
"content": "Content",
}
type Gist struct {
GistID uint
UserID uint
Visibility uint
Username string
Description string
Title string
Content string
Filenames []string
@@ -18,9 +32,12 @@ type Gist struct {
type SearchGistMetadata struct {
Username string
Title string
Description string
Content string
Filename string
Extension string
Language string
Topic string
All string
Default string
}

View File

@@ -2,10 +2,11 @@ package index
import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"path/filepath"
"sync/atomic"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
)
var atomicIndexer atomic.Pointer[Indexer]
@@ -13,9 +14,10 @@ var atomicIndexer atomic.Pointer[Indexer]
type Indexer interface {
Init() error
Close()
Reset() error
Add(gist *Gist) error
Remove(gistID uint) error
Search(query string, metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error)
Search(metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error)
}
type IndexerType string
@@ -84,6 +86,19 @@ func Close() {
atomicIndexer.Store(nil)
}
func ResetIndex() error {
if !IndexEnabled() {
return nil
}
idx := atomicIndexer.Load()
if idx == nil {
return fmt.Errorf("indexer is not initialized")
}
return (*idx).Reset()
}
func AddInIndex(gist *Gist) error {
if !IndexEnabled() {
return nil
@@ -110,7 +125,11 @@ func RemoveFromIndex(gistID uint) error {
return (*idx).Remove(gistID)
}
func SearchGists(query string, metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
// SearchGists returns a list of Gist IDs that match the given search metadata.
// If the indexer is not enabled, it returns nil, 0, nil, nil.
// If the indexer is not initialized, it returns nil, 0, nil, fmt.Errorf("indexer is not initialized").
// The function returns an error if any.
func SearchGists(metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
if !IndexEnabled() {
return nil, 0, nil, nil
}
@@ -120,7 +139,7 @@ func SearchGists(query string, metadata SearchGistMetadata, userId uint, page in
return nil, 0, nil, fmt.Errorf("indexer is not initialized")
}
return (*idx).Search(query, metadata, userId, page)
return (*idx).Search(metadata, userId, page)
}
func DepreactionIndexDirname() {

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,11 @@ import (
"fmt"
"strconv"
"strings"
"unicode"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
)
type MeiliIndexer struct {
@@ -50,10 +52,7 @@ func (i *MeiliIndexer) open() (meilisearch.IndexManager, error) {
i.client = meilisearch.New(i.host, meilisearch.WithAPIKey(i.apikey))
indexResult, err := i.client.GetIndex(i.indexName)
if indexResult != nil && err == nil {
return indexResult.IndexManager, nil
}
if indexResult == nil || err != nil {
_, err = i.client.CreateIndex(&meilisearch.IndexConfig{
Uid: i.indexName,
PrimaryKey: "GistID",
@@ -61,17 +60,37 @@ func (i *MeiliIndexer) open() (meilisearch.IndexManager, error) {
if err != nil {
return nil, err
}
}
_, _ = i.client.Index(i.indexName).UpdateSettings(&meilisearch.Settings{
FilterableAttributes: []string{"GistID", "UserID", "Visibility", "Username", "Title", "Filenames", "Extensions", "Languages", "Topics"},
DisplayedAttributes: []string{"GistID"},
SearchableAttributes: []string{"Content", "Username", "Title", "Filenames", "Extensions", "Languages", "Topics"},
RankingRules: []string{"words"},
FilterableAttributes: []string{"GistID", "UserID", "Visibility", "Username", "Extensions", "Languages", "Topics"},
SearchableAttributes: []string{"Content", "ContentSplit", "Username", "Title", "Description", "Filenames", "Extensions", "Languages", "Topics"},
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
TypoTolerance: &meilisearch.TypoTolerance{
Enabled: true,
DisableOnNumbers: true,
MinWordSizeForTypos: meilisearch.MinWordSizeForTypos{OneTypo: 4, TwoTypos: 10},
},
})
return i.client.Index(i.indexName), nil
}
func (i *MeiliIndexer) Reset() error {
if i.client != nil {
taskInfo, err := i.client.DeleteIndex(i.indexName)
if err != nil {
return fmt.Errorf("failed to delete Meilisearch index: %w", err)
}
_, err = i.client.WaitForTask(taskInfo.TaskUID, 0)
if err != nil {
return fmt.Errorf("failed to wait for Meilisearch index deletion: %w", err)
}
log.Info().Msg("Meilisearch index deleted, re-creating index")
}
return i.Init()
}
func (i *MeiliIndexer) Close() {
if i.client != nil {
i.client.Close()
@@ -80,12 +99,21 @@ func (i *MeiliIndexer) Close() {
i.client = nil
}
type meiliGist struct {
Gist
ContentSplit string
}
func (i *MeiliIndexer) Add(gist *Gist) error {
if gist == nil {
return errors.New("failed to add nil gist to index")
}
doc := &meiliGist{
Gist: *gist,
ContentSplit: splitCamelCase(gist.Content),
}
primaryKey := "GistID"
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(doc, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
return err
}
@@ -94,13 +122,14 @@ func (i *MeiliIndexer) Remove(gistID uint) error {
return err
}
func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
func (i *MeiliIndexer) Search(queryMetadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
searchRequest := &meilisearch.SearchRequest{
Offset: int64((page - 1) * 10),
Limit: 11,
AttributesToRetrieve: []string{"GistID", "Languages"},
Facets: []string{"Languages"},
AttributesToSearchOn: []string{"Content"},
AttributesToSearchOn: []string{"Content", "ContentSplit"},
MatchingStrategy: meilisearch.All,
}
var filters []string
@@ -111,23 +140,83 @@ func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
filters = append(filters, fmt.Sprintf("%s = \"%s\"", field, escapeFilterValue(value)))
}
}
var query string
if queryMetadata.All != "" {
query = queryMetadata.All
searchRequest.AttributesToSearchOn = append(AllSearchFields, "ContentSplit")
} else {
// Exact-match fields stay as filters
addFilter("Username", queryMetadata.Username)
addFilter("Title", queryMetadata.Title)
addFilter("Filenames", queryMetadata.Filename)
addFilter("Extensions", queryMetadata.Extension)
if queryMetadata.Extension != "" {
ext := queryMetadata.Extension
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
addFilter("Extensions", ext)
}
addFilter("Languages", queryMetadata.Language)
addFilter("Topics", queryMetadata.Topic)
if queryMetadata.Default != "" {
query = queryMetadata.Default
var fields []string
for _, f := range strings.Split(config.C.SearchDefault, ",") {
f = strings.TrimSpace(f)
if f == "all" {
fields = AllSearchFields
break
}
if indexField, ok := SearchFieldMap[f]; ok {
fields = append(fields, indexField)
}
}
if len(fields) > 0 {
for _, f := range fields {
if f == "Content" {
fields = append(fields, "ContentSplit")
break
}
}
searchRequest.AttributesToSearchOn = fields
}
} else {
// Fuzzy-matchable fields become part of the query
var queryParts []string
var searchFields []string
if queryMetadata.Content != "" {
queryParts = append(queryParts, queryMetadata.Content)
searchFields = append(searchFields, "Content", "ContentSplit")
}
if queryMetadata.Title != "" {
queryParts = append(queryParts, queryMetadata.Title)
searchFields = append(searchFields, "Title")
}
if queryMetadata.Description != "" {
queryParts = append(queryParts, queryMetadata.Description)
searchFields = append(searchFields, "Description")
}
if queryMetadata.Filename != "" {
queryParts = append(queryParts, queryMetadata.Filename)
searchFields = append(searchFields, "Filenames")
}
query = strings.Join(queryParts, " ")
if len(searchFields) > 0 {
searchRequest.AttributesToSearchOn = searchFields
}
}
}
if len(filters) > 0 {
searchRequest.Filter = strings.Join(filters, " AND ")
}
response, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.Search(queryStr, searchRequest)
response, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.Search(query, searchRequest)
if err != nil {
log.Error().Err(err).Msg("Failed to search Meilisearch index")
return nil, 0, nil, err
}
gistIds := make([]uint, 0, len(response.Hits))
for _, hit := range response.Hits {
if gistIDRaw, ok := hit["GistID"]; ok {
@@ -143,7 +232,9 @@ func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
var facetDist map[string]map[string]int
if err := json.Unmarshal(response.FacetDistribution, &facetDist); err == nil {
if facets, ok := facetDist["Languages"]; ok {
languageCounts = facets
for lang, count := range facets {
languageCounts[strings.ToLower(lang)] += count
}
}
}
}
@@ -151,6 +242,30 @@ func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
return gistIds, uint64(response.EstimatedTotalHits), languageCounts, nil
}
func splitCamelCase(text string) string {
var result strings.Builder
runes := []rune(text)
for i := 0; i < len(runes); i++ {
r := runes[i]
if i > 0 {
prev := runes[i-1]
if unicode.IsUpper(r) {
if unicode.IsLower(prev) || unicode.IsDigit(prev) {
result.WriteRune(' ')
} else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
result.WriteRune(' ')
}
} else if unicode.IsDigit(r) && !unicode.IsDigit(prev) {
result.WriteRune(' ')
} else if !unicode.IsDigit(r) && unicode.IsDigit(prev) {
result.WriteRune(' ')
}
}
result.WriteRune(r)
}
return result.String()
}
func escapeFilterValue(value string) string {
escaped := strings.ReplaceAll(value, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")

View File

@@ -0,0 +1,88 @@
package index
import (
"fmt"
"os"
"strconv"
"testing"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog"
)
// syncMeiliIndexer wraps MeiliIndexer to make Add/Remove synchronous for tests.
type syncMeiliIndexer struct {
*MeiliIndexer
}
func (s *syncMeiliIndexer) Add(gist *Gist) error {
if gist == nil {
return fmt.Errorf("failed to add nil gist to index")
}
doc := &meiliGist{
Gist: *gist,
ContentSplit: splitCamelCase(gist.Content),
}
primaryKey := "GistID"
taskInfo, err := s.index.AddDocuments(doc, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
if err != nil {
return err
}
_, err = s.client.WaitForTask(taskInfo.TaskUID, 0)
return err
}
func (s *syncMeiliIndexer) Remove(gistID uint) error {
taskInfo, err := s.index.DeleteDocument(strconv.Itoa(int(gistID)), nil)
if err != nil {
return err
}
_, err = s.client.WaitForTask(taskInfo.TaskUID, 0)
return err
}
func setupMeiliIndexer(t *testing.T) (Indexer, func()) {
zerolog.SetGlobalLevel(zerolog.Disabled)
t.Helper()
host := os.Getenv("OG_TEST_MEILI_HOST")
if host == "" {
host = "http://localhost:47700"
}
apiKey := os.Getenv("OG_TEST_MEILI_API_KEY")
indexName := fmt.Sprintf("test_%d", os.Getpid())
inner := NewMeiliIndexer(host, apiKey, indexName)
err := inner.Init()
if err != nil {
t.Skipf("MeiliSearch not available at %s: %v", host, err)
}
wrapped := &syncMeiliIndexer{MeiliIndexer: inner}
// Store the inner MeiliIndexer in atomicIndexer, because MeiliIndexer.Search
// type-asserts the global to *MeiliIndexer.
var idx Indexer = inner
atomicIndexer.Store(&idx)
cleanup := func() {
atomicIndexer.Store(nil)
inner.Reset()
inner.Close()
}
return wrapped, cleanup
}
func TestMeiliAddAndSearch(t *testing.T) { testAddAndSearch(t, setupMeiliIndexer) }
func TestMeiliAccessControl(t *testing.T) { testAccessControl(t, setupMeiliIndexer) }
func TestMeiliMetadataFilters(t *testing.T) { testMetadataFilters(t, setupMeiliIndexer) }
func TestMeiliAllFieldSearch(t *testing.T) { testAllFieldSearch(t, setupMeiliIndexer) }
func TestMeiliFuzzySearch(t *testing.T) { testFuzzySearch(t, setupMeiliIndexer) }
func TestMeiliContentSearch(t *testing.T) { testContentSearch(t, setupMeiliIndexer) }
func TestMeiliPagination(t *testing.T) { testPagination(t, setupMeiliIndexer) }
func TestMeiliLanguageFacets(t *testing.T) { testLanguageFacets(t, setupMeiliIndexer) }
func TestMeiliMetadataOnlySearch(t *testing.T) { testMetadataOnlySearch(t, setupMeiliIndexer) }
func TestMeiliTitleFuzzySearch(t *testing.T) { testTitleFuzzySearch(t, setupMeiliIndexer) }
func TestMeiliMultiLanguageFacets(t *testing.T) { testMultiLanguageFacets(t, setupMeiliIndexer) }

View File

@@ -29,6 +29,7 @@ type RenderedGist struct {
*db.Gist
Lines []string
HTML string
PreviewMimeType *git.MimeType
}
func highlightFile(file *git.File) (HighlightedFile, error) {
@@ -76,6 +77,18 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
Gist: gist,
}
if gist.PreviewMimeType != "" {
mt := &git.MimeType{ContentType: gist.PreviewMimeType}
if mt.CanBeEmbedded() {
rendered.PreviewMimeType = mt
return rendered, nil
}
}
if gist.Preview == "" {
return rendered, nil
}
style := newStyle()
lexer := newLexer(gist.PreviewFilename)
if lexer.Config().Name == "markdown" {

View File

@@ -59,7 +59,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
name := fl.Field().String()
restrictedNames := map[string]struct{}{}
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn"} {
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} {
restrictedNames[restrictedName] = struct{}{}
}

View File

@@ -0,0 +1,46 @@
package admin_test
import (
"testing"
"github.com/stretchr/testify/require"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestAdminActions(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
urls := []string{
"/admin-panel/sync-fs",
"/admin-panel/sync-db",
"/admin-panel/gc-repos",
"/admin-panel/sync-previews",
"/admin-panel/reset-hooks",
"/admin-panel/index-gists",
"/admin-panel/sync-languages",
}
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("NoUser", func(t *testing.T) {
for _, url := range urls {
s.Request(t, "POST", url, nil, 404)
}
})
t.Run("AdminUser", func(t *testing.T) {
s.Login(t, "thomas")
for _, url := range urls {
resp := s.Request(t, "POST", url, nil, 302)
require.Equal(t, "/admin-panel", resp.Header.Get("Location"))
}
})
t.Run("NonAdminUser", func(t *testing.T) {
s.Login(t, "nonadmin")
for _, url := range urls {
s.Request(t, "POST", url, nil, 404)
}
})
}

View File

@@ -0,0 +1,269 @@
package admin_test
import (
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestAdminPages(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
urls := []string{
"/admin-panel",
"/admin-panel/users",
"/admin-panel/gists",
"/admin-panel/invitations",
"/admin-panel/configuration",
}
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("NoUser", func(t *testing.T) {
for _, url := range urls {
s.Request(t, "GET", url, nil, 404)
}
})
t.Run("AdminUser", func(t *testing.T) {
s.Login(t, "thomas")
for _, url := range urls {
s.Request(t, "GET", url, nil, 200)
}
})
t.Run("NonAdminUser", func(t *testing.T) {
s.Login(t, "nonadmin")
for _, url := range urls {
s.Request(t, "GET", url, nil, 404)
}
})
}
func TestAdminSetConfig(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
settings := []string{
db.SettingDisableSignup,
db.SettingRequireLogin,
db.SettingAllowGistsWithoutLogin,
db.SettingDisableLoginForm,
db.SettingDisableGravatar,
}
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("NoUser", func(t *testing.T) {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
})
t.Run("NonAdminUser", func(t *testing.T) {
s.Login(t, "nonadmin")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
})
t.Run("AdminUser", func(t *testing.T) {
s.Login(t, "thomas")
for _, setting := range settings {
val, err := db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "0", val)
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200)
val, err = db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "1", val)
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200)
val, err = db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "0", val)
}
})
}
func TestAdminPagination(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
for i := 0; i < 11; i++ {
s.Register(t, "user"+strconv.Itoa(i))
}
t.Run("Pagination", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "GET", "/admin-panel/users", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=2", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=3", nil, 404)
s.Request(t, "GET", "/admin-panel/users?page=0", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=-1", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=a", nil, 200)
})
}
func TestAdminUserOperations(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("DeleteUser", func(t *testing.T) {
s.Login(t, "nonadmin")
gist1 := db.GistDTO{
Title: "gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt"},
Content: []string{"yeah"},
Topics: "",
}
s.Request(t, "POST", "/", gist1, 302)
_, err := os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin"))
require.NoError(t, err)
count, err := db.CountAll(db.User{})
require.NoError(t, err)
require.Equal(t, int64(2), count)
s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 404)
s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 302)
count, err = db.CountAll(db.User{})
require.NoError(t, err)
require.Equal(t, int64(1), count)
_, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin"))
require.Error(t, err)
})
}
func TestAdminGistOperations(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("DeleteGist", func(t *testing.T) {
s.Login(t, "nonadmin")
gist1 := db.GistDTO{
Title: "gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt"},
Content: []string{"yeah"},
Topics: "",
}
s.Request(t, "POST", "/", gist1, 302)
count, err := db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(1), count)
gist1Db, err := db.GetGistByID("1")
require.NoError(t, err)
_, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier()))
require.NoError(t, err)
s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 404)
s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 302)
count, err = db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(0), count)
_, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier()))
require.Error(t, err)
})
}
func TestAdminInvitationOperations(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("Invitation", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {""},
"expiredAtUnix": {""},
}, 302)
invitation1, err := db.GetInvitationByID(1)
require.NoError(t, err)
require.Equal(t, uint(1), invitation1.ID)
require.Equal(t, uint(0), invitation1.NbUsed)
require.Equal(t, uint(10), invitation1.NbMax)
require.InDelta(t, time.Now().Unix()+604800, invitation1.ExpiresAt, 10)
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {"aa"},
"expiredAtUnix": {"1735722000"},
}, 302)
invitation2, err := db.GetInvitationByID(2)
require.NoError(t, err)
require.Equal(t, invitation2, &db.Invitation{
ID: 2,
Code: invitation2.Code,
ExpiresAt: time.Unix(1735722000, 0).Unix(),
NbUsed: 0,
NbMax: 10,
})
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {"20"},
"expiredAtUnix": {"1735722000"},
}, 302)
invitation3, err := db.GetInvitationByID(3)
require.NoError(t, err)
require.Equal(t, invitation3, &db.Invitation{
ID: 3,
Code: invitation3.Code,
ExpiresAt: time.Unix(1735722000, 0).Unix(),
NbUsed: 0,
NbMax: 20,
})
count, err := db.CountAll(db.Invitation{})
require.NoError(t, err)
require.Equal(t, int64(3), count)
s.Request(t, "POST", "/admin-panel/invitations/1/delete", nil, 302)
count, err = db.CountAll(db.Invitation{})
require.NoError(t, err)
require.Equal(t, int64(2), count)
})
}

View File

@@ -0,0 +1 @@
package auth_test

View File

@@ -4,16 +4,15 @@ import (
"crypto/md5"
"errors"
"fmt"
"slices"
"strings"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/oauth"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gorm.io/gorm"
)
@@ -48,7 +47,8 @@ func Oauth(ctx *context.Context) error {
provider, err := oauth.DefineProvider(providerStr, opengistUrl)
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.oauth-unsupported"), nil)
ctx.AddFlash(ctx.Tr("error.oauth-unsupported"), "error")
return ctx.Redirect(302, "/login")
}
if err = provider.RegisterProvider(); err != nil {
@@ -62,28 +62,37 @@ func Oauth(ctx *context.Context) error {
func OauthCallback(ctx *context.Context) error {
provider, err := oauth.CompleteUserAuth(ctx)
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.complete-oauth-login", err.Error()), err)
ctx.AddFlash(ctx.Tr("auth.oauth.no-provider"), "error")
return ctx.Redirect(302, "/login")
}
currUser := ctx.User
user := provider.GetProviderUser()
// if user is logged in, link account to user and update its avatar URL
if currUser != nil {
provider.UpdateUserDB(currUser)
if err = currUser.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot update user "+cases.Title(language.English).String(provider.GetProvider())+" id", err)
}
ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", cases.Title(language.English).String(provider.GetProvider())), "success")
// check if this OAuth account is already linked to another user
if existingUser, err := db.GetUserByProvider(user.UserID, provider.GetProvider()); err == nil && existingUser != nil {
ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error")
return ctx.RedirectTo("/settings")
}
provider.UpdateUserDB(currUser)
if err = currUser.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot update user "+config.C.OIDCProviderName+" id", err)
}
ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", config.C.OIDCProviderName), "success")
return ctx.RedirectTo("/settings")
}
user := provider.GetProviderUser()
userDB, err := db.GetUserByProvider(user.UserID, provider.GetProvider())
// if user is not in database, create it
// if user is not in database, redirect to OAuth registration page
if err != nil {
if ctx.GetData("DisableSignup") == true {
return ctx.ErrorRes(403, ctx.Tr("error.signup-disabled"), nil)
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -94,79 +103,174 @@ func OauthCallback(ctx *context.Context) error {
user.NickName = strings.Split(user.Email, "@")[0]
}
userDB = &db.User{
Username: user.NickName,
Email: user.Email,
MD5Hash: fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(user.Email))))),
sess := ctx.GetSession()
sess.Values["oauthProvider"] = provider.GetProvider()
sess.Values["oauthUserID"] = user.UserID
sess.Values["oauthNickname"] = user.NickName
sess.Values["oauthEmail"] = user.Email
sess.Values["oauthAvatarURL"] = user.AvatarURL
sess.Values["oauthIsAdmin"] = provider.IsAdmin()
sess.Options.MaxAge = 10 * 60 // 10 minutes
ctx.SaveSession(sess)
return ctx.RedirectTo("/oauth/register")
}
// set provider id and avatar URL
provider.UpdateUserDB(userDB)
if err = userDB.Create(); err != nil {
if db.IsUniqueConstraintViolation(err) {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
return ctx.RedirectTo("/login")
}
return ctx.ErrorRes(500, "Cannot create user", err)
}
// if oidc admin group is not configured set first user as admin
if config.C.OIDCAdminGroup == "" && userDB.ID == 1 {
if err = userDB.SetAdmin(); err != nil {
// promote user to admin from oidc group
if !userDB.IsAdmin && provider.IsAdmin() {
userDB.IsAdmin = true
if err = userDB.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot set user admin", err)
}
}
keys, err := provider.GetProviderUserSSHKeys()
sess := ctx.GetSession()
sess.Values["user"] = userDB.ID
ctx.SaveSession(sess)
ctx.DeleteCsrfCookie()
return ctx.RedirectTo("/")
}
func OauthRegister(ctx *context.Context) error {
if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
}
sess := ctx.GetSession()
ctx.SetData("title", ctx.TrH("auth.oauth.complete-registration"))
ctx.SetData("htmlTitle", ctx.TrH("auth.oauth.complete-registration"))
ctx.SetData("oauthProvider", config.C.OIDCProviderName)
ctx.SetData("oauthNickname", sess.Values["oauthNickname"])
ctx.SetData("oauthEmail", sess.Values["oauthEmail"])
ctx.SetData("oauthAvatarURL", sess.Values["oauthAvatarURL"])
return ctx.Html("oauth_register.html")
}
func ProcessOauthRegister(ctx *context.Context) error {
if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
}
sess := ctx.GetSession()
providerStr := sess.Values["oauthProvider"].(string)
oauthUserID := sess.Values["oauthUserID"].(string)
setOauthRegisterData := func(dto *db.OAuthRegisterDTO) {
ctx.SetData("title", ctx.TrH("auth.oauth.complete-registration"))
ctx.SetData("htmlTitle", ctx.TrH("auth.oauth.complete-registration"))
ctx.SetData("oauthProvider", config.C.OIDCProviderName)
if dto != nil {
ctx.SetData("oauthNickname", dto.Username)
ctx.SetData("oauthEmail", dto.Email)
} else {
ctx.SetData("oauthNickname", sess.Values["oauthNickname"])
ctx.SetData("oauthEmail", sess.Values["oauthEmail"])
}
ctx.SetData("oauthAvatarURL", sess.Values["oauthAvatarURL"])
}
// Bind and validate form data
dto := new(db.OAuthRegisterDTO)
if err := ctx.Bind(dto); err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
}
if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
setOauthRegisterData(dto)
return ctx.Html("oauth_register.html")
}
if exists, err := db.UserExists(dto.Username); err != nil || exists {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
setOauthRegisterData(dto)
return ctx.Html("oauth_register.html")
}
// Check if OAuth account is already linked to another user (race condition protection)
if existingUser, err := db.GetUserByProvider(oauthUserID, providerStr); err == nil && existingUser != nil {
ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error")
setOauthRegisterData(dto)
return ctx.Html("oauth_register.html")
}
userDB := &db.User{
Username: dto.Username,
Email: dto.Email,
}
if dto.Email != "" {
userDB.MD5Hash = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(dto.Email)))))
}
nickname := ""
if n, ok := sess.Values["oauthNickname"].(string); ok {
nickname = n
}
avatarURL := ""
if av, ok := sess.Values["oauthAvatarURL"].(string); ok {
avatarURL = av
}
callbackProvider, err := oauth.NewCallbackProviderFromSession(providerStr, oauthUserID, nickname, dto.Email, avatarURL)
if err != nil {
return ctx.ErrorRes(500, "Cannot create provider", err)
}
callbackProvider.UpdateUserDB(userDB)
if err := userDB.Create(); err != nil {
if db.IsUniqueConstraintViolation(err) {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
setOauthRegisterData(dto)
return ctx.Html("oauth_register.html")
}
return ctx.ErrorRes(500, "Cannot create user", err)
}
if config.C.OIDCAdminGroup == "" && userDB.ID == 1 {
if err := userDB.SetAdmin(); err != nil {
return ctx.ErrorRes(500, "Cannot set user admin", err)
}
}
if isAdmin, ok := sess.Values["oauthIsAdmin"].(bool); ok && isAdmin {
userDB.IsAdmin = true
_ = userDB.Update()
}
keys, err := callbackProvider.GetProviderUserSSHKeys()
if err != nil {
ctx.AddFlash(ctx.Tr("flash.auth.user-sshkeys-not-retrievable"), "error")
log.Error().Err(err).Msg("Could not get user keys")
} else {
for _, key := range keys {
sshKey := db.SSHKey{
Title: "Added from " + user.Provider,
Title: "Added from " + providerStr,
Content: key,
User: *userDB,
}
if err = sshKey.Create(); err != nil {
ctx.AddFlash(ctx.Tr("flash.auth.user-sshkeys-not-created"), "error")
log.Error().Err(err).Msg("Could not create ssh key")
}
}
}
}
// update is admin status from oidc group
if config.C.OIDCAdminGroup != "" {
groupClaimName := config.C.OIDCGroupClaimName
if groupClaimName == "" {
log.Error().Msg("No OIDC group claim name configured")
} else if groups, ok := user.RawData[groupClaimName].([]interface{}); ok {
var groupNames []string
for _, group := range groups {
if groupName, ok := group.(string); ok {
groupNames = append(groupNames, groupName)
}
}
isOIDCAdmin := slices.Contains(groupNames, config.C.OIDCAdminGroup)
log.Debug().Bool("isOIDCAdmin", isOIDCAdmin).Str("user", user.Name).Msg("User is in admin group")
delete(sess.Values, "oauthProvider")
delete(sess.Values, "oauthUserID")
delete(sess.Values, "oauthNickname")
delete(sess.Values, "oauthEmail")
delete(sess.Values, "oauthAvatarURL")
delete(sess.Values, "oauthIsAdmin")
if userDB.IsAdmin != isOIDCAdmin {
userDB.IsAdmin = isOIDCAdmin
if err = userDB.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot set user admin", err)
}
}
} else {
log.Error().Msg("No groups found in user data")
}
}
sess := ctx.GetSession()
sess.Values["user"] = userDB.ID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
ctx.SaveSession(sess)
ctx.DeleteCsrfCookie()
@@ -184,10 +288,10 @@ func OauthUnlink(ctx *context.Context) error {
if provider.UserHasProvider(currUser) {
if err := currUser.DeleteProviderID(providerStr); err != nil {
return ctx.ErrorRes(500, "Cannot unlink account from "+cases.Title(language.English).String(providerStr), err)
return ctx.ErrorRes(500, "Cannot unlink account from "+config.C.OIDCProviderName, err)
}
ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", cases.Title(language.English).String(providerStr)), "success")
ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", config.C.OIDCProviderName), "success")
return ctx.RedirectTo("/settings")
}

View File

@@ -0,0 +1,221 @@
package auth_test
import (
"net/url"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestRegisterPage(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("Form", func(t *testing.T) {
s.Request(t, "GET", "/register", nil, 200)
s.TestCtxData(t, echo.Map{
"isLoginPage": false,
"disableForm": false,
"disableSignup": false,
})
})
t.Run("FormDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "GET", "/register", nil, 200)
s.TestCtxData(t, echo.Map{
"disableSignup": true,
})
})
t.Run("FormDisabledWithInviteCode", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {"10"},
"expiredAtUnix": {""},
}, 302)
invitation, err := db.GetInvitationByID(1)
require.NoError(t, err)
s.Logout()
s.Request(t, "GET", "/register", nil, 200)
s.TestCtxData(t, echo.Map{
"disableSignup": true,
})
s.Request(t, "GET", "/register?code="+invitation.Code, nil, 200)
s.TestCtxData(t, echo.Map{
"disableSignup": false,
})
})
}
func TestProcessRegister(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Register", func(t *testing.T) {
user, err := db.GetUserByUsername("thomas")
require.NoError(t, err)
require.True(t, user.IsAdmin)
s.Logout()
s.Request(t, "POST", "/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302)
user, err = db.GetUserByUsername("seconduser")
require.NoError(t, err)
require.False(t, user.IsAdmin)
s.Logout()
})
t.Run("DuplicateUsername", func(t *testing.T) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302)
s.Logout()
s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200)
s.Logout()
})
t.Run("InvalidUsername", func(t *testing.T) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "", Password: "password123"}, 200)
s.Request(t, "POST", "/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200)
})
t.Run("EmptyPassword", func(t *testing.T) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "newuser", Password: ""}, 200)
})
t.Run("RegisterDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "POST", "/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403)
exists, err := db.UserExists("blocked")
require.NoError(t, err)
require.False(t, exists)
})
t.Run("RegisterWithInvitationCode", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {"10"},
"expiredAtUnix": {""},
}, 302)
s.Logout()
invitations, err := db.GetAllInvitations()
require.NoError(t, err)
require.NotEmpty(t, invitations)
invitation := invitations[len(invitations)-1]
s.Logout()
s.Request(t, "POST", "/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302)
user, err := db.GetUserByUsername("inviteduser")
require.NoError(t, err)
require.Equal(t, "inviteduser", user.Username)
updatedInvitation, err := db.GetInvitationByID(invitation.ID)
require.NoError(t, err)
require.Equal(t, uint(1), updatedInvitation.NbUsed)
})
}
func TestLoginPage(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("Form", func(t *testing.T) {
s.Request(t, "GET", "/login", nil, 200)
s.TestCtxData(t, echo.Map{
"isLoginPage": true,
"disableForm": false,
})
})
t.Run("FormDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "GET", "/login", nil, 200)
s.TestCtxData(t, echo.Map{
"disableForm": true,
})
})
}
func TestProcessLogin(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("ValidCredentials", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302)
require.Equal(t, "/", resp.Header.Get("Location"))
require.NotEmpty(t, s.SessionCookie)
require.Equal(t, "thomas", s.User().Username)
s.Logout()
})
t.Run("InvalidPassword", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302)
require.Equal(t, "/login", resp.Header.Get("Location"))
require.Nil(t, s.User())
})
t.Run("NonExistentUser", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302)
require.Equal(t, "/login", resp.Header.Get("Location"))
require.Nil(t, s.User())
})
t.Run("EmptyCredentials", func(t *testing.T) {
s.Request(t, "POST", "/login", db.UserDTO{Username: "", Password: ""}, 302)
require.Nil(t, s.User())
})
t.Run("LoginFormDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403)
require.Nil(t, s.User())
})
}
func TestLogout(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("LogoutRedirects", func(t *testing.T) {
s.Login(t, "thomas")
require.Equal(t, "thomas", s.User().Username)
resp := s.Request(t, "GET", "/logout", nil, 302)
require.Equal(t, "/all", resp.Header.Get("Location"))
require.Nil(t, s.User())
s.Request(t, "GET", "/", nil, 302)
})
}

View File

@@ -164,6 +164,18 @@ func AllGists(ctx *context.Context) error {
return ctx.Html("all.html")
}
// Search handles the search page for gists.
//
// It takes a query parameter "q" which is a search query in the format:
// "user:username title:title description:description filename:filename language:language topic:topic"
//
// It also takes a page parameter "page" which is the page number to display.
//
// It returns an error if the search query is invalid or if the page number is invalid.
//
// It returns the search results as a list of rendered gists, along with the total number of results, the languages found, and the search query.
//
// The search results are paginated, with 10 results per page.
func Search(ctx *context.Context) error {
var err error
@@ -171,7 +183,7 @@ func Search(ctx *context.Context) error {
Query: ctx.QueryParam("q"),
}
content, meta := handlers.ParseSearchQueryStr(ctx.QueryParam("q"))
metadata := handlers.ParseSearchQueryStr(ctx.QueryParam("q"))
pageInt := handlers.GetPage(ctx)
var currentUserId uint
@@ -182,14 +194,18 @@ func Search(ctx *context.Context) error {
currentUserId = 0
}
gistsIds, nbHits, langs, err := index.SearchGists(content, index.SearchGistMetadata{
Username: meta["user"],
Title: meta["title"],
Filename: meta["filename"],
Extension: meta["extension"],
Language: meta["language"],
Topic: meta["topic"],
All: meta["all"],
// Search gists in the index and fetch the gists IDs from the database
gistsIds, nbHits, langs, err := index.SearchGists(index.SearchGistMetadata{
Username: metadata["user"],
Title: metadata["title"],
Description: metadata["description"],
Filename: metadata["filename"],
Extension: metadata["extension"],
Language: metadata["language"],
Topic: metadata["topic"],
Content: metadata["content"],
All: metadata["all"],
Default: metadata["default"],
}, currentUserId, pageInt)
if err != nil {
return ctx.ErrorRes(500, "Error searching gists", err)

View File

@@ -24,11 +24,6 @@ func Create(ctx *context.Context) error {
func ProcessCreate(ctx *context.Context) error {
isCreate := ctx.Request().URL.Path == "/"
err := ctx.Request().ParseForm()
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), err)
}
dto := new(db.GistDTO)
var gist *db.Gist
@@ -39,25 +34,24 @@ func ProcessCreate(ctx *context.Context) error {
ctx.SetData("htmlTitle", ctx.TrH("gist.edit.edit-gist", gist.Title))
}
if err := ctx.Bind(dto); err != nil {
err := ctx.Bind(dto)
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
}
dto.Files = make([]db.FileDTO, 0)
fileCounter := 0
names := ctx.Request().PostForm["name"]
contents := ctx.Request().PostForm["content"]
names := dto.Name
contents := dto.Content
// Process files from text editors
for i, content := range contents {
if content == "" {
continue
}
name := names[i]
name := git.CleanTreePathName(names[i])
if name == "" {
fileCounter += 1
name = "gistfile" + strconv.Itoa(fileCounter) + ".txt"
name = "gistfile" + strconv.Itoa(len(dto.Files)+1) + ".txt"
}
escapedValue, err := url.PathUnescape(content)
@@ -72,18 +66,26 @@ func ProcessCreate(ctx *context.Context) error {
}
// Process uploaded files from UUID arrays
fileUUIDs := ctx.Request().PostForm["uploadedfile_uuid"]
fileFilenames := ctx.Request().PostForm["uploadedfile_filename"]
fileUUIDs := dto.UploadedFilesUUID
fileFilenames := dto.UploadedFilesNames
if len(fileUUIDs) == len(fileFilenames) {
for i, fileUUID := range fileUUIDs {
filePath := filepath.Join(filepath.Join(config.GetHomeDir(), "uploads"), fileUUID)
if !uuidRegex.MatchString(filepath.Base(fileUUID)) {
continue
}
filePath := filepath.Join(config.GetHomeDir(), "uploads", fileUUID)
if _, err := os.Stat(filePath); err != nil {
continue
}
name := git.CleanTreePathName(fileFilenames[i])
if name == "" {
name = "gistfile" + strconv.Itoa(len(dto.Files)+1) + ".txt"
}
dto.Files = append(dto.Files, db.FileDTO{
Filename: fileFilenames[i],
Filename: name,
SourcePath: filePath,
Content: "", // Empty since we're using SourcePath
})
@@ -91,11 +93,11 @@ func ProcessCreate(ctx *context.Context) error {
}
// Process binary file operations (edit mode)
binaryOldNames := ctx.Request().PostForm["binary_old_name"]
binaryNewNames := ctx.Request().PostForm["binary_new_name"]
binaryOldNames := dto.BinaryFileOldName
binaryNewNames := dto.BinaryFileNewName
if len(binaryOldNames) == len(binaryNewNames) {
for i, oldName := range binaryOldNames {
newName := binaryNewNames[i]
newName := git.CleanTreePathName(binaryNewNames[i])
if newName == "" { // deletion
continue

View File

@@ -0,0 +1,519 @@
package gist_test
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// Helper function to extract username and gist identifier from redirect URL
func getGistInfoFromRedirect(resp *http.Response) (username, identifier string) {
location := resp.Header.Get("Location")
// Location format: /{username}/{identifier}
parts := strings.Split(strings.TrimPrefix(location, "/"), "/")
if len(parts) >= 2 {
return parts[0], parts[1]
}
return "", ""
}
func verifyGistCreation(t *testing.T, gist *db.Gist, username, identifier string) {
require.NotNil(t, gist)
require.Equal(t, username, gist.User.Username)
require.Equal(t, identifier, gist.Identifier())
require.NotEmpty(t, gist.Uuid)
require.Greater(t, gist.NbFiles, 0)
gistPath := filepath.Join(config.GetHomeDir(), git.ReposDirectory, username, gist.Uuid)
_, err := os.Stat(gistPath)
require.NoError(t, err, "Gist repository should exist on filesystem at %s", gistPath)
}
func TestGistCreationPage(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("NoAuth", func(t *testing.T) {
s.Request(t, "GET", "/", nil, 302)
})
t.Run("Authenticated", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "GET", "/", nil, 200)
})
}
func TestGistCreation(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("NoAuth", func(t *testing.T) {
s.Request(t, "POST", "/", url.Values{
"title": {"Test Gist"},
"name": {"test.txt"},
"content": {"hello world"},
}, 302) // Redirects to login
// Verify no gist was created
count, err := db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(0), count)
})
tests := []struct {
name string
data url.Values
expectedCode int
expectGistCreated bool
expectedTitle string
expectedDescription string
expectedURL string
expectedTopics string // Expected topics string
expectedNbFiles int
expectedVisibility db.Visibility
expectedFileNames []string // Expected filenames in the gist
expectedFileContents map[string]string // Expected content for each file (filename -> content)
}{
{
name: "NoFiles",
data: url.Values{
"title": {"Test Gist"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "EmptyContent",
data: url.Values{
"title": {"Test Gist"},
"name": {"test.txt"},
"content": {""},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "TitleTooLong",
data: url.Values{
"title": {strings.Repeat("a", 251)}, // Max is 250
"name": {"test.txt"},
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "DescriptionTooLong",
data: url.Values{
"title": {"Test Gist"},
"description": {strings.Repeat("a", 1001)}, // Max is 1000
"name": {"test.txt"},
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "URLTooLong",
data: url.Values{
"title": {"Test Gist"},
"url": {strings.Repeat("a", 33)}, // Max is 32
"name": {"test.txt"},
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "URLInvalidCharacters",
data: url.Values{
"title": {"Test Gist"},
"url": {"invalid@url#here"}, // Only alphanumeric and dashes allowed
"name": {"test.txt"},
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "InvalidVisibility",
data: url.Values{
"title": {"Test Gist"},
"name": {"test.txt"},
"content": {"hello"},
"private": {"3"}, // Valid values are 0, 1, 2
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "Valid",
data: url.Values{
"title": {"My Test Gist"},
"name": {"test.txt"},
"url": {"my-custom-url-123"}, // Alphanumeric + dashes should be valid
"content": {"hello world"},
"private": {"0"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "My Test Gist",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"test.txt"},
expectedFileContents: map[string]string{
"test.txt": "hello world",
},
},
{
name: "AutoNamedFile",
data: url.Values{
"title": {"Auto Named"},
"name": {""},
"content": {"content without name"},
"private": {"0"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Auto Named",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"gistfile1.txt"},
expectedFileContents: map[string]string{
"gistfile1.txt": "content without name",
},
},
{
name: "MultipleFiles",
data: url.Values{
"title": {"Multi File Gist"},
"name": []string{"", "file2.md"},
"content": []string{"content 1", "content 2"},
"private": {"0"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Multi File Gist",
expectedNbFiles: 2,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"gistfile1.txt", "file2.md"},
expectedFileContents: map[string]string{
"gistfile1.txt": "content 1",
"file2.md": "content 2",
},
},
{
name: "NoTitle",
data: url.Values{
"name": {"readme.md"},
"content": {"# README"},
"private": {"0"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "readme.md",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"readme.md"},
expectedFileContents: map[string]string{
"readme.md": "# README",
},
},
{
name: "Unlisted",
data: url.Values{
"title": {"Unlisted Gist"},
"name": {"secret.txt"},
"content": {"secret content"},
"private": {"1"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Unlisted Gist",
expectedNbFiles: 1,
expectedVisibility: db.UnlistedVisibility,
expectedFileNames: []string{"secret.txt"},
expectedFileContents: map[string]string{
"secret.txt": "secret content",
},
},
{
name: "Private",
data: url.Values{
"title": {"Private Gist"},
"name": {"secret.txt"},
"content": {"secret content"},
"private": {"2"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Private Gist",
expectedNbFiles: 1,
expectedVisibility: db.PrivateVisibility,
expectedFileNames: []string{"secret.txt"},
expectedFileContents: map[string]string{
"secret.txt": "secret content",
},
},
{
name: "Topics",
data: url.Values{
"title": {"Gist With Topics"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {"golang testing webdev"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Gist With Topics",
expectedTopics: "golang,testing,webdev",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"test.txt"},
expectedFileContents: map[string]string{
"test.txt": "hello",
},
},
{
name: "TopicsTooMany",
data: url.Values{
"title": {"Test"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {"topic1 topic2 topic3 topic4 topic5 topic6 topic7 topic8 topic9 topic10 topic11"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "TopicTooLong",
data: url.Values{
"title": {"Test"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {strings.Repeat("a", 51)}, // 51 chars - exceeds max of 50
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "TopicInvalidCharacters",
data: url.Values{
"title": {"Test"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {"topic@name topic.name"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "TopicUnicode",
data: url.Values{
"title": {"Unicode Topics"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {"编程 тест"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Unicode Topics",
expectedTopics: "编程,тест",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"test.txt"},
},
{
name: "DuplicateFileNames",
data: url.Values{
"title": {"Duplicate Files"},
"name": []string{"test.txt", "test.txt"},
"content": []string{"content1", "content2"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Duplicate Files",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"test.txt"},
expectedFileContents: map[string]string{
"test.txt": "content2",
},
},
{
name: "FileNameTooLong",
data: url.Values{
"title": {"Too Long Filename"},
"name": {strings.Repeat("a", 256) + ".txt"}, // 260 total - exceeds 255
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "FileNameWithUnicode",
data: url.Values{
"title": {"Unicode Filename"},
"name": {"文件.txt"},
"content": {"hello world"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Unicode Filename",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"文件.txt"},
expectedFileContents: map[string]string{
"文件.txt": "hello world",
},
},
{
name: "FileNamePathTraversal",
data: url.Values{
"title": {"Path Traversal"},
"name": {"../../../etc/passwd"},
"content": {"malicious"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Path Traversal",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"passwd"},
expectedFileContents: map[string]string{
"passwd": "malicious",
},
},
{
name: "EmptyAndValidContent",
data: url.Values{
"title": {"Mixed Content"},
"name": []string{"empty.txt", "valid.txt", "also-empty.txt"},
"content": []string{"", "valid content", ""},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Mixed Content",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"valid.txt"},
expectedFileContents: map[string]string{
"valid.txt": "valid content",
},
},
{
name: "ContentWithSpecialCharacters",
data: url.Values{
"title": {"Special Chars"},
"name": {"special.txt"},
"content": {"Line1\nLine2\tTabbed\x00NullByte😀Emoji"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Special Chars",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"special.txt"},
expectedFileContents: map[string]string{
"special.txt": "Line1\nLine2\tTabbed\x00NullByte😀Emoji",
},
},
{
name: "ContentMultibyteUnicode",
data: url.Values{
"title": {"Unicode Content"},
"name": {"unicode.txt"},
"content": {"Hello 世界 🌍 Привет"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Unicode Content",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"unicode.txt"},
expectedFileContents: map[string]string{
"unicode.txt": "Hello 世界 🌍 Привет",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas")
resp := s.Request(t, "POST", "/", tt.data, tt.expectedCode)
if tt.expectGistCreated {
// Get gist info from redirect
username, gistIdentifier := getGistInfoFromRedirect(resp)
require.Equal(t, "thomas", username)
require.NotEmpty(t, gistIdentifier)
// Verify gist was created
gist, err := db.GetGist(username, gistIdentifier)
require.NoError(t, err)
// Run common verification (filesystem, git, etc.)
verifyGistCreation(t, gist, username, gistIdentifier)
// Verify all expected fields
require.Equal(t, tt.expectedTitle, gist.Title, "Title mismatch")
require.Equal(t, tt.expectedNbFiles, gist.NbFiles, "File count mismatch")
require.Equal(t, tt.expectedVisibility, gist.Private, "Visibility mismatch")
// Verify description if specified
if tt.expectedDescription != "" {
require.Equal(t, tt.expectedDescription, gist.Description, "Description mismatch")
}
// Verify URL if specified
if tt.expectedURL != "" {
require.Equal(t, tt.expectedURL, gist.Identifier(), "URL/Identifier mismatch")
}
// Verify topics if specified
if tt.expectedTopics != "" {
// Get gist topics
topics, err := gist.GetTopics()
require.NoError(t, err, "Failed to get gist topics")
require.ElementsMatch(t, strings.Split(tt.expectedTopics, ","), topics, "Topics mismatch")
}
// Verify files if specified
if len(tt.expectedFileNames) > 0 {
files, err := gist.Files("HEAD", false)
require.NoError(t, err, "Failed to get gist files")
require.Len(t, files, len(tt.expectedFileNames), "File count mismatch")
actualFileNames := make([]string, len(files))
for i, file := range files {
actualFileNames[i] = file.Filename
}
require.ElementsMatch(t, tt.expectedFileNames, actualFileNames, "File names mismatch")
// Verify file contents if specified
if len(tt.expectedFileContents) > 0 {
for filename, expectedContent := range tt.expectedFileContents {
content, _, err := git.GetFileContent(username, gist.Uuid, "HEAD", filename, false)
require.NoError(t, err, "Failed to get content for file %s", filename)
require.Equal(t, expectedContent, content, "Content mismatch for file %s", filename)
}
}
}
}
})
}
}

View File

@@ -0,0 +1,74 @@
package gist_test
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestDeleteGist(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("NoAuth", func(t *testing.T) {
gistPath, _, username, identifier := s.CreateGist(t, "0")
deleteURL := "/" + username + "/" + identifier + "/delete"
s.Request(t, "POST", deleteURL, nil, 302)
gistCheck, err := db.GetGist(username, identifier)
require.NoError(t, err, "Gist should still exist in database")
require.NotNil(t, gistCheck)
_, err = os.Stat(gistPath)
require.NoError(t, err, "Gist should still exist on filesystem")
})
t.Run("DeleteOwnGist", func(t *testing.T) {
gistPath, _, username, identifier := s.CreateGist(t, "0")
gistCheck, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.NotNil(t, gistCheck)
s.Login(t, "thomas")
deleteURL := "/" + username + "/" + identifier + "/delete"
s.Request(t, "POST", deleteURL, nil, 302)
gistCheck, err = db.GetGist(username, identifier)
require.Error(t, err, "Gist should be deleted from database")
_, err = os.Stat(gistPath)
require.Error(t, err, "Gist should not exist on filesystem after deletion")
require.True(t, os.IsNotExist(err), "Filesystem should return 'not exist' error")
require.Equal(t, uint(0), gistCheck.ID, "Gist should be not in database after deletion")
})
t.Run("DeleteOthersGist", func(t *testing.T) {
gistPath, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
deleteURL := "/" + username + "/" + identifier + "/delete"
s.Request(t, "POST", deleteURL, nil, 403)
gistCheck, err := db.GetGist(username, identifier)
require.NoError(t, err, "Gist should still exist in database")
require.NotNil(t, gistCheck)
_, err = os.Stat(gistPath)
require.NoError(t, err, "Gist should still exist on filesystem")
})
t.Run("DeleteNonExistentGist", func(t *testing.T) {
s.Login(t, "thomas")
deleteURL := "/thomas/nonexistent-gist-12345/delete"
s.Request(t, "POST", deleteURL, nil, 404)
})
}

View File

@@ -3,6 +3,7 @@ package gist
import (
"archive/zip"
"bytes"
"net/url"
"strconv"
"github.com/thomiceli/opengist/internal/db"
@@ -19,8 +20,23 @@ func RawFile(ctx *context.Context) error {
if file == nil {
return ctx.NotFound("File not found")
}
if file.MimeType.IsSVG() {
ctx.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
} else if file.MimeType.IsPDF() {
ctx.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
if file.MimeType.CanBeEmbedded() {
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
ctx.Response().Header().Set("Content-Disposition", "inline; filename=\""+file.Filename+"\"")
} else if file.MimeType.IsText() {
ctx.Response().Header().Set("Content-Type", "text/plain; charset=utf-8")
} else {
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
}
ctx.Response().Header().Set("Content-Disposition", "inline; filename=\""+url.PathEscape(file.Filename)+"\"")
ctx.Response().Header().Set("X-Content-Type-Options", "nosniff")
return ctx.PlainText(200, file.Content)
}
@@ -36,8 +52,9 @@ func DownloadFile(ctx *context.Context) error {
}
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+file.Filename)
ctx.Response().Header().Set("Content-Disposition", "attachment; filename=\""+url.PathEscape(file.Filename)+"\"")
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
ctx.Response().Header().Set("X-Content-Type-Options", "nosniff")
_, err = ctx.Response().Write([]byte(file.Content))
if err != nil {
return ctx.ErrorRes(500, "Error downloading the file", err)

View File

@@ -0,0 +1,141 @@
package gist_test
import (
"archive/zip"
"bytes"
"io"
"testing"
"github.com/stretchr/testify/require"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestDownloadZip(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
t.Run("MultipleFiles", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/archive/HEAD", nil, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
require.NoError(t, err)
require.Len(t, zipReader.File, 2)
fileNames := make([]string, len(zipReader.File))
contents := make([]string, len(zipReader.File))
for i, file := range zipReader.File {
fileNames[i] = file.Name
f, err := file.Open()
require.NoError(t, err)
content, err := io.ReadAll(f)
require.NoError(t, err)
contents[i] = string(content)
f.Close()
}
require.ElementsMatch(t, []string{"file.txt", "otherfile.txt"}, fileNames)
require.ElementsMatch(t, []string{"hello world", "other content"}, contents)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Request(t, "GET", "/"+username+"/"+identifier+"/archive/HEAD", nil, 404)
})
t.Run("NonExistentRevision", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
// TODO: return 404
s.Request(t, "GET", "/"+username+"/"+identifier+"/archive/zz", nil, 0)
})
}
func TestRawFile(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
t.Run("ExistingFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/HEAD/file.txt", nil, 200)
require.Equal(t, `inline; filename="file.txt"`, resp.Header.Get("Content-Disposition"))
require.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
require.Contains(t, resp.Header.Get("Content-Type"), "text/plain")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "hello world", string(body))
})
t.Run("NonExistentFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/HEAD/nonexistent.txt", nil, 404)
})
t.Run("NonExistentRevision", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/zz/file.txt", nil, 404)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/HEAD/file.txt", nil, 404)
})
}
func TestDownloadFile(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
t.Run("ExistingFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/download/HEAD/file.txt", nil, 200)
require.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
require.Equal(t, `attachment; filename="file.txt"`, resp.Header.Get("Content-Disposition"))
require.Equal(t, "11", resp.Header.Get("Content-Length"))
require.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "hello world", string(body))
})
t.Run("NonExistentFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/download/HEAD/nonexistent.txt", nil, 404)
_, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// TODO: change the response to not found
// require.Equal(t, "File not found", string(body))
})
t.Run("NonExistentRevision", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/download/zz/file.txt", nil, 404)
_, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// TODO: change the response to not found
// require.Equal(t, "File not found", string(body))
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Request(t, "GET", "/"+username+"/"+identifier+"/download/HEAD/file.txt", nil, 404)
})
}

View File

@@ -1,10 +1,11 @@
package gist
import (
"strconv"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/render"
"github.com/thomiceli/opengist/internal/web/context"
"strconv"
)
func Edit(ctx *context.Context) error {

View File

@@ -0,0 +1,66 @@
package gist_test
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestVisibility(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("ChangeVisibility", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", url.Values{
"private": {"2"},
}, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, db.PrivateVisibility, gist.Private)
})
t.Run("ChangeToUnlisted", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", url.Values{
"private": {"1"},
}, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, db.UnlistedVisibility, gist.Private)
})
t.Run("OtherUserCannotChange", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", nil, 403)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, db.PublicVisibility, gist.Private)
})
t.Run("NoAuth", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Logout()
s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", nil, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, db.PublicVisibility, gist.Private)
})
}

View File

@@ -0,0 +1,102 @@
package gist_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestFork(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Fork", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
resp := s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
forkedGist, err := db.GetGistByID("2")
require.NoError(t, err)
require.Equal(t, "alice", forkedGist.User.Username)
require.Equal(t, gist.Title, forkedGist.Title)
require.Equal(t, gist.Description, forkedGist.Description)
require.Equal(t, gist.Private, forkedGist.Private)
require.Equal(t, gist.ID, forkedGist.ForkedID)
forkedFiles, err := forkedGist.Files("HEAD", false)
require.NoError(t, err)
gistFiles, err := gist.Files("HEAD", false)
require.NoError(t, err)
for i, file := range gistFiles {
require.Equal(t, file.Filename, forkedFiles[i].Filename)
require.Equal(t, file.Content, forkedFiles[i].Content)
}
require.Equal(t, "/alice/"+forkedGist.Identifier(), resp.Header.Get("Location"))
original, err := db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, 1, original.NbForks)
forks, err := original.GetForks(2, 0)
require.NoError(t, err)
require.Len(t, forks, 1)
require.Equal(t, forkedGist.ID, forks[0].ID)
forkedGists, err := db.GetAllGistsForkedByUser(2, 2, 0, "created", "asc")
require.NoError(t, err)
require.Len(t, forkedGists, 1)
require.Equal(t, forkedGist.ID, forkedGists[0].ID)
})
t.Run("OwnGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
original, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 0, original.NbForks)
})
t.Run("AlreadyForked", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
firstResp := s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
forkLocation := firstResp.Header.Get("Location")
secondResp := s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
require.Equal(t, forkLocation, secondResp.Header.Get("Location"))
original, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 1, original.NbForks)
})
t.Run("NoAuth", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
original, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 0, original.NbForks)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 404)
})
}

View File

@@ -165,10 +165,39 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string) (string, erro
}
js := fmt.Sprintf(`
document.write('<link rel="stylesheet" href=%s>');
document.write('<link rel="stylesheet" href=%s>');
document.write(%s);
`,
(function() {
if (!customElements.get('opengist-embed')) {
customElements.define('opengist-embed', class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
init(css1, css2, content) {
this.shadowRoot.innerHTML = %s
<style>
@import url(${css1});
@import url(${css2});
:host { display: block; all: initial; font-family: sans-serif; }
</style>
<div class="container">${content}</div>
%s;
}
});
}
var currentScript = document.currentScript || (function() {
var scripts = document.getElementsByTagName('script');
return scripts[scripts.length - 1];
})();
const instance = document.createElement('opengist-embed');
instance.init(%s, %s, %s);
currentScript.parentNode.insertBefore(instance, currentScript.nextSibling);
})();
`,
"`",
"`",
string(jsonCssUrl),
string(jsonThemeUrl),
string(jsonContent),

View File

@@ -0,0 +1,327 @@
package gist_test
import (
"encoding/json"
"io"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func setupManifestEntries() {
context.ManifestEntries = map[string]context.Asset{
"embed.css": {File: "assets/embed.css"},
"ts/embed.ts": {Css: []string{"assets/embed.css"}},
"ts/light.ts": {Css: []string{"assets/light.css"}},
"ts/dark.ts": {Css: []string{"assets/dark.css"}},
}
}
func TestGistIndex(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Public", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
})
t.Run("NonExistentRevision", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier+"/rev/nonexistent", nil, 404)
})
t.Run("NonExistentGist", func(t *testing.T) {
s.Request(t, "GET", "/thomas/nonexistent", nil, 404)
})
t.Run("Unlisted", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "1")
s.Login(t, "thomas")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
s.Login(t, "alice")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
s.Logout()
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
})
t.Run("Private", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Login(t, "thomas")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
s.Login(t, "alice")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 404)
s.Logout()
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 404)
})
t.Run("SpecificRevision", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{
"title": {"Test"},
"name": {"file.txt"},
"content": {"updated content"},
}, 302)
files, err := gist.Files("HEAD", false)
require.NoError(t, err)
found := false
for _, f := range files {
if f.Filename == "file.txt" {
require.Equal(t, "updated content", f.Content)
found = true
}
}
require.True(t, found)
commits, err := gist.Log(0)
require.NoError(t, err)
require.Len(t, commits, 2)
filesOld, err := gist.Files(commits[1].Hash, false)
require.NoError(t, err)
for _, f := range filesOld {
if f.Filename == "file.txt" {
require.Equal(t, "hello world", f.Content)
}
}
s.Request(t, "GET", "/"+username+"/"+identifier+"/rev/HEAD", nil, 200)
s.Request(t, "GET", "/"+username+"/"+identifier+"/rev/"+commits[1].Hash, nil, 200)
})
}
func TestPreview(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("Markdown", func(t *testing.T) {
s.Login(t, "thomas")
resp := s.Request(t, "POST", "/preview", url.Values{
"content": {"# Hello\n\nThis is **bold** and *italic*."},
}, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
html := string(body)
require.Contains(t, html, "<h1>")
require.Contains(t, html, "Hello")
require.Contains(t, html, "<strong>bold</strong>")
require.Contains(t, html, "<em>italic</em>")
})
t.Run("NoAuth", func(t *testing.T) {
s.Logout()
s.Request(t, "POST", "/preview", url.Values{
"content": {"# Hello"},
}, 302)
})
}
func TestGistJson(t *testing.T) {
setupManifestEntries()
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Public", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+".json", nil, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(body, &result)
require.NoError(t, err)
t.Helper()
require.Equal(t, username, result["owner"])
require.Equal(t, identifier, result["id"])
require.Equal(t, gist.Uuid, result["uuid"])
require.Equal(t, gist.Title, result["title"])
require.Equal(t, "public", result["visibility"])
require.Equal(t, []interface{}{"hello", "opengist"}, result["topics"])
require.Equal(t, []interface{}{
map[string]interface{}{
"content": "hello world",
"filename": "file.txt",
"human_size": "11 B",
"size": float64(11),
"truncated": false,
"type": "Text",
},
map[string]interface{}{
"content": "other content",
"filename": "otherfile.txt",
"human_size": "13 B",
"size": float64(13),
"truncated": false,
"type": "Text",
},
}, result["files"])
embed, ok := result["embed"].(map[string]interface{})
require.True(t, ok)
require.Contains(t, embed["js"], identifier+".js")
require.Contains(t, embed["js_dark"], identifier+".js?dark")
require.NotEmpty(t, embed["css"])
require.NotEmpty(t, embed["html"])
})
t.Run("Unlisted", func(t *testing.T) {
s.Logout()
_, _, username, identifier := s.CreateGist(t, "1")
s.Request(t, "GET", "/"+username+"/"+identifier+".json", nil, 200)
})
t.Run("Private", func(t *testing.T) {
s.Logout()
_, _, username, identifier := s.CreateGist(t, "2")
s.Request(t, "GET", "/"+username+"/"+identifier+".json", nil, 404)
})
t.Run("NonExistentGist", func(t *testing.T) {
s.Request(t, "GET", "/thomas/nonexistent.json", nil, 404)
})
}
func TestGistAccess(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
_, _, user, publicId := s.CreateGist(t, "0")
_, _, _, unlistedId := s.CreateGist(t, "1")
_, _, _, privateId := s.CreateGist(t, "2")
tests := []struct {
name string
settings map[string]string
// expected codes: [owner, otherUser, anonymous] x [public, unlisted, private]
owner, otherUser, anonymous []int
}{
{
name: "Default",
owner: []int{200, 200, 200},
otherUser: []int{200, 200, 404},
anonymous: []int{200, 200, 404},
},
{
name: "RequireLogin",
settings: map[string]string{db.SettingRequireLogin: "1"},
owner: []int{200, 200, 200},
otherUser: []int{200, 200, 404},
anonymous: []int{302, 302, 302},
},
{
name: "AllowGistsWithoutLogin",
settings: map[string]string{db.SettingRequireLogin: "1", db.SettingAllowGistsWithoutLogin: "1"},
owner: []int{200, 200, 200},
otherUser: []int{200, 200, 404},
anonymous: []int{200, 200, 404},
},
}
gists := []string{publicId, unlistedId, privateId}
labels := []string{"Public", "Unlisted", "Private"}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas")
for k, v := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
}
t.Run("Owner", func(t *testing.T) {
s.Login(t, "thomas")
for i, id := range gists {
s.Request(t, "GET", "/"+user+"/"+id, nil, tt.owner[i])
}
})
t.Run("OtherUser", func(t *testing.T) {
s.Login(t, "alice")
for i, id := range gists {
s.Request(t, "GET", "/"+user+"/"+id, nil, tt.otherUser[i])
}
})
t.Run("Anonymous", func(t *testing.T) {
s.Logout()
for i, id := range gists {
t.Run(labels[i], func(t *testing.T) {
s.Request(t, "GET", "/"+user+"/"+id, nil, tt.anonymous[i])
})
}
})
s.Login(t, "thomas")
for k := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
}
})
}
}
func TestGetGistCaseInsensitive(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "THOmas")
s.Login(t, "THOmas")
s.Request(t, "POST", "/", url.Values{
"title": {"Test"},
"name": {"file.txt"},
"content": {"hello world"},
"url": {"my-GIST"},
"private": {"0"},
}, 302)
gist, err := db.GetGistByID("1")
require.NoError(t, err)
s.Logout()
t.Run("URL", func(t *testing.T) {
s.Request(t, "GET", "/thomas/my-gist", nil, 200)
s.Request(t, "GET", "/THOMAS/MY-GIST", nil, 200)
s.Request(t, "GET", "/thomas/MY-GIST", nil, 200)
s.Request(t, "GET", "/THOMAS/my-gist", nil, 200)
})
t.Run("UUID", func(t *testing.T) {
s.Request(t, "GET", "/thomas/"+strings.ToLower(gist.Uuid), nil, 200)
s.Request(t, "GET", "/THOMAS/"+strings.ToUpper(gist.Uuid), nil, 200)
})
}

View File

@@ -0,0 +1,96 @@
package gist_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestLike(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Like", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
resp := s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
require.Equal(t, "/"+username+"/"+identifier, resp.Header.Get("Location"))
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 2, gist.NbLikes)
likers, err := gist.GetUsersLikes(0)
require.NoError(t, err)
require.Len(t, likers, 2)
})
t.Run("Unlike", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 0, gist.NbLikes)
likers, err := gist.GetUsersLikes(0)
require.NoError(t, err)
require.Len(t, likers, 0)
})
t.Run("NoAuth", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Logout()
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 0, gist.NbLikes)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 404)
})
}
func TestLikes(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Likes", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
s.Request(t, "GET", "/"+username+"/"+identifier+"/likes", nil, 200)
users, err := gist.GetUsersLikes(0)
require.NoError(t, err)
require.Len(t, users, 2)
require.Equal(t, "thomas", users[0].Username)
require.Equal(t, "alice", users[1].Username)
})
}

View File

@@ -1,10 +1,11 @@
package gist
import (
"strings"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
"strings"
)
func Revisions(ctx *context.Context) error {

View File

@@ -0,0 +1,153 @@
package gist_test
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/git"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestRevisions(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Revisions", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{
"title": {"Test"},
"name": {"file.txt", "ok.txt"},
"content": {"updated content", "okay"},
}, 302)
s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{
"title": {"Test"},
"name": {"renamed.txt", "ok.txt"},
"content": {"updated content", "okay"},
}, 302)
commits, err := gist.Log(0)
require.NoError(t, err)
require.Len(t, commits, 3)
require.Regexp(t, "^[a-f0-9]{40}$", commits[0].Hash)
require.Regexp(t, "^[a-f0-9]{40}$", commits[1].Hash)
require.Regexp(t, "^[a-f0-9]{40}$", commits[2].Hash)
require.Equal(t, &git.Commit{
Hash: commits[0].Hash,
Timestamp: commits[0].Timestamp,
AuthorName: "thomas",
Changed: "1 file changed, 0 insertions, 0 deletions",
Files: []git.File{
{
Filename: "renamed.txt",
Size: 0,
HumanSize: "",
OldFilename: "file.txt",
Content: ``,
Truncated: false,
IsCreated: false,
IsDeleted: false,
IsBinary: false,
MimeType: git.MimeType{},
},
},
}, commits[0])
require.Equal(t, &git.Commit{
Hash: commits[1].Hash,
Timestamp: commits[1].Timestamp,
AuthorName: "thomas",
Changed: "3 files changed, 2 insertions, 2 deletions",
Files: []git.File{
{
Filename: "file.txt",
OldFilename: "file.txt",
Content: `@@ -1 +1 @@
-hello world
\ No newline at end of file
+updated content
\ No newline at end of file
`,
IsCreated: false,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "ok.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
+okay
\ No newline at end of file
`,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "otherfile.txt",
OldFilename: "",
Content: `@@ -1 +0,0 @@
-other content
\ No newline at end of file
`,
IsCreated: false,
IsDeleted: true,
IsBinary: false,
},
},
}, commits[1])
require.Equal(t, &git.Commit{
Hash: commits[2].Hash,
Timestamp: commits[2].Timestamp,
AuthorName: "thomas",
Changed: "2 files changed, 2 insertions",
Files: []git.File{
{
Filename: "file.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
+hello world
\ No newline at end of file
`,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "otherfile.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
+other content
\ No newline at end of file
`,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
},
},
}, commits[2])
s.Request(t, "GET", "/"+username+"/"+identifier+"/revisions", nil, 200)
})
t.Run("NoAuth", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Logout()
s.Request(t, "GET", "/"+username+"/"+identifier+"/revisions", nil, 200)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Login(t, "alice")
s.Request(t, "GET", "/"+username+"/"+identifier+"/revisions", nil, 404)
})
}

View File

@@ -4,12 +4,15 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"github.com/google/uuid"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/web/context"
)
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
func Upload(ctx *context.Context) error {
err := ctx.Request().ParseMultipartForm(32 << 20) // 32 MB max
if err != nil {
@@ -57,13 +60,13 @@ func Upload(ctx *context.Context) error {
}
func DeleteUpload(ctx *context.Context) error {
uuid := ctx.Param("uuid")
if uuid == "" {
fileUuid := filepath.Base(ctx.Param("uuid"))
if fileUuid == "" || !uuidRegex.MatchString(fileUuid) {
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), nil)
}
uploadsDir := filepath.Join(config.GetHomeDir(), "uploads")
filePath := filepath.Join(uploadsDir, uuid)
filePath := filepath.Join(config.GetHomeDir(), "uploads", fileUuid)
if _, err := os.Stat(filePath); err == nil {
if err := os.Remove(filePath); err != nil {

View File

@@ -0,0 +1,151 @@
package gist_test
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func createMultipartRequest(t *testing.T, uri, fieldName, fileName, content string) *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(fieldName, fileName)
require.NoError(t, err)
_, err = part.Write([]byte(content))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, uri, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func TestUpload(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("UploadFile", func(t *testing.T) {
s.Login(t, "thomas")
req := createMultipartRequest(t, "/upload", "file", "test.txt", "file content")
resp := s.RawRequest(t, req, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var result map[string]string
err = json.Unmarshal(body, &result)
require.NoError(t, err)
require.Equal(t, "test.txt", result["filename"])
require.NotEmpty(t, result["uuid"])
require.Regexp(t, `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, result["uuid"])
filePath := filepath.Join(config.GetHomeDir(), "uploads", result["uuid"])
data, err := os.ReadFile(filePath)
require.NoError(t, err)
require.Equal(t, "file content", string(data))
})
t.Run("NoFile", func(t *testing.T) {
s.Login(t, "thomas")
req := httptest.NewRequest(http.MethodPost, "/upload", nil)
req.Header.Set("Content-Type", "multipart/form-data; boundary=xxx")
s.RawRequest(t, req, 400)
})
t.Run("NoAuth", func(t *testing.T) {
s.Logout()
req := createMultipartRequest(t, "/upload", "file", "test.txt", "content")
s.RawRequest(t, req, 302)
})
}
func TestDeleteUpload(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("DeleteExistingFile", func(t *testing.T) {
s.Login(t, "thomas")
req := createMultipartRequest(t, "/upload", "file", "todelete.txt", "delete me")
uploadResp := s.RawRequest(t, req, 200)
body, err := io.ReadAll(uploadResp.Body)
require.NoError(t, err)
var uploadResult map[string]string
err = json.Unmarshal(body, &uploadResult)
require.NoError(t, err)
fileUUID := uploadResult["uuid"]
filePath := filepath.Join(config.GetHomeDir(), "uploads", fileUUID)
_, err = os.Stat(filePath)
require.NoError(t, err)
deleteReq := httptest.NewRequest(http.MethodDelete, "/upload/"+fileUUID, nil)
deleteResp := s.RawRequest(t, deleteReq, 200)
deleteBody, err := io.ReadAll(deleteResp.Body)
require.NoError(t, err)
var deleteResult map[string]string
err = json.Unmarshal(deleteBody, &deleteResult)
require.NoError(t, err)
require.Equal(t, "deleted", deleteResult["status"])
_, err = os.Stat(filePath)
require.True(t, os.IsNotExist(err))
})
t.Run("DeleteNonExistentFile", func(t *testing.T) {
s.Login(t, "thomas")
req := httptest.NewRequest(http.MethodDelete, "/upload/00000000-0000-0000-0000-000000000000", nil)
s.RawRequest(t, req, 200)
})
t.Run("InvalidUUID", func(t *testing.T) {
s.Login(t, "thomas")
req := httptest.NewRequest(http.MethodDelete, "/upload/not-a-valid-uuid", nil)
s.RawRequest(t, req, 400)
})
t.Run("PathTraversal", func(t *testing.T) {
s.Login(t, "thomas")
req := httptest.NewRequest(http.MethodDelete, "/upload/../../etc/passwd", nil)
s.RawRequest(t, req, 400)
})
t.Run("NoAuth", func(t *testing.T) {
s.Logout()
req := httptest.NewRequest(http.MethodDelete, "/upload/00000000-0000-0000-0000-000000000000", nil)
s.RawRequest(t, req, 302)
})
}

View File

@@ -0,0 +1,235 @@
package git_test
import (
"net/url"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func gitClone(baseUrl, creds, user, gistId, destDir string) error {
authUrl := baseUrl
if creds != "" {
authUrl = "http://" + creds + "@" + baseUrl[len("http://"):]
}
return exec.Command("git", "clone", authUrl+"/"+user+"/"+gistId+".git", destDir).Run()
}
func gitPush(repoDir, filename, content string) error {
if err := os.WriteFile(filepath.Join(repoDir, filename), []byte(content), 0644); err != nil {
return err
}
if err := exec.Command("git", "-C", repoDir, "add", filename).Run(); err != nil {
return err
}
if err := exec.Command("git", "-C", repoDir, "commit", "-m", "add "+filename).Run(); err != nil {
return err
}
return exec.Command("git", "-C", repoDir, "push", "origin").Run()
}
func TestGitClonePull(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
baseUrl := s.StartHttpServer(t)
s.Register(t, "thomas")
s.Register(t, "alice")
_, _, user, publicId := s.CreateGist(t, "0")
_, _, _, unlistedId := s.CreateGist(t, "1")
_, _, _, privateId := s.CreateGist(t, "2")
type credTest struct {
name string
creds string
expect [3]bool // [public, unlisted, private]
}
tests := []struct {
name string
settings map[string]string
creds []credTest
}{
{
name: "Default",
creds: []credTest{
{"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}},
{"OtherUserAuth", "alice:alice", [3]bool{true, true, false}},
{"WrongPassword", "thomas:wrong", [3]bool{true, true, false}},
{"WrongUser", "aze:aze", [3]bool{true, true, false}},
{"Anonymous", "", [3]bool{true, true, false}},
},
},
{
name: "RequireLogin",
settings: map[string]string{db.SettingRequireLogin: "1"},
creds: []credTest{
{"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}},
{"OtherUserAuth", "alice:alice", [3]bool{true, true, false}},
{"WrongPassword", "thomas:wrong", [3]bool{false, false, false}},
{"WrongUser", "aze:aze", [3]bool{false, false, false}},
{"Anonymous", "", [3]bool{false, false, false}},
},
},
{
name: "AllowGistsWithoutLogin",
settings: map[string]string{db.SettingRequireLogin: "1", db.SettingAllowGistsWithoutLogin: "1"},
creds: []credTest{
{"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}},
{"OtherUserAuth", "alice:alice", [3]bool{true, true, false}},
{"WrongPassword", "thomas:wrong", [3]bool{true, true, false}},
{"WrongUser", "aze:aze", [3]bool{true, true, false}},
{"Anonymous", "", [3]bool{true, true, false}},
},
},
}
gists := [3]string{publicId, unlistedId, privateId}
labels := [3]string{"Public", "Unlisted", "Private"}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas")
for k, v := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
}
for _, ct := range tt.creds {
t.Run(ct.name, func(t *testing.T) {
for i, id := range gists {
t.Run(labels[i], func(t *testing.T) {
dest := t.TempDir()
err := gitClone(baseUrl, ct.creds, user, id, dest)
if ct.expect[i] {
require.NoError(t, err)
_, err = os.Stat(filepath.Join(dest, "file.txt"))
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
})
}
// Reset settings
s.Login(t, "thomas")
for k := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
}
})
}
}
func TestGitPush(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
baseUrl := s.StartHttpServer(t)
s.Register(t, "thomas")
s.Register(t, "alice")
_, _, user, publicId := s.CreateGist(t, "0")
_, _, _, unlistedId := s.CreateGist(t, "1")
_, _, _, privateId := s.CreateGist(t, "2")
type credTest struct {
name string
creds string
expect [3]bool // [public, unlisted, private]
}
tests := []credTest{
{"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}},
{"OtherUserAuth", "alice:alice", [3]bool{false, false, false}},
{"WrongPassword", "thomas:wrong", [3]bool{false, false, false}},
{"WrongUser", "aze:aze", [3]bool{false, false, false}},
{"Anonymous", "", [3]bool{false, false, false}},
}
gists := [3]string{publicId, unlistedId, privateId}
labels := [3]string{"Public", "Unlisted", "Private"}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for i, id := range gists {
t.Run(labels[i], func(t *testing.T) {
dest := t.TempDir()
require.NoError(t, gitClone(baseUrl, "thomas:thomas", user, id, dest))
if tt.creds != "thomas:thomas" {
require.NoError(t, exec.Command("git", "-C", dest, "remote", "set-url", "origin",
"http://"+tt.creds+"@"+baseUrl[len("http://"):]+"/"+user+"/"+id+".git").Run())
}
err := gitPush(dest, "newfile.txt", "new content")
if tt.expect[i] {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
})
}
}
func TestGitCreatePush(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
baseUrl := s.StartHttpServer(t)
s.Register(t, "thomas")
s.Register(t, "alice")
gitInitAndPush := func(t *testing.T, creds, remoteUrl string) error {
dest := t.TempDir()
require.NoError(t, exec.Command("git", "init", "--initial-branch=master", dest).Run())
require.NoError(t, exec.Command("git", "-C", dest, "remote", "add", "origin",
"http://"+creds+"@"+baseUrl[len("http://"):]+remoteUrl).Run())
require.NoError(t, os.WriteFile(filepath.Join(dest, "hello.txt"), []byte("hello"), 0644))
require.NoError(t, exec.Command("git", "-C", dest, "add", "hello.txt").Run())
require.NoError(t, exec.Command("git", "-C", dest, "commit", "-m", "initial").Run())
return exec.Command("git", "-C", dest, "push", "origin").Run()
}
tests := []struct {
name string
creds string
url string
expect bool
gistOwner string // if expect=true, verify gist exists at this owner/identifier
gistId string
}{
{"OwnerCreates", "thomas:thomas", "/thomas/mygist.git", true, "thomas", "mygist"},
{"OtherUserCreatesOnOwnUrl", "alice:alice", "/alice/alicegist.git", true, "alice", "alicegist"},
{"WrongPassword", "thomas:wrong", "/thomas/newgist.git", false, "", ""},
{"OtherUserCannotCreateOnOwner", "alice:alice", "/thomas/hackgist.git", false, "", ""},
{"WrongUser", "aze:aze", "/aze/azegist.git", false, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := gitInitAndPush(t, tt.creds, tt.url)
if tt.expect {
require.NoError(t, err)
gist, err := db.GetGist(tt.gistOwner, tt.gistId)
require.NoError(t, err)
require.NotNil(t, gist)
require.Equal(t, tt.gistId, gist.Identifier())
} else {
require.Error(t, err)
}
})
}
}

View File

@@ -0,0 +1,30 @@
package health_test
import (
"encoding/json"
"io"
"testing"
"github.com/stretchr/testify/require"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestHealthcheck(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
t.Run("OK", func(t *testing.T) {
resp := s.Request(t, "GET", "/healthcheck", nil, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(body, &result)
require.NoError(t, err)
require.Equal(t, "ok", result["opengist"])
require.Equal(t, "ok", result["database"])
require.NotEmpty(t, result["time"])
})
}

View File

@@ -1,16 +1,12 @@
package metrics
import (
"github.com/labstack/echo-contrib/echoprometheus"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
)
var (
// Using promauto to automatically register metrics with the default registry
countUsersGauge prometheus.Gauge
countGistsGauge prometheus.Gauge
countSSHKeysGauge prometheus.Gauge
@@ -18,14 +14,11 @@ var (
metricsInitialized bool = false
)
// initMetrics initializes metrics if they're not already initialized
func initMetrics() {
if metricsInitialized {
return
}
// Only initialize metrics if they're enabled
if config.C.MetricsEnabled {
countUsersGauge = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "opengist_users_total",
@@ -48,54 +41,25 @@ func initMetrics() {
)
metricsInitialized = true
}
}
// updateMetrics refreshes all metric values from the database
func updateMetrics() {
// Only update metrics if they're enabled
if !config.C.MetricsEnabled || !metricsInitialized {
if !metricsInitialized {
return
}
// Update users count
countUsers, err := db.CountAll(&db.User{})
if err == nil {
countUsersGauge.Set(float64(countUsers))
}
// Update gists count
countGists, err := db.CountAll(&db.Gist{})
if err == nil {
countGistsGauge.Set(float64(countGists))
}
// Update SSH keys count
countKeys, err := db.CountAll(&db.SSHKey{})
if err == nil {
countSSHKeysGauge.Set(float64(countKeys))
}
}
// Metrics handles prometheus metrics endpoint requests.
func Metrics(ctx *context.Context) error {
// If metrics are disabled, return 404
if !config.C.MetricsEnabled {
return ctx.NotFound("Metrics endpoint is disabled")
}
// Initialize metrics if not already done
initMetrics()
// Update metrics
updateMetrics()
// Get the Echo context
echoCtx := ctx.Context
// Use the Prometheus metrics handler
handler := echoprometheus.NewHandler()
// Call the handler
return handler(echoCtx)
}

View File

@@ -1,28 +1,26 @@
package test
package metrics_test
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"io"
"net/http"
"os"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
var (
SSHKey = db.SSHKeyDTO{
Title: "Test SSH Key",
Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`,
}
AdminUser = db.UserDTO{
Username: "admin",
Password: "admin",
}
func TestMetrics(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
SimpleGist = db.GistDTO{
s.Register(t, "thomas")
s.Login(t, "thomas")
s.Request(t, "POST", "/", db.GistDTO{
Title: "Simple Test Gist",
Description: "A simple gist for testing",
VisibilityDTO: db.VisibilityDTO{
@@ -31,53 +29,22 @@ var (
Name: []string{"file1.txt"},
Content: []string{"This is the content of file1"},
Topics: "",
}
)
}, 302)
// TestMetrics tests the metrics endpoint functionality of the application.
// It verifies that the metrics endpoint correctly reports counts for:
// - Total number of users
// - Total number of gists
// - Total number of SSH keys
//
// The test follows these steps:
// 1. Enables metrics via environment variable
// 2. Sets up test environment
// 3. Registers and logs in an admin user
// 4. Creates a gist and adds an SSH key
// 5. Queries the metrics endpoint
// 6. Verifies the reported metrics match expected values
//
// Environment variables:
// - OG_METRICS_ENABLED: Set to "true" for this test
func TestMetrics(t *testing.T) {
originalValue := os.Getenv("OG_METRICS_ENABLED")
s.Request(t, "POST", "/settings/ssh-keys", db.SSHKeyDTO{
Title: "Test SSH Key",
Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`,
}, 302)
os.Setenv("OG_METRICS_ENABLED", "true")
metricsServer := webtest.NewTestMetricsServer()
defer os.Setenv("OG_METRICS_ENABLED", originalValue)
req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder()
metricsServer.ServeHTTP(w, req)
s := Setup(t)
defer Teardown(t, s)
require.Equal(t, 200, w.Code)
register(t, s, AdminUser)
login(t, s, AdminUser)
err := s.Request("GET", "/all", nil, 200)
require.NoError(t, err)
err = s.Request("POST", "/", SimpleGist, 302)
require.NoError(t, err)
err = s.Request("POST", "/settings/ssh-keys", SSHKey, 302)
require.NoError(t, err)
var metricsRes http.Response
err = s.Request("GET", "/metrics", nil, 200, &metricsRes)
require.NoError(t, err)
body, err := io.ReadAll(metricsRes.Body)
defer metricsRes.Body.Close()
body, err := io.ReadAll(w.Body)
require.NoError(t, err)
lines := strings.Split(string(body), "\n")

View File

@@ -0,0 +1,50 @@
package metrics
import (
"net/http"
"github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
)
type Server struct {
echo *echo.Echo
}
func NewServer() *Server {
e := echo.New()
e.HideBanner = true
e.HidePort = true
s := &Server{echo: e}
initMetrics()
e.GET("/metrics", func(ctx echo.Context) error {
updateMetrics()
return echoprometheus.NewHandler()(ctx)
})
return s
}
func (s *Server) Start() {
addr := config.C.MetricsHost + ":" + config.C.MetricsPort
log.Info().Msg("Starting metrics server on http://" + addr)
if err := s.echo.Start(addr); err != nil && err != http.ErrServerClosed {
log.Error().Err(err).Msg("Failed to start metrics server")
}
}
func (s *Server) Stop() {
log.Info().Msg("Stopping metrics server...")
if err := s.echo.Close(); err != nil {
log.Error().Err(err).Msg("Failed to stop metrics server")
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.echo.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,75 @@
package settings
import (
"strconv"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context"
)
func AccessTokens(ctx *context.Context) error {
user := ctx.User
tokens, err := db.GetAccessTokensByUserID(user.ID)
if err != nil {
return ctx.ErrorRes(500, "Cannot get access tokens", err)
}
ctx.SetData("accessTokens", tokens)
ctx.SetData("settingsHeaderPage", "tokens")
ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_tokens.html")
}
func AccessTokensProcess(ctx *context.Context) error {
user := ctx.User
dto := new(db.AccessTokenDTO)
if err := ctx.Bind(dto); err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
}
if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
return ctx.RedirectTo("/settings/access-tokens")
}
token := dto.ToAccessToken()
token.UserID = user.ID
plainToken, err := token.GenerateToken()
if err != nil {
return ctx.ErrorRes(500, "Cannot generate token", err)
}
if err := token.Create(); err != nil {
return ctx.ErrorRes(500, "Cannot create access token", err)
}
// Show the token once to the user
ctx.AddFlash(ctx.Tr("settings.token-created"), "success")
ctx.AddFlash(plainToken, "success")
return ctx.RedirectTo("/settings/access-tokens")
}
func AccessTokensDelete(ctx *context.Context) error {
user := ctx.User
tokenID, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
return ctx.RedirectTo("/settings/access-tokens")
}
token, err := db.GetAccessTokenByID(uint(tokenID))
if err != nil || token.UserID != user.ID {
return ctx.RedirectTo("/settings/access-tokens")
}
if err := token.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete access token", err)
}
ctx.AddFlash(ctx.Tr("settings.token-deleted"), "success")
return ctx.RedirectTo("/settings/access-tokens")
}

View File

@@ -0,0 +1,332 @@
package settings_test
import (
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestAccessTokensCRUD(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("RequiresAuth", func(t *testing.T) {
s.Logout()
s.Request(t, "GET", "/settings/access-tokens", nil, 302)
})
t.Run("AccessTokensPage", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "GET", "/settings/access-tokens", nil, 200)
})
t.Run("CreateReadToken", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
Name: "test-token",
ScopeGist: db.ReadPermission,
}, 302)
tokens, err := db.GetAccessTokensByUserID(1)
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, "test-token", tokens[0].Name)
require.Equal(t, uint(db.ReadPermission), tokens[0].ScopeGist)
require.Equal(t, int64(0), tokens[0].ExpiresAt)
})
t.Run("CreateExpiringToken", func(t *testing.T) {
s.Login(t, "thomas")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
Name: "expiring-token",
ScopeGist: db.ReadWritePermission,
ExpiresAt: tomorrow,
}, 302)
tokens, err := db.GetAccessTokensByUserID(1)
require.NoError(t, err)
require.Len(t, tokens, 2)
})
t.Run("DeleteToken", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "DELETE", "/settings/access-tokens/1", nil, 302)
tokens, err := db.GetAccessTokensByUserID(1)
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, "expiring-token", tokens[0].Name)
})
}
func TestAccessTokenPrivateGistAccess(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user, identifier := s.CreateGist(t, "2")
// Create access token with read permission
token := &db.AccessToken{
Name: "read-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
plainToken, err := token.GenerateToken()
require.NoError(t, err)
require.NoError(t, token.Create())
s.Logout()
headers := map[string]string{"Authorization": "Token " + plainToken}
t.Run("NoTokenReturns404", func(t *testing.T) {
s.Request(t, "GET", "/"+user+"/"+identifier, nil, 404)
})
t.Run("ValidTokenGrantsAccess", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, headers)
})
t.Run("RawContentAccessible", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier+"/raw/HEAD/file.txt", nil, 200, headers)
})
t.Run("JSONEndpointAccessible", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier+".json", nil, 200, headers)
})
t.Run("InvalidTokenReturns404", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{
"Authorization": "Token invalid_token",
})
})
}
func TestAccessTokenPermissions(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user, identifier := s.CreateGist(t, "2")
// Create token with NO permission
noPermToken := &db.AccessToken{
Name: "no-perm-token",
UserID: 1,
ScopeGist: db.NoPermission,
}
noPermPlain, err := noPermToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, noPermToken.Create())
// Create token with READ permission
readToken := &db.AccessToken{
Name: "read-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
readPlain, err := readToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, readToken.Create())
s.Logout()
t.Run("NoPermissionDenied", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{
"Authorization": "Token " + noPermPlain,
})
})
t.Run("ReadPermissionGranted", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{
"Authorization": "Token " + readPlain,
})
})
}
func TestAccessTokenExpiration(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user, identifier := s.CreateGist(t, "2")
// Create an expired token
expiredToken := &db.AccessToken{
Name: "expired-token",
UserID: 1,
ScopeGist: db.ReadPermission,
ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(),
}
expiredPlain, err := expiredToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, expiredToken.Create())
// Create a valid token
validToken := &db.AccessToken{
Name: "valid-token",
UserID: 1,
ScopeGist: db.ReadPermission,
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
}
validPlain, err := validToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, validToken.Create())
s.Logout()
t.Run("ExpiredTokenDenied", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{
"Authorization": "Token " + expiredPlain,
})
})
t.Run("ValidTokenGranted", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{
"Authorization": "Token " + validPlain,
})
})
}
func TestAccessTokenWrongUser(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "kaguya")
_, _, user, identifier := s.CreateGist(t, "2")
// Create token for kaguya
kaguyaToken := &db.AccessToken{
Name: "kaguya-token",
UserID: 2,
ScopeGist: db.ReadPermission,
}
kaguyaPlain, err := kaguyaToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, kaguyaToken.Create())
// Create token for thomas
thomasToken := &db.AccessToken{
Name: "thomas-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
thomasPlain, err := thomasToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, thomasToken.Create())
s.Logout()
t.Run("OtherUserTokenDenied", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{
"Authorization": "Token " + kaguyaPlain,
})
})
t.Run("OwnerTokenGranted", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{
"Authorization": "Token " + thomasPlain,
})
})
}
func TestAccessTokenLastUsedUpdate(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user, identifier := s.CreateGist(t, "2")
token := &db.AccessToken{
Name: "test-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
plainToken, err := token.GenerateToken()
require.NoError(t, err)
require.NoError(t, token.Create())
// Verify LastUsedAt is 0 initially
tokenFromDB, err := db.GetAccessTokenByID(token.ID)
require.NoError(t, err)
require.Equal(t, int64(0), tokenFromDB.LastUsedAt)
s.Logout()
// Use the token
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{
"Authorization": "Token " + plainToken,
})
// Verify LastUsedAt was updated
tokenFromDB, err = db.GetAccessTokenByID(token.ID)
require.NoError(t, err)
require.NotEqual(t, int64(0), tokenFromDB.LastUsedAt)
}
func TestAccessTokenWithRequireLogin(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user1, identifier1 := s.CreateGist(t, "2")
s.Login(t, "thomas")
_, _, user2, identifier2 := s.CreateGist(t, "0")
s.Login(t, "thomas")
token := &db.AccessToken{
Name: "read-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
plainToken, err := token.GenerateToken()
require.NoError(t, err)
require.NoError(t, token.Create())
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingRequireLogin}, "value": {"1"}}, 200)
s.Logout()
headers := map[string]string{"Authorization": "Token " + plainToken}
t.Run("UnauthenticatedRedirects", func(t *testing.T) {
s.Request(t, "GET", "/"+user1+"/"+identifier1, nil, 302)
s.Request(t, "GET", "/"+user2+"/"+identifier2, nil, 302)
})
t.Run("ValidTokenGrantsAccess", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1, nil, 200, headers)
s.RequestWithHeaders(t, "GET", "/"+user2+"/"+identifier2, nil, 200, headers)
s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1+"/raw/HEAD/file.txt", nil, 200, headers)
})
t.Run("InvalidTokenRedirects", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1, nil, 302, map[string]string{
"Authorization": "Token invalid_token",
})
})
t.Run("NoPermTokenRedirects", func(t *testing.T) {
noPermToken := &db.AccessToken{
Name: "no-perm-token",
UserID: 1,
ScopeGist: db.NoPermission,
}
noPermPlain, err := noPermToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, noPermToken.Create())
s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1, nil, 302, map[string]string{
"Authorization": "Token " + noPermPlain,
})
})
}

View File

@@ -3,16 +3,17 @@ package settings
import (
"crypto/md5"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context"
"os"
"path/filepath"
"strings"
"time"
)
func EmailProcess(ctx *context.Context) error {
@@ -61,20 +62,24 @@ func UsernameProcess(ctx *context.Context) error {
return ctx.RedirectTo("/settings")
}
if !strings.EqualFold(dto.Username, user.Username) {
if exists, err := db.UserExists(dto.Username); err != nil || exists {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
return ctx.RedirectTo("/settings")
}
}
sourceDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(user.Username))
destinationDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(dto.Username))
if sourceDir != destinationDir {
if _, err := os.Stat(sourceDir); !os.IsNotExist(err) {
err := os.Rename(sourceDir, destinationDir)
if err != nil {
return ctx.ErrorRes(500, "Cannot rename user directory", err)
}
}
}
user.Username = dto.Username

View File

@@ -8,7 +8,7 @@ import (
"strings"
"github.com/gorilla/schema"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/web/context"
)
@@ -119,10 +119,16 @@ func Paginate[T any](ctx *context.Context, data []*T, pageInt int, perPage int,
return nil
}
func ParseSearchQueryStr(query string) (string, map[string]string) {
// ParseSearchQueryStr parses a search query string and returns a map of metadata.
// The query string is split into words and each word is checked if it contains a colon (:).
// If a word contains a colon, it is split into a key-value pair and added to the metadata map.
// If a word does not contain a colon, it is added to an "all" key in the metadata map.
// The "all" key is used to search all fields in the index.
// The function returns the metadata map.
func ParseSearchQueryStr(query string) map[string]string {
words := strings.Fields(query)
metadata := make(map[string]string)
var contentBuilder strings.Builder
var allFieldsBuilder strings.Builder
for _, word := range words {
if strings.Contains(word, ":") {
@@ -133,10 +139,18 @@ func ParseSearchQueryStr(query string) (string, map[string]string) {
metadata[key] = value
}
} else {
contentBuilder.WriteString(word + " ")
// Add to content search by default
allFieldsBuilder.WriteString(word + " ")
}
}
content := strings.TrimSpace(contentBuilder.String())
return content, metadata
// Set the default search field
allContent := strings.TrimSpace(allFieldsBuilder.String())
if allContent != "" {
metadata["default"] = allContent
}
log.Debug().Msgf("Metadata: %v", metadata)
return metadata
}

View File

@@ -28,7 +28,7 @@ import (
func (s *Server) useCustomContext() {
s.echo.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
cc := context.NewContext(c, s.sessionsPath)
cc := context.NewContext(c, filepath.Join(config.GetHomeDir(), "sessions"))
return next(cc)
}
})
@@ -38,8 +38,7 @@ func (s *Server) registerMiddlewares() {
s.echo.Use(Middleware(dataInit).toEcho())
s.echo.Use(Middleware(locale).toEcho())
if config.C.MetricsEnabled {
p := echoprometheus.NewMiddleware("opengist")
s.echo.Use(p)
s.echo.Use(echoprometheus.NewMiddleware("opengist"))
}
s.echo.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{
@@ -59,8 +58,6 @@ func (s *Server) registerMiddlewares() {
s.echo.Use(middleware.Recover())
s.echo.Use(middleware.Secure())
s.echo.Use(Middleware(sessionInit).toEcho())
if !s.ignoreCsrf {
s.echo.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "form:_csrf,header:X-CSRF-Token",
CookiePath: "/",
@@ -73,7 +70,7 @@ func (s *Server) registerMiddlewares() {
/* skip CSRF for git clients */
matchUploadPack, _ := regexp.MatchString("(.*?)/git-upload-pack$", ctx.Request().URL.Path)
matchReceivePack, _ := regexp.MatchString("(.*?)/git-receive-pack$", ctx.Request().URL.Path)
return filepath.Ext(gistName) == ".js" || matchUploadPack || matchReceivePack
return (filepath.Ext(gistName) == ".js" && ctx.Request().Method == "GET") || matchUploadPack || matchReceivePack
},
ErrorHandler: func(err error, c echo.Context) error {
log.Info().Err(err).Msg("CSRF error")
@@ -81,7 +78,7 @@ func (s *Server) registerMiddlewares() {
},
}))
s.echo.Use(Middleware(csrfInit).toEcho())
}
}
func (s *Server) errorHandler(err error, ctx echo.Context) {
@@ -160,10 +157,10 @@ func dataInit(next Handler) Handler {
func writePermission(next Handler) Handler {
return func(ctx *context.Context) error {
gist := ctx.GetData("gist")
gist := ctx.GetData("gist").(*db.Gist)
user := ctx.User
if !gist.(*db.Gist).CanWrite(user) {
return ctx.RedirectTo("/" + gist.(*db.Gist).User.Username + "/" + gist.(*db.Gist).Identifier())
if !gist.CanWrite(user) {
return ctx.ErrorRes(403, "You don't have permission to edit this gist", nil)
}
return next(ctx)
}
@@ -200,6 +197,17 @@ func inMFASession(next Handler) Handler {
}
}
func inOAuthRegisterSession(next Handler) Handler {
return func(ctx *context.Context) error {
sess := ctx.GetSession()
_, ok := sess.Values["oauthProvider"].(string)
if !ok {
return ctx.RedirectTo("/login")
}
return next(ctx)
}
}
func makeCheckRequireLogin(isSingleGistAccess bool) Middleware {
return func(next Handler) Handler {
return func(ctx *context.Context) error {
@@ -207,6 +215,9 @@ func makeCheckRequireLogin(isSingleGistAccess bool) Middleware {
return next(ctx)
}
if getUserByToken(ctx) != nil {
return next(ctx)
}
allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, isSingleGistAccess)
if err != nil {
log.Fatal().Err(err).Msg("Failed to check if unauthenticated access is allowed")
@@ -307,7 +318,6 @@ func csrfInit(next Handler) Handler {
csrf = csrfToken
}
ctx.SetData("csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
ctx.SetData("csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
return next(ctx)
}
@@ -327,6 +337,39 @@ func loadSettings(ctx *context.Context) error {
return nil
}
// getUserByToken checks the Authorization header for token-based auth.
// Expects format: Authorization: Token <token>
// Returns the user if the token is valid and has gist read permission, nil otherwise.
func getUserByToken(ctx *context.Context) *db.User {
authHeader := ctx.Request().Header.Get("Authorization")
if authHeader == "" {
return nil
}
if !strings.HasPrefix(authHeader, "Token ") {
return nil
}
plainToken := strings.TrimPrefix(authHeader, "Token ")
accessToken, err := db.GetAccessTokenByToken(plainToken)
if err != nil {
return nil
}
if accessToken.IsExpired() {
return nil
}
if !accessToken.HasGistReadPermission() {
return nil
}
_ = accessToken.UpdateLastUsed()
return &accessToken.User
}
func gistInit(next Handler) Handler {
return func(ctx *context.Context) error {
currUser := ctx.User
@@ -353,9 +396,14 @@ func gistInit(next Handler) Handler {
if gist.Private == db.PrivateVisibility {
if currUser == nil || currUser.ID != gist.UserID {
// Check for token-based auth via Authorization header
if tokenUser := getUserByToken(ctx); tokenUser != nil && tokenUser.ID == gist.UserID {
// Token is valid and belongs to gist owner, allow access
} else {
return ctx.NotFound("Gist not found")
}
}
}
ctx.SetData("gist", gist)

View File

@@ -147,7 +147,10 @@ func (s *Server) setFuncMap() {
return dict, nil
},
"addMetadataToSearchQuery": func(input, key, value string) string {
content, metadata := handlers.ParseSearchQueryStr(input)
metadata := handlers.ParseSearchQueryStr(input)
// extract free-text content (stored under "all") and remove it from metadata
content := metadata["all"]
delete(metadata, "all")
metadata[key] = value
@@ -192,6 +195,9 @@ func (s *Server) setFuncMap() {
"humanDate": func(t int64) string {
return time.Unix(t, 0).Format("02/01/2006 15:04")
},
"humanDateOnly": func(t int64) string {
return time.Unix(t, 0).Format("02/01/2006")
},
"mainTheme": func(theme *db.UserStyleDTO) string {
if theme == nil {
return "auto"

View File

@@ -17,7 +17,6 @@ import (
"github.com/thomiceli/opengist/internal/web/handlers/gist"
"github.com/thomiceli/opengist/internal/web/handlers/git"
"github.com/thomiceli/opengist/internal/web/handlers/health"
"github.com/thomiceli/opengist/internal/web/handlers/metrics"
"github.com/thomiceli/opengist/internal/web/handlers/settings"
"github.com/thomiceli/opengist/public"
)
@@ -34,15 +33,13 @@ func (s *Server) registerRoutes() {
r.GET("/healthcheck", health.Healthcheck)
if config.C.MetricsEnabled {
r.GET("/metrics", metrics.Metrics)
}
r.GET("/register", auth.Register)
r.POST("/register", auth.ProcessRegister)
r.GET("/login", auth.Login)
r.POST("/login", auth.ProcessLogin)
r.GET("/logout", auth.Logout)
r.GET("/oauth/register", auth.OauthRegister, inOAuthRegisterSession)
r.POST("/oauth/register", auth.ProcessOauthRegister, inOAuthRegisterSession)
r.GET("/oauth/:provider", auth.Oauth)
r.GET("/oauth/:provider/callback", auth.OauthCallback)
r.GET("/oauth/:provider/unlink", auth.OauthUnlink, logged)
@@ -67,6 +64,9 @@ func (s *Server) registerRoutes() {
sA.DELETE("/account", settings.AccountDeleteProcess)
sA.POST("/ssh-keys", settings.SshKeysProcess)
sA.DELETE("/ssh-keys/:id", settings.SshKeysDelete)
sA.GET("/access-tokens", settings.AccessTokens)
sA.POST("/access-tokens", settings.AccessTokensProcess)
sA.DELETE("/access-tokens/:id", settings.AccessTokensDelete)
sA.DELETE("/passkeys/:id", settings.PasskeyDelete)
sA.PUT("/password", settings.PasswordProcess)
sA.PUT("/username", settings.UsernameProcess)

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