Compare commits

..

49 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
98 changed files with 5621 additions and 4932 deletions

View File

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

View File

@@ -83,6 +83,18 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -94,13 +106,15 @@ jobs:
- name: Run tests - name: Run tests
run: make test TEST_DB_TYPE=${{ matrix.database }} run: make test TEST_DB_TYPE=${{ matrix.database }}
env:
OG_TEST_MEILI_HOST: http://localhost:47700
test: test:
name: Test name: Test
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: ["ubuntu-latest", "macOS-latest", "windows-latest"] os: ["ubuntu-latest", "macOS-latest"]
go: ["1.25"] go: ["1.25"]
database: ["sqlite"] database: ["sqlite"]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}

View File

@@ -36,10 +36,10 @@ jobs:
- name: Push to docs repository - name: Push to docs repository
run: | run: |
git clone https://${{ secrets.DOCS_REPO_TOKEN }}@github.com/${{ secrets.DOCS_REPO }}.git target-repo git clone https://${{ secrets.STATIC_REPO_TOKEN }}@github.com/${{ secrets.STATIC_REPO }}.git target-repo
mkdir -p target-repo/helm mkdir -p target-repo/helm
cp helm/*.tgz target-repo/helm/ cp helm/*.tgz target-repo/srv/helm/
cp helm/index.yaml target-repo/helm/ cp helm/index.yaml target-repo/srv/helm/
cd target-repo cd target-repo
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"

View File

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

View File

@@ -1,5 +1,28 @@
# Changelog # 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 ## [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. See here how to [update](https://opengist.io/docs/update) Opengist.

View File

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

View File

@@ -32,6 +32,10 @@ index.meili.host:
# Set the API key for the Meiliseach server # Set the API key for the Meiliseach server
index.meili.api-key: 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. # 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 # 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: git.default-branch:

View File

@@ -85,7 +85,7 @@ export default defineConfig({
{icon: 'github', link: 'https://github.com/thomiceli/opengist'} {icon: 'github', link: 'https://github.com/thomiceli/opengist'}
], ],
editLink: { 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 // @ts-ignore
lastUpdated: true, lastUpdated: true,

View File

@@ -15,6 +15,7 @@ aside: false
| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). | | 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.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. | | 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) | | 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) | | 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.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) |
@@ -43,6 +44,8 @@ aside: false
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. | | 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.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.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.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-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. | | 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 ./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 ## Configuration via Environment Variables

View File

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

View File

@@ -10,7 +10,7 @@ Requirements:
git clone https://github.com/thomiceli/opengist git clone https://github.com/thomiceli/opengist
cd opengist cd opengist
git checkout v1.12.1 # optional, to checkout the latest release git checkout v1.12.2 # optional, to checkout the latest release
make make
./opengist ./opengist

View File

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

97
go.mod
View File

@@ -7,30 +7,30 @@ require (
github.com/alecthomas/chroma/v2 v2.23.1 github.com/alecthomas/chroma/v2 v2.23.1
github.com/blevesearch/bleve/v2 v2.5.7 github.com/blevesearch/bleve/v2 v2.5.7
github.com/dustin/go-humanize v1.0.1 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/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.15.0 github.com/go-webauthn/webauthn v0.17.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.4.1 github.com/gorilla/schema v1.4.1
github.com/gorilla/securecookie v1.1.2 github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
github.com/labstack/echo-contrib v0.17.4 github.com/labstack/echo-contrib v0.50.1
github.com/labstack/echo/v4 v4.15.0 github.com/labstack/echo/v4 v4.15.1
github.com/markbates/goth v1.82.0 github.com/markbates/goth v1.82.0
github.com/meilisearch/meilisearch-go v0.36.0 github.com/meilisearch/meilisearch-go v0.36.2
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2 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/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7 github.com/urfave/cli/v2 v2.27.7
github.com/yuin/goldmark v1.7.16 github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-emoji v1.0.6 github.com/yuin/goldmark-emoji v1.0.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/mermaid v0.6.0 go.abhg.dev/goldmark/mermaid v0.6.0
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.50.0
golang.org/x/text v0.33.0 golang.org/x/text v0.36.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
@@ -38,88 +38,89 @@ require (
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect github.com/RoaringBitmap/roaring/v2 v2.18.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/blevesearch/bleve_index_api v1.3.1 // indirect github.com/blevesearch/bleve_index_api v1.3.11 // indirect
github.com/blevesearch/geo v0.2.4 // indirect github.com/blevesearch/geo v0.2.5 // indirect
github.com/blevesearch/go-faiss v1.0.27 // indirect github.com/blevesearch/go-faiss v1.0.35 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.2.0 // indirect github.com/blevesearch/mmap-go v1.2.0 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.4.1 // indirect github.com/blevesearch/scorch_segment_api/v2 v2.4.7 // indirect
github.com/blevesearch/segment v0.9.1 // indirect github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.2.0 // indirect github.com/blevesearch/vellum v1.2.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.2 // indirect github.com/blevesearch/zapx/v11 v11.4.3 // indirect
github.com/blevesearch/zapx/v12 v12.4.2 // indirect github.com/blevesearch/zapx/v12 v12.4.3 // indirect
github.com/blevesearch/zapx/v13 v13.4.2 // indirect github.com/blevesearch/zapx/v13 v13.4.3 // indirect
github.com/blevesearch/zapx/v14 v14.4.2 // indirect github.com/blevesearch/zapx/v14 v14.4.3 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect github.com/blevesearch/zapx/v15 v15.4.3 // indirect
github.com/blevesearch/zapx/v16 v16.3.0 // indirect github.com/blevesearch/zapx/v16 v16.3.4 // indirect
github.com/boombuler/barcode v1.1.0 // indirect github.com/boombuler/barcode v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // 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-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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect github.com/go-webauthn/x v0.2.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/snappy v1.0.0 // 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/gorilla/mux v1.8.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/kr/text v0.2.0 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.19 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // 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/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // 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/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.etcd.io/bbolt v1.4.3 // indirect go.etcd.io/bbolt v1.4.3 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.67.7 // indirect modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect modernc.org/sqlite v1.50.0 // indirect
) )

236
go.sum
View File

@@ -1,11 +1,11 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= 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 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0=
github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc= github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc=
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw= github.com/RoaringBitmap/roaring/v2 v2.18.0 h1:h7sS0VqCkfBMGgcHaudJFB4FE6Td71H6svRB2poRnGY=
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY= 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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 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.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
@@ -16,28 +16,28 @@ 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/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 h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 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.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= 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/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 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.3.1 h1:LdH3CQgBbIZ5UI/5Pykz87e0jfeQtVnrdZ2WUBrHHwU= github.com/blevesearch/bleve_index_api v1.3.11 h1:x29vbV8OjWfLcrDVd7Lr1q+BkLNS0JWNEig0MCVnKH4=
github.com/blevesearch/bleve_index_api v1.3.1/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= github.com/blevesearch/bleve_index_api v1.3.11/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE=
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4=
github.com/blevesearch/go-faiss v1.0.27 h1:7cBImYDDQ82WJd5RUZ1ie6zXztCsC73W94ZzwOjkatk= github.com/blevesearch/go-faiss v1.0.35 h1:DJF8yKgH8/n+Oo4E047Jy4oDeRivOugfMb6Cih//JjI=
github.com/blevesearch/go-faiss v1.0.27/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= 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 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= 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 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI= 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/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
github.com/blevesearch/scorch_segment_api/v2 v2.4.1 h1:os52/JeCSLZ0YUkOuLk/Z7pu0SKUMofDPUg+VnbrRD0= github.com/blevesearch/scorch_segment_api/v2 v2.4.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw=
github.com/blevesearch/scorch_segment_api/v2 v2.4.1/go.mod h1:zvilBm4BNfbnTRLW7KgCTNgk2R31JaWzwRc2BEcD7Is= 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 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= 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 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
@@ -46,18 +46,18 @@ github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMG
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0= github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k= github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY=
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo=
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE=
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM=
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU=
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.3.0 h1:hF6VlN15E9CB40RMPyqOIhlDw1OOo9RItumhKMQktxw= github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0=
github.com/blevesearch/zapx/v16 v16.3.0/go.mod h1:zCFjv7McXWm1C8rROL+3mUoD5WYe2RKsZP3ufqcYpLY= 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.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 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -69,11 +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/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 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 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/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -82,57 +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/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.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.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.1/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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= 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 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= 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 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-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.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
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/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 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 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.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= 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 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY= github.com/go-webauthn/webauthn v0.17.0 h1:8tFdaByIF7EgAg0W849Wt5q+213f1drsV2ggC0t80wM=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A= github.com/go-webauthn/webauthn v0.17.0/go.mod h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs= github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg= 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 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 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 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 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 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 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/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 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.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 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -158,8 +156,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= 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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -188,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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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.50.1 h1:W9cZZ9viA4TDdFtm8cuA+XGFwOcnfbjJpl7VgfsRLHE=
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= github.com/labstack/echo-contrib v0.50.1/go.mod h1:8r/++U/Fw/QniApFnzunLanKaviPfBX7fX7/2QX0qOk=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24= github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk= 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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/meilisearch/meilisearch-go v0.36.2 h1:MYaMPCpdLh2aYPt+zK+19mLoA4dfBY3S1L7T0FADCjU=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/meilisearch/meilisearch-go v0.36.2/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
github.com/meilisearch/meilisearch-go v0.36.0 h1:N1etykTektXt5KPcSbhBO0d5Xx5NaKj4pJWEM7WA5dI=
github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6Vk9IivcwKocWRuW7W78=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -220,7 +215,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 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/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -231,15 +227,14 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= 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/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 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.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -247,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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= 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= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -260,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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 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.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 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= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
@@ -274,33 +271,28 @@ 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/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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -315,30 +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/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 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI= modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,5 +1,10 @@
# Helm Chart Changelog # 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 ## 0.6.0 - 2026-02-03
- Bump Opengist image to 1.12.1 - Bump Opengist image to 1.12.1

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# Opengist Helm Chart # Opengist Helm Chart
![Version: 0.6.0](https://img.shields.io/badge/Version-0.6.0-informational?style=flat-square) ![AppVersion: 1.12.1](https://img.shields.io/badge/AppVersion-1.12.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. Check [CHANGELOG.md](CHANGELOG.md) for release notes. Opengist Helm chart for Kubernetes. Check [CHANGELOG.md](CHANGELOG.md) for release notes.

View File

@@ -63,6 +63,19 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }} {{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }} 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: ports:
- name: http - name: http
containerPort: {{ .Values.service.http.port }} containerPort: {{ .Values.service.http.port }}

View File

@@ -25,7 +25,7 @@
{{- $user := default "" .Values.postgresql.global.postgresql.auth.username }} {{- $user := default "" .Values.postgresql.global.postgresql.auth.username }}
{{- $pass := default "" .Values.postgresql.global.postgresql.auth.password }} {{- $pass := default "" .Values.postgresql.global.postgresql.auth.password }}
{{- $db := default "" .Values.postgresql.global.postgresql.auth.database }} {{- $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 "") }} {{- 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" }} {{- fail "postgresql.enabled=true requires username/password/database (postgresql.global.postgresql.auth.*) or set config.db-uri manually" }}
{{- end }} {{- end }}

View File

@@ -84,7 +84,7 @@ spec:
serviceName: {{ include "opengist.fullname" . }}-http serviceName: {{ include "opengist.fullname" . }}-http
podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }} podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }}
updateStrategy: updateStrategy:
{{- toYaml .Values.statefulSet.updateStrategy | nindent 2 }} {{- toYaml .Values.statefulSet.updateStrategy | nindent 4 }}
selector: selector:
matchLabels: matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }} {{- include "opengist.selectorLabels" . | nindent 6 }}
@@ -131,6 +131,19 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }} {{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }} 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: ports:
- name: http - name: http
containerPort: {{ .Values.service.http.port }} containerPort: {{ .Values.service.http.port }}

View File

@@ -18,7 +18,7 @@ configExistingSecret: ""
image: image:
repository: ghcr.io/thomiceli/opengist repository: ghcr.io/thomiceli/opengist
pullPolicy: Always pullPolicy: Always
tag: "1.12.1" tag: "1.12.2"
digest: "" digest: ""
imagePullSecrets: [] imagePullSecrets: []
# - name: "image-pull-secret" # - name: "image-pull-secret"
@@ -258,6 +258,13 @@ autoscaling:
## Additional deployment configuration ## Additional deployment configuration
deployment: deployment:
env: [] 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 terminationGracePeriodSeconds: 60
labels: {} labels: {}
annotations: {} annotations: {}

View File

@@ -150,7 +150,12 @@ func resetHooks() {
} }
func indexGists() { 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() gists, err := db.GetAllGistsRows()
if err != nil { if err != nil {
log.Error().Err(err).Msg("Cannot get gists") log.Error().Err(err).Msg("Cannot get gists")

View File

@@ -2,16 +2,15 @@ package oauth
import ( import (
gocontext "context" gocontext "context"
gojson "encoding/json"
"net/http"
"github.com/markbates/goth" "github.com/markbates/goth"
"github.com/markbates/goth/gothic" "github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/gitea" "github.com/markbates/goth/providers/gitea"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"io"
"net/http"
) )
type GiteaProvider struct { type GiteaProvider struct {
@@ -80,34 +79,11 @@ func (p *GiteaCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
func (p *GiteaCallbackProvider) UpdateUserDB(user *db.User) { func (p *GiteaCallbackProvider) UpdateUserDB(user *db.User) {
user.GiteaID = p.User.UserID user.GiteaID = p.User.UserID
user.AvatarURL = p.User.AvatarURL
}
resp, err := http.Get(urlJoin(config.C.GiteaUrl, "/api/v1/users/", p.User.UserID)) func (p *GiteaCallbackProvider) IsAdmin() bool {
if err != nil { return false
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 NewGiteaCallbackProvider(user *goth.User) CallbackProvider { 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" 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 { func NewGitHubCallbackProvider(user *goth.User) CallbackProvider {
return &GitHubCallbackProvider{ return &GitHubCallbackProvider{
User: user, User: user,

View File

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

View File

@@ -3,6 +3,8 @@ package oauth
import ( import (
gocontext "context" gocontext "context"
"errors" "errors"
"slices"
"github.com/markbates/goth" "github.com/markbates/goth"
"github.com/markbates/goth/gothic" "github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/openidConnect" "github.com/markbates/goth/providers/openidConnect"
@@ -79,6 +81,31 @@ func (p *OIDCCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = p.User.AvatarURL 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 { func NewOIDCCallbackProvider(user *goth.User) CallbackProvider {
return &OIDCCallbackProvider{ return &OIDCCallbackProvider{
User: user, User: user,

View File

@@ -2,15 +2,16 @@ package oauth
import ( import (
"fmt" "fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/markbates/goth" "github.com/markbates/goth"
"github.com/markbates/goth/gothic" "github.com/markbates/goth/gothic"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"io"
"net/http"
"net/url"
"strings"
) )
const ( const (
@@ -32,6 +33,7 @@ type CallbackProvider interface {
GetProviderUserID(user *db.User) bool GetProviderUserID(user *db.User) bool
GetProviderUserSSHKeys() ([]string, error) GetProviderUserSSHKeys() ([]string, error)
UpdateUserDB(user *db.User) UpdateUserDB(user *db.User)
IsAdmin() bool
} }
func DefineProvider(provider string, url string) (Provider, error) { 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) 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 { func urlJoin(base string, elem ...string) string {
joined, err := url.JoinPath(base, elem...) joined, err := url.JoinPath(base, elem...)
if err != nil { if err != nil {

View File

@@ -2,6 +2,12 @@ package cli
import ( import (
"fmt" "fmt"
"os"
"os/signal"
"path"
"path/filepath"
"syscall"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/webauthn" "github.com/thomiceli/opengist/internal/auth/webauthn"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
@@ -12,11 +18,6 @@ import (
"github.com/thomiceli/opengist/internal/web/handlers/metrics" "github.com/thomiceli/opengist/internal/web/handlers/metrics"
"github.com/thomiceli/opengist/internal/web/server" "github.com/thomiceli/opengist/internal/web/server"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"os"
"os/signal"
"path"
"path/filepath"
"syscall"
) )
var CmdVersion = cli.Command{ var CmdVersion = cli.Command{
@@ -37,7 +38,7 @@ var CmdStart = cli.Command{
Initialize(ctx) Initialize(ctx)
httpServer := server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false) httpServer := server.NewServer(os.Getenv("OG_DEV") == "1")
go httpServer.Start() go httpServer.Start()
go ssh.Start() go ssh.Start()

View File

@@ -2,7 +2,6 @@ package config
import ( import (
"fmt" "fmt"
"github.com/thomiceli/opengist/internal/session"
"io" "io"
"net/url" "net/url"
"os" "os"
@@ -13,6 +12,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/thomiceli/opengist/internal/session"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -37,11 +38,12 @@ type config struct {
DBUri string `yaml:"db-uri" env:"OG_DB_URI"` DBUri string `yaml:"db-uri" env:"OG_DB_URI"`
DBFilename string `yaml:"db-filename" env:"OG_DB_FILENAME"` // deprecated DBFilename string `yaml:"db-filename" env:"OG_DB_FILENAME"` // deprecated
IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"` // deprecated IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"` // deprecated
Index string `yaml:"index" env:"OG_INDEX"` Index string `yaml:"index" env:"OG_INDEX"`
BleveDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` // deprecated BleveDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` // deprecated
MeiliHost string `yaml:"index.meili.host" env:"OG_MEILI_HOST"` MeiliHost string `yaml:"index.meili.host" env:"OG_MEILI_HOST"`
MeiliAPIKey string `yaml:"index.meili.api-key" env:"OG_MEILI_API_KEY"` 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"` GitDefaultBranch string `yaml:"git.default-branch" env:"OG_GIT_DEFAULT_BRANCH"`
@@ -110,6 +112,7 @@ func configWithDefaults() (*config, error) {
c.OpengistHome = "" c.OpengistHome = ""
c.DBUri = "opengist.db" c.DBUri = "opengist.db"
c.Index = "bleve" c.Index = "bleve"
c.SearchDefault = "content"
c.SqliteJournalMode = "WAL" c.SqliteJournalMode = "WAL"

View File

@@ -71,6 +71,7 @@ type Gist struct {
Uuid string Uuid string
Title string Title string
URL string URL string
URLNormalized string
Preview string Preview string
PreviewFilename string PreviewFilename string
PreviewMimeType string PreviewMimeType string
@@ -98,6 +99,11 @@ type Like struct {
CreatedAt int64 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 { func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
// Decrement fork counter if the gist was forked // Decrement fork counter if the gist was forked
err := tx.Model(&Gist{}). err := tx.Model(&Gist{}).
@@ -110,7 +116,8 @@ func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
func GetGist(user string, gistUuid string) (*Gist, error) { func GetGist(user string, gistUuid string) (*Gist, error) {
gist := new(Gist) gist := new(Gist)
err := db.Preload("User").Preload("Forked.User").Preload("Topics"). 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"). Joins("join users on gists.user_id = users.id").
First(&gist).Error First(&gist).Error
@@ -720,13 +727,17 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
// -- DTO -- // // -- DTO -- //
type GistDTO struct { type GistDTO struct {
Title string `validate:"max=250" form:"title"` Title string `validate:"max=250" form:"title"`
Description string `validate:"max=1000" form:"description"` Description string `validate:"max=1000" form:"description"`
URL string `validate:"max=32,alphanumdashorempty" form:"url"` URL string `validate:"max=32,alphanumdashorempty" form:"url"`
Files []FileDTO `validate:"min=1,dive"` Files []FileDTO `validate:"min=1,dive"`
Name []string `form:"name"` Name []string `form:"name"`
Content []string `form:"content"` Content []string `form:"content"`
Topics string `validate:"gisttopics" form:"topics"` 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 VisibilityDTO
} }
@@ -806,18 +817,19 @@ func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
} }
indexedGist := &index.Gist{ indexedGist := &index.Gist{
GistID: gist.ID, GistID: gist.ID,
UserID: gist.UserID, UserID: gist.UserID,
Visibility: gist.Private.Uint(), Visibility: gist.Private.Uint(),
Username: gist.User.Username, Username: gist.User.Username,
Title: gist.Title, Description: gist.Description,
Content: wholeContent, Title: gist.Title,
Filenames: fileNames, Content: wholeContent,
Extensions: exts, Filenames: fileNames,
Languages: langs, Extensions: exts,
Topics: topics, Languages: langs,
CreatedAt: gist.CreatedAt, Topics: topics,
UpdatedAt: gist.UpdatedAt, CreatedAt: gist.CreatedAt,
UpdatedAt: gist.UpdatedAt,
} }
return indexedGist, nil return indexedGist, nil

View File

@@ -2,7 +2,9 @@ package db
import ( import (
"fmt" "fmt"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gorm.io/gorm"
) )
type MigrationVersion struct { type MigrationVersion struct {
@@ -12,60 +14,74 @@ type MigrationVersion struct {
func applyMigrations(dbInfo *databaseInfo) error { func applyMigrations(dbInfo *databaseInfo) error {
switch dbInfo.Type { switch dbInfo.Type {
case SQLite: case SQLite, PostgreSQL, MySQL:
return applySqliteMigrations() return applyAllMigrations(dbInfo.Type)
case PostgreSQL, MySQL:
return nil
default: default:
return fmt.Errorf("unknown database type: %s", dbInfo.Type) return fmt.Errorf("unknown database type: %s", dbInfo.Type)
} }
} }
func applySqliteMigrations() error { func applyAllMigrations(dbType databaseType) error {
// Create migration table if it doesn't exist
if err := db.AutoMigrate(&MigrationVersion{}); err != nil { if err := db.AutoMigrate(&MigrationVersion{}); err != nil {
log.Fatal().Err(err).Msg("Error creating migration version table") log.Fatal().Err(err).Msg("Error creating migration version table")
return err return err
} }
// Get the current migration version
var currentVersion MigrationVersion var currentVersion MigrationVersion
db.First(&currentVersion) db.First(&currentVersion)
// Define migrations
migrations := []struct { migrations := []struct {
Version uint Version uint
DBTypes []databaseType // nil = all types
Func func() error Func func() error
}{ }{
{1, v1_modifyConstraintToSSHKeys}, {1, []databaseType{SQLite}, v1_modifyConstraintToSSHKeys},
{2, v2_lowercaseEmails}, {2, []databaseType{SQLite}, v2_lowercaseEmails},
// Add more migrations here as needed {3, nil, v3_normalizedColumns},
} }
// Apply migrations
for _, m := range migrations { for _, m := range migrations {
if m.Version > currentVersion.Version { if m.Version <= currentVersion.Version {
tx := db.Begin() continue
if err := tx.Error; err != nil { }
log.Fatal().Err(err).Msg("Error starting transaction")
return err
}
if err := m.Func(); err != nil { // Skip migrations not intended for this DB type
log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version)) if len(m.DBTypes) > 0 {
tx.Rollback() applicable := false
return err for _, t := range m.DBTypes {
} else { if t == dbType {
if err = tx.Commit().Error; err != nil { applicable = true
log.Fatal().Err(err).Msg(fmt.Sprintf("Error committing migration %d:", m.Version)) break
return err
} }
}
if !applicable {
// Advance version so we don't retry on next startup
currentVersion.Version = m.Version currentVersion.Version = m.Version
db.Save(&currentVersion) db.Save(&currentVersion)
log.Info().Msg(fmt.Sprintf("Migration %d applied successfully", m.Version)) continue
} }
} }
tx := db.Begin()
if err := tx.Error; err != nil {
log.Fatal().Err(err).Msg("Error starting transaction")
return err
}
if err := m.Func(); err != nil {
tx.Rollback()
log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version))
return err
}
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 return nil
@@ -112,3 +128,12 @@ func v2_lowercaseEmails() error {
copySQL := `UPDATE users SET email = lower(email);` copySQL := `UPDATE users SET email = lower(email);`
return db.Exec(copySQL).Error 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,24 +2,27 @@ package db
import ( import (
"encoding/json" "encoding/json"
"strings"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"gorm.io/gorm" "gorm.io/gorm"
) )
type User struct { type User struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"` Username string `gorm:"uniqueIndex,size:191"`
Password string UsernameNormalized string `gorm:"index"`
IsAdmin bool Password string
CreatedAt int64 IsAdmin bool
Email string CreatedAt int64
MD5Hash string // for gravatar, if no Email is specified, the value is random Email string
AvatarURL string MD5Hash string // for gravatar, if no Email is specified, the value is random
GithubID string AvatarURL string
GitlabID string GithubID string
GiteaID string GitlabID string
OIDCID string `gorm:"column:oidc_id"` GiteaID string
StylePreferences string OIDCID string `gorm:"column:oidc_id"`
StylePreferences string
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
@@ -28,6 +31,11 @@ type User struct {
AccessTokens []AccessToken `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 { func (user *User) BeforeDelete(tx *gorm.DB) error {
// Decrement likes counter using derived table // Decrement likes counter using derived table
err := tx.Exec(` err := tx.Exec(`
@@ -93,7 +101,7 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
func UserExists(username string) (bool, error) { func UserExists(username string) (bool, error) {
var count int64 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 return count > 0, err
} }
@@ -111,7 +119,7 @@ func GetAllUsers(offset int) ([]*User, error) {
func GetUserByUsername(username string) (*User, error) { func GetUserByUsername(username string) (*User, error) {
user := new(User) user := new(User)
err := db. err := db.
Where("username like ?", username). Where("username_normalized = ?", strings.ToLower(username)).
First(&user).Error First(&user).Error
return user, err return user, err
} }
@@ -258,6 +266,11 @@ type UserDTO struct {
Password string `form:"password" validate:"required"` 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 { func (dto *UserDTO) ToUser() *User {
return &User{ return &User{
Username: dto.Username, 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

@@ -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.git-gc: '„garbage collection“ bei allen git Repositories ausführen'
admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren' admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren'
admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories 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.id: 'ID'
admin.user: 'Benutzer' admin.user: 'Benutzer'
admin.delete: 'Löschen' 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.git-gc: 'Sammle Repositories...'
flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...' flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...'
flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...' 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.username-exists: 'Benutzername existiert bereits'
flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen' 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.no-results: No gists found
gist.search.help.user: gists created by user gist.search.help.user: gists created by user
gist.search.help.title: gists with given title 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.filename: gists having files with given name
gist.search.help.extension: gists having files with given extension gist.search.help.extension: gists having files with given extension
gist.search.help.language: gists having files with given language gist.search.help.language: gists having files with given language
gist.search.help.topic: gists with given topic gist.search.help.topic: gists with given topic
gist.search.help.all: search all fields
gist.search.placeholder.title: Title gist.search.placeholder.title: Title
gist.search.placeholder.visibility: Visibility gist.search.placeholder.visibility: Visibility
gist.search.placeholder.public: Public gist.search.placeholder.public: Public
@@ -200,6 +202,13 @@ auth.password: Password
auth.register-instead: Register instead auth.register-instead: Register instead
auth.login-instead: Login instead auth.login-instead: Login instead
auth.oauth: Continue with %s account 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: Multi-factor authentication
auth.mfa.passkey: Passkey auth.mfa.passkey: Passkey
auth.mfa.passkeys: Passkeys auth.mfa.passkeys: Passkeys
@@ -241,7 +250,7 @@ error.signup-disabled: Signing up is disabled
error.signup-disabled-form: Signing up via registration form 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.login-disabled-form: Logging in via login form is disabled
error.complete-oauth-login: "Cannot complete user auth: %s" 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.cannot-bind-data: Cannot bind data
error.invalid-number: Invalid number error.invalid-number: Invalid number
error.invalid-character-unescaped: Invalid character unescaped error.invalid-character-unescaped: Invalid character unescaped
@@ -285,7 +294,7 @@ admin.actions.sync-db: Synchronize gists from database
admin.actions.git-gc: Garbage collect all git repositories admin.actions.git-gc: Garbage collect all git repositories
admin.actions.sync-previews: Synchronize all gists previews admin.actions.sync-previews: Synchronize all gists previews
admin.actions.reset-hooks: Reset Git server hooks for all repositories 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.actions.sync-gist-languages: Synchronize all gists languages
admin.id: ID admin.id: ID
admin.user: User admin.user: User
@@ -331,7 +340,7 @@ flash.admin.sync-db: Syncing repositories from database...
flash.admin.git-gc: Garbage collecting repositories... flash.admin.git-gc: Garbage collecting repositories...
flash.admin.sync-previews: Syncing Gist previews... flash.admin.sync-previews: Syncing Gist previews...
flash.admin.reset-hooks: Resetting Git server hooks for all repositories... 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.admin.sync-gist-languages: Syncing Gist languages...
flash.auth.username-exists: Username already exists flash.auth.username-exists: Username already exists
@@ -343,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.must-be-logged-in: You must be logged in to access gists
flash.auth.passkey-registred: Passkey %s registered flash.auth.passkey-registred: Passkey %s registered
flash.auth.passkey-deleted: Passkey deleted 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.visibility-changed: Gist visibility has been changed
flash.gist.deleted: Gist has been deleted flash.gist.deleted: Gist has been deleted

View File

@@ -213,7 +213,7 @@ admin.invitations: 'Invitaciones'
admin.invitations.create: 'Crear invitación' admin.invitations.create: 'Crear invitación'
admin.actions.sync-previews: 'Sincronizar todas las vistas previas de gists' 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.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.config-link-overriden: 'sobrescrito'
admin.invitations.help: 'Las invitaciones se pueden usar para crear una cuenta aunque el registro esté deshabilitado.' 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' 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.git-gc: 'Recolectando basura en los repositorios...'
flash.admin.sync-previews: 'Sincronizando vistas previas de gists...' flash.admin.sync-previews: 'Sincronizando vistas previas de gists...'
flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...' 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.username-exists: 'El nombre de usuario ya existe'
flash.auth.invalid-credentials: 'Credenciales incorrectas' flash.auth.invalid-credentials: 'Credenciales incorrectas'
flash.auth.account-linked-oauth: 'Cuenta vinculada a %s' 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.new.url: URL
gist.search.no-results: Aucun gist trouvé gist.search.no-results: Aucun gist trouvé
settings.unlink-gitlab-account: Détacher le compte GitLab 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.preview: 'Aperçu'
gist.new.create-a-new-gist: 'Créer un nouveau gist' gist.new.create-a-new-gist: 'Créer un nouveau gist'
gist.edit.edit-gist: 'Modifier %s' 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.git-gc: 'Nettoyage des dépôts...'
flash.admin.sync-previews: 'Synchronisation des aperçus du Gist...' 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.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.username-exists: 'Nom d''utilisateur déjà utilisé'
flash.auth.invalid-credentials: 'Identifiants non valides' flash.auth.invalid-credentials: 'Identifiants non valides'
flash.auth.account-linked-oauth: 'Compte lié à %s' 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.git-gc: Használatlan git repository-k eltávolítása
admin.actions.sync-previews: Gist előnézetek szinkronizálá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.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.id: Azonosító
admin.user: Felhasználó admin.user: Felhasználó
admin.delete: Törlés 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.git-gc: 'Esegui la garbage collection da tutti i repositories'
admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists' 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.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.id: 'ID'
admin.user: 'Utente' admin.user: 'Utente'
admin.delete: 'Elimina' 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.git-gc: 'Eseguendo il garbage collector dei repositories...'
flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...' flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...'
flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...' 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.username-exists: 'Il nome utente esiste già'
flash.auth.invalid-credentials: 'Credenziali errate' 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.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git'
admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów' admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów'
admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytorió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.id: 'ID'
admin.user: 'Użytkownik' admin.user: 'Użytkownik'
admin.delete: 'Usuń' 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.git-gc: 'Zbieranie śmieci w repozytoriach...'
flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...' 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.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.username-exists: 'Nazwa użytkownika już istnieje'
flash.auth.invalid-credentials: 'Niepoprawne dane logowania' flash.auth.invalid-credentials: 'Niepoprawne dane logowania'

View File

@@ -214,7 +214,7 @@ admin.invitations: 'Инвайты'
admin.invitations.create: 'Создать инвайт' admin.invitations.create: 'Создать инвайт'
admin.actions.sync-previews: 'Обновить предпросмотры всех фрагментов' admin.actions.sync-previews: 'Обновить предпросмотры всех фрагментов'
admin.actions.reset-hooks: 'Сбросить хуки Git-сервера для всех репозиториев' admin.actions.reset-hooks: 'Сбросить хуки Git-сервера для всех репозиториев'
admin.actions.index-gists: роиндексировать все фрагменты' admin.actions.index-gists: ерестроить поисковый индекс'
validation.should-not-be-empty: 'Поле %s не должно быть пустым' validation.should-not-be-empty: 'Поле %s не должно быть пустым'
admin.invitations.help: 'Инвайты используются для создания аккаунта, даже когда регистрация запрещена.' admin.invitations.help: 'Инвайты используются для создания аккаунта, даже когда регистрация запрещена.'
admin.invitations.max_uses: 'Максимальное количество использований' admin.invitations.max_uses: 'Максимальное количество использований'
@@ -232,7 +232,7 @@ flash.admin.sync-db: 'Выполняется синхронизация репо
flash.admin.git-gc: 'Сборка мусора в репозиториях…' flash.admin.git-gc: 'Сборка мусора в репозиториях…'
flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…' flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…'
flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…' flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…'
flash.admin.index-gists: 'Выполняется индексация фрагментов…' flash.admin.index-gists: 'Перестроение поискового индекса…'
flash.auth.username-exists: 'Такое имя пользователя уже занято' flash.auth.username-exists: 'Такое имя пользователя уже занято'
flash.auth.invalid-credentials: 'Некорректные данные для входа' flash.auth.invalid-credentials: 'Некорректные данные для входа'
flash.auth.account-linked-oauth: 'Учётная запись связана с %s' flash.auth.account-linked-oauth: 'Учётная запись связана с %s'

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.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et 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.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.id: ID
admin.user: Kullanıcı admin.user: Kullanıcı
admin.delete: Sil 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.git-gc: Depolardan gereksiz veriler temizleniyor...
flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor... 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.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.username-exists: Kullanıcı adı zaten mevcut
flash.auth.invalid-credentials: Geçersiz kimlik bilgileri 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.git-gc: Збір сміття з репозиторіїв Git
admin.actions.sync-previews: Синхронізувати всі gists перегляди admin.actions.sync-previews: Синхронізувати всі gists перегляди
admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв
admin.actions.index-gists: Проіндексувати всі gists admin.actions.index-gists: Перебудувати пошуковий індекс
admin.id: ID admin.id: ID
admin.user: Користувач admin.user: Користувач
admin.delete: Видалити admin.delete: Видалити
@@ -236,7 +236,7 @@ flash.admin.sync-db: Синхронізація репозиторіїв за б
flash.admin.git-gc: Збір сміття з репозиторіїв... flash.admin.git-gc: Збір сміття з репозиторіїв...
flash.admin.sync-previews: Синхронізація Gist переглядів... flash.admin.sync-previews: Синхронізація Gist переглядів...
flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв... flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв...
flash.admin.index-gists: Індексація всіх gists... flash.admin.index-gists: Перебудова пошукового індексу...
flash.auth.username-exists: Це ім'я користувача вже існує flash.auth.username-exists: Це ім'я користувача вже існує
flash.auth.invalid-credentials: Недійсні облікові дані flash.auth.invalid-credentials: Недійсні облікові дані

View File

@@ -214,7 +214,7 @@ admin.invitations: '邀请'
admin.invitations.create: '创建邀请' admin.invitations.create: '创建邀请'
admin.actions.sync-previews: '同步所有 Gists 预览' admin.actions.sync-previews: '同步所有 Gists 预览'
admin.actions.reset-hooks: '重置所有存储库的 Git 服务 hooks' admin.actions.reset-hooks: '重置所有存储库的 Git 服务 hooks'
admin.actions.index-gists: '索引所有 Gists' admin.actions.index-gists: '重建搜索索引'
admin.invitations.help: '即使在禁用注册功能的情况下,邀请功能也可用于创建帐户。' admin.invitations.help: '即使在禁用注册功能的情况下,邀请功能也可用于创建帐户。'
admin.invitations.max_uses: '最多使用次数' admin.invitations.max_uses: '最多使用次数'
admin.invitations.expires_at: '过期时间' admin.invitations.expires_at: '过期时间'
@@ -231,7 +231,7 @@ flash.admin.sync-db: '正在从数据库同步存储库...'
flash.admin.git-gc: '正在进行存储库垃圾回收...' flash.admin.git-gc: '正在进行存储库垃圾回收...'
flash.admin.sync-previews: '正在同步 Gist 预览...' flash.admin.sync-previews: '正在同步 Gist 预览...'
flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...' flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...'
flash.admin.index-gists: '正在索引所有 Gists...' flash.admin.index-gists: '正在重建搜索索引...'
flash.auth.username-exists: '用户名已存在' flash.auth.username-exists: '用户名已存在'
flash.auth.invalid-credentials: '无效的凭证' flash.auth.invalid-credentials: '无效的凭证'
flash.auth.account-linked-oauth: '帐户已关联到 %s' 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.title: Gists 的標題
gist.search.help.filename: Gists 的檔案名稱 gist.search.help.filename: Gists 的檔案名稱
gist.search.help.language: Gists 的程式語言 gist.search.help.language: Gists 的程式語言
admin.actions.index-gists: 索引所有的 Gists admin.actions.index-gists: 重建搜尋索引
gist.search.help.user: 由使用者建立的 Gists gist.search.help.user: 由使用者建立的 Gists
gist.search.found: 已找到 Gists gist.search.found: 已找到 Gists
gist.search.help.extension: Gists 的副檔名 gist.search.help.extension: Gists 的副檔名

View File

@@ -2,16 +2,21 @@ package index
import ( import (
"errors" "errors"
"fmt"
"os"
"strconv" "strconv"
"strings"
"github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom" "github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase" "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/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/search/query" "github.com/blevesearch/bleve/v2/search/query"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
) )
type BleveIndexer struct { type BleveIndexer struct {
@@ -52,14 +57,9 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
return nil, err 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() mapping := bleve.NewIndexMapping()
// Token filters
if err = mapping.AddCustomTokenFilter("unicodeNormalize", map[string]any{ if err = mapping.AddCustomTokenFilter("unicodeNormalize", map[string]any{
"type": unicodenorm.Name, "type": unicodenorm.Name,
"form": unicodenorm.NFC, "form": unicodenorm.NFC,
@@ -67,21 +67,88 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
return nil, err return nil, err
} }
if err = mapping.AddCustomAnalyzer("gistAnalyser", map[string]interface{}{ if err = mapping.AddCustomTokenFilter("lengthMin2", map[string]interface{}{
"type": custom.Name, "type": length.Name,
"char_filters": []string{}, "min": 2.0,
"tokenizer": unicode.Name,
"token_filters": []string{"unicodeNormalize", camelcase.Name, lowercase.Name},
}); err != nil { }); err != nil {
return nil, err 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 mapping.DefaultMapping = docMapping
return bleve.New(i.path, mapping) 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() { func (i *BleveIndexer) Close() {
if i == nil || i.index == nil { if i == nil || i.index == nil {
return return
@@ -105,18 +172,111 @@ func (i *BleveIndexer) Remove(gistID uint) error {
return (*atomicIndexer.Load()).(*BleveIndexer).index.Delete(strconv.Itoa(int(gistID))) 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 err error
var indexerQuery query.Query var indexerQuery query.Query = bleve.NewMatchAllQuery()
if queryStr != "" {
// Use match query with fuzzy matching for more flexible content search // Query factory
contentQuery := bleve.NewMatchQuery(queryStr) factoryQuery := func(field, value string) query.Query {
contentQuery.SetField("Content") query := bleve.NewMatchPhraseQuery(value)
contentQuery.SetFuzziness(2) query.SetField(field)
indexerQuery = contentQuery return query
} else { }
contentQuery := bleve.NewMatchAllQuery()
indexerQuery = contentQuery // 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 // 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) accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery)
indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery) 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 // Handle "All" field - search across all metadata fields with OR logic
if queryMetadata.All != "" { if metadata.All != "" {
allQueries := make([]query.Query, 0) allQueries := make([]query.Query, 0, len(AllSearchFields))
for _, field := range AllSearchFields {
// Create match phrase queries for each field allQueries = append(allQueries, buildFieldQuery(field, metadata.All))
fields := []struct {
field string
value string
}{
{"Username", queryMetadata.All},
{"Title", queryMetadata.All},
{"Extensions", "." + queryMetadata.All},
{"Filenames", queryMetadata.All},
{"Languages", queryMetadata.All},
{"Topics", queryMetadata.All},
} }
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, bleve.NewDisjunctionQuery(allQueries...))
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)
} else { } else {
// Original behavior: add each metadata field with AND logic // Original behavior: add each metadata field with AND logic
addQuery := func(field, value string) { addQuery("Username", metadata.Username)
if value != "" && value != "." { addTextQuery("Title", metadata.Title)
q := bleve.NewMatchPhraseQuery(value) addTextQuery("Description", metadata.Description)
q.FieldVal = field addQuery("Extensions", "."+metadata.Extension)
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q) 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) // Handle default search fields from config with OR logic
addQuery("Title", queryMetadata.Title) if metadata.Default != "" {
addQuery("Extensions", "."+queryMetadata.Extension) var fields []string
addQuery("Filenames", queryMetadata.Filename) for _, f := range strings.Split(config.C.SearchDefault, ",") {
addQuery("Languages", queryMetadata.Language) f = strings.TrimSpace(f)
addQuery("Topics", queryMetadata.Topic) 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) languageFacet := bleve.NewFacetRequest("Languages", 10)
@@ -186,6 +360,8 @@ func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
s.Fields = []string{"GistID"} s.Fields = []string{"GistID"}
s.IncludeLocations = false s.IncludeLocations = false
log.Debug().Interface("searchRequest", s).Msg("Bleve search request")
results, err := (*atomicIndexer.Load()).(*BleveIndexer).index.Search(s) results, err := (*atomicIndexer.Load()).(*BleveIndexer).index.Search(s)
if err != nil { if err != nil {
return nil, 0, nil, err return nil, 0, nil, err

View File

@@ -4,33 +4,31 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
) )
// setupBleveIndexer creates a new BleveIndexer for testing // 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() t.Helper()
// Create a temporary directory for the test index
tmpDir, err := os.MkdirTemp("", "bleve-test-*") tmpDir, err := os.MkdirTemp("", "bleve-test-*")
if err != nil { require.NoError(t, err)
t.Fatalf("Failed to create temp directory: %v", err)
}
indexPath := filepath.Join(tmpDir, "test.index") indexPath := filepath.Join(tmpDir, "test.index")
indexer := NewBleveIndexer(indexPath) indexer := NewBleveIndexer(indexPath)
// Initialize the indexer
err = indexer.Init() err = indexer.Init()
if err != nil { if err != nil {
os.RemoveAll(tmpDir) os.RemoveAll(tmpDir)
t.Fatalf("Failed to initialize BleveIndexer: %v", err) t.Fatalf("Failed to initialize BleveIndexer: %v", err)
} }
// Store in the global atomicIndexer since Add/Remove use it
var idx Indexer = indexer var idx Indexer = indexer
atomicIndexer.Store(&idx) atomicIndexer.Store(&idx)
// Return cleanup function
cleanup := func() { cleanup := func() {
atomicIndexer.Store(nil) atomicIndexer.Store(nil)
indexer.Close() indexer.Close()
@@ -40,123 +38,50 @@ func setupBleveIndexer(t *testing.T) (*BleveIndexer, func()) {
return indexer, cleanup return indexer, cleanup
} }
func TestBleveIndexerAddGist(t *testing.T) { func TestBleveAddAndSearch(t *testing.T) { testAddAndSearch(t, setupBleveIndexer) }
indexer, cleanup := setupBleveIndexer(t) func TestBleveAccessControl(t *testing.T) { testAccessControl(t, setupBleveIndexer) }
defer cleanup() 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 TestBlevePersistence(t *testing.T) {
} tmpDir, err := os.MkdirTemp("", "bleve-persist-test-*")
require.NoError(t, err)
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)
}
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
indexPath := filepath.Join(tmpDir, "test.index") indexPath := filepath.Join(tmpDir, "test.index")
indexer := NewBleveIndexer(indexPath)
// Test initialization // Create and populate index
err = indexer.Init() indexer1 := NewBleveIndexer(indexPath)
if err != nil { require.NoError(t, indexer1.Init())
t.Fatalf("Failed to initialize BleveIndexer: %v", err)
}
if indexer.index == nil { var idx Indexer = indexer1
t.Fatal("Expected index to be initialized, got nil") atomicIndexer.Store(&idx)
}
// Test closing g := newGist(1, 1, 0, "persistent data survives restart")
indexer.Close() require.NoError(t, indexer1.Add(g))
// Test reopening the same index indexer1.Close()
atomicIndexer.Store(nil)
// Reopen at same path
indexer2 := NewBleveIndexer(indexPath) indexer2 := NewBleveIndexer(indexPath)
err = indexer2.Init() require.NoError(t, indexer2.Init())
if err != nil {
t.Fatalf("Failed to reopen BleveIndexer: %v", err)
}
defer indexer2.Close() defer indexer2.Close()
if indexer2.index == nil { idx = indexer2
t.Fatal("Expected reopened index to be initialized, got nil") atomicIndexer.Store(&idx)
} defer atomicIndexer.Store(nil)
}
ids, total, _, err := indexer2.Search(SearchGistMetadata{Content: "persistent"}, 1, 1)
// TestBleveIndexerUnicodeSearch tests that Unicode content can be indexed and searched require.NoError(t, err)
func TestBleveIndexerUnicodeSearch(t *testing.T) { require.Equal(t, uint64(1), total, "data should survive close+reopen")
indexer, cleanup := setupBleveIndexer(t) require.Equal(t, uint(1), ids[0])
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")
}
} }

View File

@@ -1,26 +1,43 @@
package index 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 { type Gist struct {
GistID uint GistID uint
UserID uint UserID uint
Visibility uint Visibility uint
Username string Username string
Title string Description string
Content string Title string
Filenames []string Content string
Extensions []string Filenames []string
Languages []string Extensions []string
Topics []string Languages []string
CreatedAt int64 Topics []string
UpdatedAt int64 CreatedAt int64
UpdatedAt int64
} }
type SearchGistMetadata struct { type SearchGistMetadata struct {
Username string Username string
Title string Title string
Filename string Description string
Extension string Content string
Language string Filename string
Topic string Extension string
All string Language string
Topic string
All string
Default string
} }

View File

@@ -2,10 +2,11 @@ package index
import ( import (
"fmt" "fmt"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"path/filepath" "path/filepath"
"sync/atomic" "sync/atomic"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
) )
var atomicIndexer atomic.Pointer[Indexer] var atomicIndexer atomic.Pointer[Indexer]
@@ -13,9 +14,10 @@ var atomicIndexer atomic.Pointer[Indexer]
type Indexer interface { type Indexer interface {
Init() error Init() error
Close() Close()
Reset() error
Add(gist *Gist) error Add(gist *Gist) error
Remove(gistID uint) 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 type IndexerType string
@@ -84,6 +86,19 @@ func Close() {
atomicIndexer.Store(nil) 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 { func AddInIndex(gist *Gist) error {
if !IndexEnabled() { if !IndexEnabled() {
return nil return nil
@@ -110,7 +125,11 @@ func RemoveFromIndex(gistID uint) error {
return (*idx).Remove(gistID) 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() { if !IndexEnabled() {
return nil, 0, nil, nil 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 nil, 0, nil, fmt.Errorf("indexer is not initialized")
} }
return (*idx).Search(query, metadata, userId, page) return (*idx).Search(metadata, userId, page)
} }
func DepreactionIndexDirname() { func DepreactionIndexDirname() {

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,11 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"unicode"
"github.com/meilisearch/meilisearch-go" "github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
) )
type MeiliIndexer struct { type MeiliIndexer struct {
@@ -50,28 +52,45 @@ func (i *MeiliIndexer) open() (meilisearch.IndexManager, error) {
i.client = meilisearch.New(i.host, meilisearch.WithAPIKey(i.apikey)) i.client = meilisearch.New(i.host, meilisearch.WithAPIKey(i.apikey))
indexResult, err := i.client.GetIndex(i.indexName) indexResult, err := i.client.GetIndex(i.indexName)
if indexResult != nil && err == nil { if indexResult == nil || err != nil {
return indexResult.IndexManager, nil _, err = i.client.CreateIndex(&meilisearch.IndexConfig{
} Uid: i.indexName,
PrimaryKey: "GistID",
_, err = i.client.CreateIndex(&meilisearch.IndexConfig{ })
Uid: i.indexName, if err != nil {
PrimaryKey: "GistID", return nil, err
}) }
if err != nil {
return nil, err
} }
_, _ = i.client.Index(i.indexName).UpdateSettings(&meilisearch.Settings{ _, _ = i.client.Index(i.indexName).UpdateSettings(&meilisearch.Settings{
FilterableAttributes: []string{"GistID", "UserID", "Visibility", "Username", "Title", "Filenames", "Extensions", "Languages", "Topics"}, FilterableAttributes: []string{"GistID", "UserID", "Visibility", "Username", "Extensions", "Languages", "Topics"},
DisplayedAttributes: []string{"GistID"}, SearchableAttributes: []string{"Content", "ContentSplit", "Username", "Title", "Description", "Filenames", "Extensions", "Languages", "Topics"},
SearchableAttributes: []string{"Content", "Username", "Title", "Filenames", "Extensions", "Languages", "Topics"}, RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
RankingRules: []string{"words"}, TypoTolerance: &meilisearch.TypoTolerance{
Enabled: true,
DisableOnNumbers: true,
MinWordSizeForTypos: meilisearch.MinWordSizeForTypos{OneTypo: 4, TwoTypos: 10},
},
}) })
return i.client.Index(i.indexName), nil 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() { func (i *MeiliIndexer) Close() {
if i.client != nil { if i.client != nil {
i.client.Close() i.client.Close()
@@ -80,12 +99,21 @@ func (i *MeiliIndexer) Close() {
i.client = nil i.client = nil
} }
type meiliGist struct {
Gist
ContentSplit string
}
func (i *MeiliIndexer) Add(gist *Gist) error { func (i *MeiliIndexer) Add(gist *Gist) error {
if gist == nil { if gist == nil {
return errors.New("failed to add nil gist to index") return errors.New("failed to add nil gist to index")
} }
doc := &meiliGist{
Gist: *gist,
ContentSplit: splitCamelCase(gist.Content),
}
primaryKey := "GistID" 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 return err
} }
@@ -94,13 +122,14 @@ func (i *MeiliIndexer) Remove(gistID uint) error {
return err 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{ searchRequest := &meilisearch.SearchRequest{
Offset: int64((page - 1) * 10), Offset: int64((page - 1) * 10),
Limit: 11, Limit: 11,
AttributesToRetrieve: []string{"GistID", "Languages"}, AttributesToRetrieve: []string{"GistID", "Languages"},
Facets: []string{"Languages"}, Facets: []string{"Languages"},
AttributesToSearchOn: []string{"Content"}, AttributesToSearchOn: []string{"Content", "ContentSplit"},
MatchingStrategy: meilisearch.All,
} }
var filters []string 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))) filters = append(filters, fmt.Sprintf("%s = \"%s\"", field, escapeFilterValue(value)))
} }
} }
addFilter("Username", queryMetadata.Username) var query string
addFilter("Title", queryMetadata.Title) if queryMetadata.All != "" {
addFilter("Filenames", queryMetadata.Filename) query = queryMetadata.All
addFilter("Extensions", queryMetadata.Extension) searchRequest.AttributesToSearchOn = append(AllSearchFields, "ContentSplit")
addFilter("Languages", queryMetadata.Language) } else {
addFilter("Topics", queryMetadata.Topic) // Exact-match fields stay as filters
addFilter("Username", queryMetadata.Username)
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 { if len(filters) > 0 {
searchRequest.Filter = strings.Join(filters, " AND ") 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 { if err != nil {
log.Error().Err(err).Msg("Failed to search Meilisearch index") log.Error().Err(err).Msg("Failed to search Meilisearch index")
return nil, 0, nil, err return nil, 0, nil, err
} }
gistIds := make([]uint, 0, len(response.Hits)) gistIds := make([]uint, 0, len(response.Hits))
for _, hit := range response.Hits { for _, hit := range response.Hits {
if gistIDRaw, ok := hit["GistID"]; ok { 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 var facetDist map[string]map[string]int
if err := json.Unmarshal(response.FacetDistribution, &facetDist); err == nil { if err := json.Unmarshal(response.FacetDistribution, &facetDist); err == nil {
if facets, ok := facetDist["Languages"]; ok { 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 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 { func escapeFilterValue(value string) string {
escaped := strings.ReplaceAll(value, "\\", "\\\\") escaped := strings.ReplaceAll(value, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"") 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

@@ -59,7 +59,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
name := fl.Field().String() name := fl.Field().String()
restrictedNames := map[string]struct{}{} 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{}{} 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" "crypto/md5"
"errors" "errors"
"fmt" "fmt"
"slices"
"strings" "strings"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/oauth" "github.com/thomiceli/opengist/internal/auth/oauth"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "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" "github.com/thomiceli/opengist/internal/web/context"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -48,7 +47,8 @@ func Oauth(ctx *context.Context) error {
provider, err := oauth.DefineProvider(providerStr, opengistUrl) provider, err := oauth.DefineProvider(providerStr, opengistUrl)
if err != nil { 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 { if err = provider.RegisterProvider(); err != nil {
@@ -62,28 +62,37 @@ func Oauth(ctx *context.Context) error {
func OauthCallback(ctx *context.Context) error { func OauthCallback(ctx *context.Context) error {
provider, err := oauth.CompleteUserAuth(ctx) provider, err := oauth.CompleteUserAuth(ctx)
if err != nil { 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 currUser := ctx.User
user := provider.GetProviderUser()
// if user is logged in, link account to user and update its avatar URL // if user is logged in, link account to user and update its avatar URL
if currUser != nil { if currUser != nil {
// 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) provider.UpdateUserDB(currUser)
if err = currUser.Update(); err != nil { if err = currUser.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot update user "+cases.Title(language.English).String(provider.GetProvider())+" id", err) return ctx.ErrorRes(500, "Cannot update user "+config.C.OIDCProviderName+" id", err)
} }
ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", cases.Title(language.English).String(provider.GetProvider())), "success") ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", config.C.OIDCProviderName), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings")
} }
user := provider.GetProviderUser()
userDB, err := db.GetUserByProvider(user.UserID, provider.GetProvider()) 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 err != nil {
if ctx.GetData("DisableSignup") == true { 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) { if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -94,74 +103,25 @@ func OauthCallback(ctx *context.Context) error {
user.NickName = strings.Split(user.Email, "@")[0] user.NickName = strings.Split(user.Email, "@")[0]
} }
userDB = &db.User{ sess := ctx.GetSession()
Username: user.NickName, sess.Values["oauthProvider"] = provider.GetProvider()
Email: user.Email, sess.Values["oauthUserID"] = user.UserID
MD5Hash: fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(user.Email))))), sess.Values["oauthNickname"] = user.NickName
} sess.Values["oauthEmail"] = user.Email
sess.Values["oauthAvatarURL"] = user.AvatarURL
sess.Values["oauthIsAdmin"] = provider.IsAdmin()
// set provider id and avatar URL sess.Options.MaxAge = 10 * 60 // 10 minutes
provider.UpdateUserDB(userDB) ctx.SaveSession(sess)
if err = userDB.Create(); err != nil { return ctx.RedirectTo("/oauth/register")
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 {
return ctx.ErrorRes(500, "Cannot set user admin", err)
}
}
keys, err := provider.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,
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 // promote user to admin from oidc group
if config.C.OIDCAdminGroup != "" { if !userDB.IsAdmin && provider.IsAdmin() {
groupClaimName := config.C.OIDCGroupClaimName userDB.IsAdmin = true
if groupClaimName == "" { if err = userDB.Update(); err != nil {
log.Error().Msg("No OIDC group claim name configured") return ctx.ErrorRes(500, "Cannot set user admin", err)
} 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")
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")
} }
} }
@@ -173,6 +133,150 @@ func OauthCallback(ctx *context.Context) error {
return ctx.RedirectTo("/") 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 " + 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")
}
}
}
delete(sess.Values, "oauthProvider")
delete(sess.Values, "oauthUserID")
delete(sess.Values, "oauthNickname")
delete(sess.Values, "oauthEmail")
delete(sess.Values, "oauthAvatarURL")
delete(sess.Values, "oauthIsAdmin")
sess.Values["user"] = userDB.ID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
ctx.SaveSession(sess)
ctx.DeleteCsrfCookie()
return ctx.RedirectTo("/")
}
func OauthUnlink(ctx *context.Context) error { func OauthUnlink(ctx *context.Context) error {
providerStr := ctx.Param("provider") providerStr := ctx.Param("provider")
provider, err := oauth.DefineProvider(ctx.Param("provider"), "") provider, err := oauth.DefineProvider(ctx.Param("provider"), "")
@@ -184,10 +288,10 @@ func OauthUnlink(ctx *context.Context) error {
if provider.UserHasProvider(currUser) { if provider.UserHasProvider(currUser) {
if err := currUser.DeleteProviderID(providerStr); err != nil { 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") 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") 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 { func Search(ctx *context.Context) error {
var err error var err error
@@ -171,7 +183,7 @@ func Search(ctx *context.Context) error {
Query: ctx.QueryParam("q"), Query: ctx.QueryParam("q"),
} }
content, meta := handlers.ParseSearchQueryStr(ctx.QueryParam("q")) metadata := handlers.ParseSearchQueryStr(ctx.QueryParam("q"))
pageInt := handlers.GetPage(ctx) pageInt := handlers.GetPage(ctx)
var currentUserId uint var currentUserId uint
@@ -182,14 +194,18 @@ func Search(ctx *context.Context) error {
currentUserId = 0 currentUserId = 0
} }
gistsIds, nbHits, langs, err := index.SearchGists(content, index.SearchGistMetadata{ // Search gists in the index and fetch the gists IDs from the database
Username: meta["user"], gistsIds, nbHits, langs, err := index.SearchGists(index.SearchGistMetadata{
Title: meta["title"], Username: metadata["user"],
Filename: meta["filename"], Title: metadata["title"],
Extension: meta["extension"], Description: metadata["description"],
Language: meta["language"], Filename: metadata["filename"],
Topic: meta["topic"], Extension: metadata["extension"],
All: meta["all"], Language: metadata["language"],
Topic: metadata["topic"],
Content: metadata["content"],
All: metadata["all"],
Default: metadata["default"],
}, currentUserId, pageInt) }, currentUserId, pageInt)
if err != nil { if err != nil {
return ctx.ErrorRes(500, "Error searching gists", err) 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 { func ProcessCreate(ctx *context.Context) error {
isCreate := ctx.Request().URL.Path == "/" 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) dto := new(db.GistDTO)
var gist *db.Gist 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)) 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) return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
} }
dto.Files = make([]db.FileDTO, 0) dto.Files = make([]db.FileDTO, 0)
fileCounter := 0
names := ctx.Request().PostForm["name"] names := dto.Name
contents := ctx.Request().PostForm["content"] contents := dto.Content
// Process files from text editors // Process files from text editors
for i, content := range contents { for i, content := range contents {
if content == "" { if content == "" {
continue continue
} }
name := names[i] name := git.CleanTreePathName(names[i])
if name == "" { if name == "" {
fileCounter += 1 name = "gistfile" + strconv.Itoa(len(dto.Files)+1) + ".txt"
name = "gistfile" + strconv.Itoa(fileCounter) + ".txt"
} }
escapedValue, err := url.PathUnescape(content) escapedValue, err := url.PathUnescape(content)
@@ -72,18 +66,26 @@ func ProcessCreate(ctx *context.Context) error {
} }
// Process uploaded files from UUID arrays // Process uploaded files from UUID arrays
fileUUIDs := ctx.Request().PostForm["uploadedfile_uuid"] fileUUIDs := dto.UploadedFilesUUID
fileFilenames := ctx.Request().PostForm["uploadedfile_filename"] fileFilenames := dto.UploadedFilesNames
if len(fileUUIDs) == len(fileFilenames) { if len(fileUUIDs) == len(fileFilenames) {
for i, fileUUID := range fileUUIDs { 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 { if _, err := os.Stat(filePath); err != nil {
continue continue
} }
name := git.CleanTreePathName(fileFilenames[i])
if name == "" {
name = "gistfile" + strconv.Itoa(len(dto.Files)+1) + ".txt"
}
dto.Files = append(dto.Files, db.FileDTO{ dto.Files = append(dto.Files, db.FileDTO{
Filename: fileFilenames[i], Filename: name,
SourcePath: filePath, SourcePath: filePath,
Content: "", // Empty since we're using SourcePath Content: "", // Empty since we're using SourcePath
}) })
@@ -91,11 +93,11 @@ func ProcessCreate(ctx *context.Context) error {
} }
// Process binary file operations (edit mode) // Process binary file operations (edit mode)
binaryOldNames := ctx.Request().PostForm["binary_old_name"] binaryOldNames := dto.BinaryFileOldName
binaryNewNames := ctx.Request().PostForm["binary_new_name"] binaryNewNames := dto.BinaryFileNewName
if len(binaryOldNames) == len(binaryNewNames) { if len(binaryOldNames) == len(binaryNewNames) {
for i, oldName := range binaryOldNames { for i, oldName := range binaryOldNames {
newName := binaryNewNames[i] newName := git.CleanTreePathName(binaryNewNames[i])
if newName == "" { // deletion if newName == "" { // deletion
continue 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

@@ -30,7 +30,7 @@ func RawFile(ctx *context.Context) error {
if file.MimeType.CanBeEmbedded() { if file.MimeType.CanBeEmbedded() {
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType) ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
} else if file.MimeType.IsText() { } else if file.MimeType.IsText() {
ctx.Response().Header().Set("Content-Type", "text/plain") ctx.Response().Header().Set("Content-Type", "text/plain; charset=utf-8")
} else { } else {
ctx.Response().Header().Set("Content-Type", "application/octet-stream") ctx.Response().Header().Set("Content-Type", "application/octet-stream")
} }

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 package gist
import ( import (
"strconv"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/render" "github.com/thomiceli/opengist/internal/render"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"strconv"
) )
func Edit(ctx *context.Context) error { 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(` js := fmt.Sprintf(`
document.write('<link rel="stylesheet" href=%s>'); (function() {
document.write('<link rel="stylesheet" href=%s>'); if (!customElements.get('opengist-embed')) {
document.write(%s); 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(jsonCssUrl),
string(jsonThemeUrl), string(jsonThemeUrl),
string(jsonContent), 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 package gist
import ( import (
"strings"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers" "github.com/thomiceli/opengist/internal/web/handlers"
"strings"
) )
func Revisions(ctx *context.Context) error { 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" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/web/context" "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 { func Upload(ctx *context.Context) error {
err := ctx.Request().ParseMultipartForm(32 << 20) // 32 MB max err := ctx.Request().ParseMultipartForm(32 << 20) // 32 MB max
if err != nil { if err != nil {
@@ -57,13 +60,13 @@ func Upload(ctx *context.Context) error {
} }
func DeleteUpload(ctx *context.Context) error { func DeleteUpload(ctx *context.Context) error {
uuid := ctx.Param("uuid") fileUuid := filepath.Base(ctx.Param("uuid"))
if uuid == "" {
if fileUuid == "" || !uuidRegex.MatchString(fileUuid) {
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), nil) return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), nil)
} }
uploadsDir := filepath.Join(config.GetHomeDir(), "uploads") filePath := filepath.Join(config.GetHomeDir(), "uploads", fileUuid)
filePath := filepath.Join(uploadsDir, uuid)
if _, err := os.Stat(filePath); err == nil { if _, err := os.Stat(filePath); err == nil {
if err := os.Remove(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,4 +1,4 @@
package test package metrics_test
import ( import (
"io" "io"
@@ -10,19 +10,17 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
) )
var ( func TestMetrics(t *testing.T) {
SSHKey = db.SSHKeyDTO{ s := webtest.Setup(t)
Title: "Test SSH Key", defer webtest.Teardown(t)
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",
}
SimpleGist = db.GistDTO{ s.Register(t, "thomas")
s.Login(t, "thomas")
s.Request(t, "POST", "/", db.GistDTO{
Title: "Simple Test Gist", Title: "Simple Test Gist",
Description: "A simple gist for testing", Description: "A simple gist for testing",
VisibilityDTO: db.VisibilityDTO{ VisibilityDTO: db.VisibilityDTO{
@@ -31,39 +29,14 @@ var (
Name: []string{"file1.txt"}, Name: []string{"file1.txt"},
Content: []string{"This is the content of file1"}, Content: []string{"This is the content of file1"},
Topics: "", Topics: "",
} }, 302)
)
// TestMetrics tests the metrics endpoint functionality of the application. s.Request(t, "POST", "/settings/ssh-keys", db.SSHKeyDTO{
// It verifies that the metrics endpoint correctly reports counts for: Title: "Test SSH Key",
// - Total number of users Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`,
// - Total number of gists }, 302)
// - Total number of SSH keys
//
// The test follows these steps:
// 1. Sets up test environment
// 2. Registers and logs in an admin user
// 3. Creates a gist and adds an SSH key
// 4. Creates a metrics server and queries the /metrics endpoint
// 5. Verifies the reported metrics match expected values
func TestMetrics(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
register(t, s, AdminUser) metricsServer := webtest.NewTestMetricsServer()
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)
// Create a metrics server and query it
metricsServer := NewTestMetricsServer()
req := httptest.NewRequest("GET", "/metrics", nil) req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()

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

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"github.com/gorilla/schema" "github.com/gorilla/schema"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/web/context" "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 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) words := strings.Fields(query)
metadata := make(map[string]string) metadata := make(map[string]string)
var contentBuilder strings.Builder var allFieldsBuilder strings.Builder
for _, word := range words { for _, word := range words {
if strings.Contains(word, ":") { if strings.Contains(word, ":") {
@@ -133,10 +139,18 @@ func ParseSearchQueryStr(query string) (string, map[string]string) {
metadata[key] = value metadata[key] = value
} }
} else { } else {
contentBuilder.WriteString(word + " ") // Add to content search by default
allFieldsBuilder.WriteString(word + " ")
} }
} }
content := strings.TrimSpace(contentBuilder.String()) // Set the default search field
return content, metadata 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() { func (s *Server) useCustomContext() {
s.echo.Use(func(next echo.HandlerFunc) echo.HandlerFunc { s.echo.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
cc := context.NewContext(c, s.sessionsPath) cc := context.NewContext(c, filepath.Join(config.GetHomeDir(), "sessions"))
return next(cc) return next(cc)
} }
}) })
@@ -58,29 +58,27 @@ func (s *Server) registerMiddlewares() {
s.echo.Use(middleware.Recover()) s.echo.Use(middleware.Recover())
s.echo.Use(middleware.Secure()) s.echo.Use(middleware.Secure())
s.echo.Use(Middleware(sessionInit).toEcho()) s.echo.Use(Middleware(sessionInit).toEcho())
s.echo.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "form:_csrf,header:X-CSRF-Token",
CookiePath: "/",
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
Skipper: func(ctx echo.Context) bool {
/* skip CSRF for embeds */
gistName := ctx.Param("gistname")
if !s.ignoreCsrf { /* skip CSRF for git clients */
s.echo.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ matchUploadPack, _ := regexp.MatchString("(.*?)/git-upload-pack$", ctx.Request().URL.Path)
TokenLookup: "form:_csrf,header:X-CSRF-Token", matchReceivePack, _ := regexp.MatchString("(.*?)/git-receive-pack$", ctx.Request().URL.Path)
CookiePath: "/", return (filepath.Ext(gistName) == ".js" && ctx.Request().Method == "GET") || matchUploadPack || matchReceivePack
CookieHTTPOnly: true, },
CookieSameSite: http.SameSiteStrictMode, ErrorHandler: func(err error, c echo.Context) error {
Skipper: func(ctx echo.Context) bool { log.Info().Err(err).Msg("CSRF error")
/* skip CSRF for embeds */ return err
gistName := ctx.Param("gistname") },
}))
s.echo.Use(Middleware(csrfInit).toEcho())
/* 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
},
ErrorHandler: func(err error, c echo.Context) error {
log.Info().Err(err).Msg("CSRF error")
return err
},
}))
s.echo.Use(Middleware(csrfInit).toEcho())
}
} }
func (s *Server) errorHandler(err error, ctx echo.Context) { func (s *Server) errorHandler(err error, ctx echo.Context) {
@@ -159,10 +157,10 @@ func dataInit(next Handler) Handler {
func writePermission(next Handler) Handler { func writePermission(next Handler) Handler {
return func(ctx *context.Context) error { return func(ctx *context.Context) error {
gist := ctx.GetData("gist") gist := ctx.GetData("gist").(*db.Gist)
user := ctx.User user := ctx.User
if !gist.(*db.Gist).CanWrite(user) { if !gist.CanWrite(user) {
return ctx.RedirectTo("/" + gist.(*db.Gist).User.Username + "/" + gist.(*db.Gist).Identifier()) return ctx.ErrorRes(403, "You don't have permission to edit this gist", nil)
} }
return next(ctx) return next(ctx)
} }
@@ -199,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 { func makeCheckRequireLogin(isSingleGistAccess bool) Middleware {
return func(next Handler) Handler { return func(next Handler) Handler {
return func(ctx *context.Context) error { return func(ctx *context.Context) error {
@@ -309,7 +318,6 @@ func csrfInit(next Handler) Handler {
csrf = csrfToken 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+`">`))
ctx.SetData("csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
return next(ctx) return next(ctx)
} }

View File

@@ -147,7 +147,10 @@ func (s *Server) setFuncMap() {
return dict, nil return dict, nil
}, },
"addMetadataToSearchQuery": func(input, key, value string) string { "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 metadata[key] = value

View File

@@ -38,6 +38,8 @@ func (s *Server) registerRoutes() {
r.GET("/login", auth.Login) r.GET("/login", auth.Login)
r.POST("/login", auth.ProcessLogin) r.POST("/login", auth.ProcessLogin)
r.GET("/logout", auth.Logout) 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", auth.Oauth)
r.GET("/oauth/:provider/callback", auth.OauthCallback) r.GET("/oauth/:provider/callback", auth.OauthCallback)
r.GET("/oauth/:provider/unlink", auth.OauthUnlink, logged) r.GET("/oauth/:provider/unlink", auth.OauthUnlink, logged)

View File

@@ -2,7 +2,6 @@ package server
import ( import (
"fmt" "fmt"
"github.com/thomiceli/opengist/internal/validator"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -10,6 +9,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/thomiceli/opengist/internal/validator"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
@@ -18,19 +19,16 @@ import (
type Server struct { type Server struct {
echo *echo.Echo echo *echo.Echo
dev bool
dev bool
sessionsPath string
ignoreCsrf bool
} }
func NewServer(isDev bool, sessionsPath string, ignoreCsrf bool) *Server { func NewServer(isDev bool) *Server {
e := echo.New() e := echo.New()
e.HideBanner = true e.HideBanner = true
e.HidePort = true e.HidePort = true
e.Validator = validator.NewValidator() e.Validator = validator.NewValidator()
s := &Server{echo: e, dev: isDev, sessionsPath: sessionsPath, ignoreCsrf: ignoreCsrf} s := &Server{echo: e, dev: isDev}
s.useCustomContext() s.useCustomContext()
@@ -175,3 +173,7 @@ func (s *Server) createPidFile(pidPath string) error {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.echo.ServeHTTP(w, r) s.echo.ServeHTTP(w, r)
} }
func (s *Server) Use(middleware echo.MiddlewareFunc) {
s.echo.Use(middleware)
}

View File

@@ -1,448 +0,0 @@
package test
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
)
func TestAccessTokensCRUD(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register and login
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
// Access tokens page requires login
s.sessionCookie = ""
err := s.Request("GET", "/settings/access-tokens", nil, 302)
require.NoError(t, err)
login(t, s, user1)
// Access tokens page
err = s.Request("GET", "/settings/access-tokens", nil, 200)
require.NoError(t, err)
// Create a token with read permission
tokenDTO := db.AccessTokenDTO{
Name: "test-token",
ScopeGist: db.ReadPermission,
}
err = s.Request("POST", "/settings/access-tokens", tokenDTO, 302)
require.NoError(t, err)
// Verify token was created in database
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)
// Create another token with expiration
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
tokenDTO2 := db.AccessTokenDTO{
Name: "expiring-token",
ScopeGist: db.ReadWritePermission,
ExpiresAt: tomorrow,
}
err = s.Request("POST", "/settings/access-tokens", tokenDTO2, 302)
require.NoError(t, err)
tokens, err = db.GetAccessTokensByUserID(1)
require.NoError(t, err)
require.Len(t, tokens, 2)
// Delete the first token
err = s.Request("DELETE", "/settings/access-tokens/1", nil, 302)
require.NoError(t, err)
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 := Setup(t)
defer Teardown(t, s)
// Register user and create a private gist
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "private-gist",
Description: "my private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"secret.txt"},
Content: []string{"secret content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
// 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)
err = token.Create()
require.NoError(t, err)
// Clear session - simulate unauthenticated request
s.sessionCookie = ""
// Without token, private gist should return 404
err = s.Request("GET", "/thomas/"+gist1db.Uuid, nil, 404)
require.NoError(t, err)
// With valid token, private gist should be accessible
headers := map[string]string{"Authorization": "Token " + plainToken}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers)
require.NoError(t, err)
// Raw content should also be accessible with token
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+"/raw/HEAD/secret.txt", nil, 200, headers)
require.NoError(t, err)
// JSON endpoint should also be accessible with token
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+".json", nil, 200, headers)
require.NoError(t, err)
// Invalid token should not work
invalidHeaders := map[string]string{"Authorization": "Token invalid_token"}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, invalidHeaders)
require.NoError(t, err)
}
func TestAccessTokenPermissions(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register user and create a private gist
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "private-gist",
Description: "my private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"file.txt"},
Content: []string{"content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
// 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)
err = noPermToken.Create()
require.NoError(t, err)
// Create token with READ permission
readToken := &db.AccessToken{
Name: "read-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
readPlain, err := readToken.GenerateToken()
require.NoError(t, err)
err = readToken.Create()
require.NoError(t, err)
s.sessionCookie = ""
// No permission token should not grant access
noPermHeaders := map[string]string{"Authorization": "Token " + noPermPlain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, noPermHeaders)
require.NoError(t, err)
// Read permission token should grant access
readHeaders := map[string]string{"Authorization": "Token " + readPlain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, readHeaders)
require.NoError(t, err)
}
func TestAccessTokenExpiration(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register user and create a private gist
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "private-gist",
Description: "my private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"file.txt"},
Content: []string{"content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
// Create an expired token
expiredToken := &db.AccessToken{
Name: "expired-token",
UserID: 1,
ScopeGist: db.ReadPermission,
ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(), // Expired yesterday
}
expiredPlain, err := expiredToken.GenerateToken()
require.NoError(t, err)
err = expiredToken.Create()
require.NoError(t, err)
// Create a valid (non-expired) token
validToken := &db.AccessToken{
Name: "valid-token",
UserID: 1,
ScopeGist: db.ReadPermission,
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), // Expires tomorrow
}
validPlain, err := validToken.GenerateToken()
require.NoError(t, err)
err = validToken.Create()
require.NoError(t, err)
s.sessionCookie = ""
// Expired token should not grant access
expiredHeaders := map[string]string{"Authorization": "Token " + expiredPlain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, expiredHeaders)
require.NoError(t, err)
// Valid token should grant access
validHeaders := map[string]string{"Authorization": "Token " + validPlain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, validHeaders)
require.NoError(t, err)
}
func TestAccessTokenWrongUser(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register two users
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
// Create a private gist for user1
gist1 := db.GistDTO{
Title: "thomas-private-gist",
Description: "thomas private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"file.txt"},
Content: []string{"content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
s.sessionCookie = ""
user2 := db.UserDTO{Username: "kaguya", Password: "kaguya"}
register(t, s, user2)
// Create token for user2
user2Token := &db.AccessToken{
Name: "kaguya-token",
UserID: 2,
ScopeGist: db.ReadPermission,
}
user2Plain, err := user2Token.GenerateToken()
require.NoError(t, err)
err = user2Token.Create()
require.NoError(t, err)
s.sessionCookie = ""
// User2's token should NOT grant access to user1's private gist
user2Headers := map[string]string{"Authorization": "Token " + user2Plain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, user2Headers)
require.NoError(t, err)
// Create token for user1
user1Token := &db.AccessToken{
Name: "thomas-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
user1Plain, err := user1Token.GenerateToken()
require.NoError(t, err)
err = user1Token.Create()
require.NoError(t, err)
// User1's token SHOULD grant access to user1's private gist
user1Headers := map[string]string{"Authorization": "Token " + user1Plain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, user1Headers)
require.NoError(t, err)
}
func TestAccessTokenLastUsedUpdate(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register user and create a private gist
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "private-gist",
Description: "my private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"file.txt"},
Content: []string{"content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
// Create token
token := &db.AccessToken{
Name: "test-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
plainToken, err := token.GenerateToken()
require.NoError(t, err)
err = token.Create()
require.NoError(t, err)
// Verify LastUsedAt is 0 initially
tokenFromDB, err := db.GetAccessTokenByID(token.ID)
require.NoError(t, err)
require.Equal(t, int64(0), tokenFromDB.LastUsedAt)
s.sessionCookie = ""
// Use the token
headers := map[string]string{"Authorization": "Token " + plainToken}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers)
require.NoError(t, err)
// 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 := Setup(t)
defer Teardown(t, s)
admin := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, admin)
gist1 := db.GistDTO{
Title: "private-gist",
Description: "my private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"file.txt"},
Content: []string{"content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
gist2 := db.GistDTO{
Title: "public-gist",
Description: "my public gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PublicVisibility,
},
Name: []string{"public.txt"},
Content: []string{"public content"},
}
err = s.Request("POST", "/", gist2, 302)
require.NoError(t, err)
gist2db, err := db.GetGistByID("2")
require.NoError(t, err)
token := &db.AccessToken{
Name: "read-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
plainToken, err := token.GenerateToken()
require.NoError(t, err)
err = token.Create()
require.NoError(t, err)
err = s.Request("PUT", "/admin-panel/set-config", settingSet{"require-login", "1"}, 200)
require.NoError(t, err)
s.sessionCookie = ""
err = s.Request("GET", "/thomas/"+gist1db.Uuid, nil, 302)
require.NoError(t, err)
err = s.Request("GET", "/thomas/"+gist2db.Uuid, nil, 302)
require.NoError(t, err)
headers := map[string]string{"Authorization": "Token " + plainToken}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers)
require.NoError(t, err)
err = s.RequestWithHeaders("GET", "/thomas/"+gist2db.Uuid, nil, 200, headers)
require.NoError(t, err)
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+"/raw/HEAD/file.txt", nil, 200, headers)
require.NoError(t, err)
invalidHeaders := map[string]string{"Authorization": "Token invalid_token"}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 302, invalidHeaders)
require.NoError(t, err)
noPermToken := &db.AccessToken{
Name: "no-perm-token",
UserID: 1,
ScopeGist: db.NoPermission,
}
noPermPlain, err := noPermToken.GenerateToken()
require.NoError(t, err)
err = noPermToken.Create()
require.NoError(t, err)
noPermHeaders := map[string]string{"Authorization": "Token " + noPermPlain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 302, noPermHeaders)
require.NoError(t, err)
}

View File

@@ -1,41 +0,0 @@
package test
import (
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"testing"
)
func TestAdminActions(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
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",
}
for _, url := range urls {
err := s.Request("POST", url, nil, 404)
require.NoError(t, err)
}
user1 := db.UserDTO{Username: "admin", Password: "admin"}
register(t, s, user1)
login(t, s, user1)
for _, url := range urls {
err := s.Request("POST", url, nil, 302)
require.NoError(t, err)
}
user2 := db.UserDTO{Username: "nonadmin", Password: "nonadmin"}
register(t, s, user2)
login(t, s, user2)
for _, url := range urls {
err := s.Request("POST", url, nil, 404)
require.NoError(t, err)
}
}

View File

@@ -1,260 +0,0 @@
package test
import (
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"os"
"path/filepath"
"strconv"
"testing"
"time"
)
func TestAdminPages(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
urls := []string{
"/admin-panel",
"/admin-panel/users",
"/admin-panel/gists",
"/admin-panel/invitations",
"/admin-panel/configuration",
}
for _, url := range urls {
err := s.Request("GET", url, nil, 404)
require.NoError(t, err)
}
user1 := db.UserDTO{Username: "admin", Password: "admin"}
register(t, s, user1)
login(t, s, user1)
for _, url := range urls {
err := s.Request("GET", url, nil, 200)
require.NoError(t, err)
}
user2 := db.UserDTO{Username: "nonadmin", Password: "nonadmin"}
register(t, s, user2)
login(t, s, user2)
for _, url := range urls {
err := s.Request("GET", url, nil, 404)
require.NoError(t, err)
}
}
func TestSetConfig(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
settings := []string{
db.SettingDisableSignup,
db.SettingRequireLogin,
db.SettingAllowGistsWithoutLogin,
db.SettingDisableLoginForm,
db.SettingDisableGravatar,
}
user1 := db.UserDTO{Username: "admin", Password: "admin"}
register(t, s, user1)
login(t, s, user1)
for _, setting := range settings {
val, err := db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "0", val)
err = s.Request("PUT", "/admin-panel/set-config", settingSet{setting, "1"}, 200)
require.NoError(t, err)
val, err = db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "1", val)
err = s.Request("PUT", "/admin-panel/set-config", settingSet{setting, "0"}, 200)
require.NoError(t, err)
val, err = db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "0", val)
}
}
func TestPagination(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
user1 := db.UserDTO{Username: "admin", Password: "admin"}
register(t, s, user1)
for i := 0; i < 11; i++ {
user := db.UserDTO{Username: "user" + strconv.Itoa(i), Password: "user" + strconv.Itoa(i)}
register(t, s, user)
}
login(t, s, user1)
err := s.Request("GET", "/admin-panel/users", nil, 200)
require.NoError(t, err)
err = s.Request("GET", "/admin-panel/users?page=2", nil, 200)
require.NoError(t, err)
err = s.Request("GET", "/admin-panel/users?page=3", nil, 404)
require.NoError(t, err)
err = s.Request("GET", "/admin-panel/users?page=0", nil, 200)
require.NoError(t, err)
err = s.Request("GET", "/admin-panel/users?page=-1", nil, 200)
require.NoError(t, err)
err = s.Request("GET", "/admin-panel/users?page=a", nil, 200)
require.NoError(t, err)
}
func TestAdminUser(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
user1 := db.UserDTO{Username: "admin", Password: "admin"}
user2 := db.UserDTO{Username: "nonadmin", Password: "nonadmin"}
register(t, s, user1)
register(t, s, user2)
login(t, s, user2)
gist1 := db.GistDTO{
Title: "gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt"},
Content: []string{"yeah"},
Topics: "",
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
_, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, user2.Username))
require.NoError(t, err)
count, err := db.CountAll(db.User{})
require.NoError(t, err)
require.Equal(t, int64(2), count)
login(t, s, user1)
err = s.Request("POST", "/admin-panel/users/2/delete", nil, 302)
require.NoError(t, err)
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, user2.Username))
require.Error(t, err)
}
func TestAdminGist(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
user1 := db.UserDTO{Username: "admin", Password: "admin"}
register(t, s, user1)
login(t, s, user1)
gist1 := db.GistDTO{
Title: "gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt"},
Content: []string{"yeah"},
Topics: "",
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
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, user1.Username, gist1Db.Identifier()))
require.NoError(t, err)
err = s.Request("POST", "/admin-panel/gists/1/delete", nil, 302)
require.NoError(t, err)
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, user1.Username, gist1Db.Identifier()))
require.Error(t, err)
}
func TestAdminInvitation(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
user1 := db.UserDTO{Username: "admin", Password: "admin"}
register(t, s, user1)
login(t, s, user1)
err := s.Request("POST", "/admin-panel/invitations", invitationAdmin{
nbMax: "",
expiredAtUnix: "",
}, 302)
require.NoError(t, err)
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)
err = s.Request("POST", "/admin-panel/invitations", invitationAdmin{
nbMax: "aa",
expiredAtUnix: "1735722000",
}, 302)
require.NoError(t, err)
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,
})
err = s.Request("POST", "/admin-panel/invitations", invitationAdmin{
nbMax: "20",
expiredAtUnix: "1735722000",
}, 302)
require.NoError(t, err)
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)
err = s.Request("POST", "/admin-panel/invitations/1/delete", nil, 302)
require.NoError(t, err)
count, err = db.CountAll(db.Invitation{})
require.NoError(t, err)
require.Equal(t, int64(2), count)
}

View File

@@ -1,414 +0,0 @@
package test
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
)
func TestRegister(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
err := s.Request("GET", "/", nil, 302)
require.NoError(t, err)
err = s.Request("GET", "/register", nil, 200)
require.NoError(t, err)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
user1db, err := db.GetUserById(1)
require.NoError(t, err)
require.Equal(t, user1.Username, user1db.Username)
require.True(t, user1db.IsAdmin)
err = s.Request("GET", "/", nil, 200)
require.NoError(t, err)
s.sessionCookie = ""
user2 := db.UserDTO{Username: "thomas", Password: "azeaze"}
err = s.Request("POST", "/register", user2, 200)
require.Error(t, err)
user3 := db.UserDTO{Username: "kaguya", Password: "kaguya"}
register(t, s, user3)
user3db, err := db.GetUserById(2)
require.NoError(t, err)
require.False(t, user3db.IsAdmin)
s.sessionCookie = ""
count, err := db.CountAll(db.User{})
require.NoError(t, err)
require.Equal(t, int64(2), count)
}
func TestLogin(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
err := s.Request("GET", "/login", nil, 200)
require.NoError(t, err)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
s.sessionCookie = ""
login(t, s, user1)
require.NotEmpty(t, s.sessionCookie)
s.sessionCookie = ""
user2 := db.UserDTO{Username: "thomas", Password: "azeaze"}
user3 := db.UserDTO{Username: "azeaze", Password: ""}
err = s.Request("POST", "/login", user2, 302)
require.Empty(t, s.sessionCookie)
require.Error(t, err)
err = s.Request("POST", "/login", user3, 302)
require.Empty(t, s.sessionCookie)
require.Error(t, err)
}
func register(t *testing.T, s *TestServer, user db.UserDTO) {
err := s.Request("POST", "/register", user, 302)
require.NoError(t, err)
}
func login(t *testing.T, s *TestServer, user db.UserDTO) {
err := s.Request("POST", "/login", user, 302)
require.NoError(t, err)
}
func TestAnonymous(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
user := db.UserDTO{Username: "thomas", Password: "azeaze"}
register(t, s, user)
err := s.Request("PUT", "/admin-panel/set-config", settingSet{"require-login", "1"}, 200)
require.NoError(t, err)
gist1 := db.GistDTO{
Title: "gist1",
Description: "my first gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"},
Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"},
Topics: "",
}
err = s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
err = s.Request("GET", "/all", nil, 200)
require.NoError(t, err)
cookie := s.sessionCookie
s.sessionCookie = ""
err = s.Request("GET", "/all", nil, 302)
require.NoError(t, err)
// Should redirect to login if RequireLogin
err = s.Request("GET", "/"+gist1db.User.Username+"/"+gist1db.Uuid, nil, 302)
require.NoError(t, err)
s.sessionCookie = cookie
err = s.Request("PUT", "/admin-panel/set-config", settingSet{"allow-gists-without-login", "1"}, 200)
require.NoError(t, err)
s.sessionCookie = ""
// Should return results
err = s.Request("GET", "/"+gist1db.User.Username+"/"+gist1db.Uuid, nil, 200)
require.NoError(t, err)
}
func TestGitOperations(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
admin := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, admin)
s.sessionCookie = ""
register(t, s, db.UserDTO{Username: "fujiwara", Password: "fujiwara"})
s.sessionCookie = ""
register(t, s, db.UserDTO{Username: "kaguya", Password: "kaguya"})
gist1 := db.GistDTO{
Title: "kaguya-pub-gist",
URL: "kaguya-pub-gist",
Description: "kaguya's first gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PublicVisibility,
},
Name: []string{"kaguya-file.txt"},
Content: []string{
"yeah",
},
Topics: "",
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist2 := db.GistDTO{
Title: "kaguya-unl-gist",
URL: "kaguya-unl-gist",
Description: "kaguya's second gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.UnlistedVisibility,
},
Name: []string{"kaguya-file.txt"},
Content: []string{
"cool",
},
Topics: "",
}
err = s.Request("POST", "/", gist2, 302)
require.NoError(t, err)
gist3 := db.GistDTO{
Title: "kaguya-priv-gist",
URL: "kaguya-priv-gist",
Description: "kaguya's second gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"kaguya-file.txt"},
Content: []string{
"super",
},
Topics: "",
}
err = s.Request("POST", "/", gist3, 302)
require.NoError(t, err)
tests := []struct {
credentials string
user string
url string
pushOptions string
expectErrorClone bool
expectErrorCheck bool
expectErrorPush bool
}{
{":", "kaguya", "kaguya-pub-gist", "", false, false, true},
{":", "kaguya", "kaguya-unl-gist", "", false, false, true},
{":", "kaguya", "kaguya-priv-gist", "", true, true, true},
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false},
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true},
}
for _, test := range tests {
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
}
login(t, s, admin)
err = s.Request("PUT", "/admin-panel/set-config", settingSet{"require-login", "1"}, 200)
require.NoError(t, err)
testsRequireLogin := []struct {
credentials string
user string
url string
pushOptions string
expectErrorClone bool
expectErrorCheck bool
expectErrorPush bool
}{
{":", "kaguya", "kaguya-pub-gist", "", true, true, true},
{":", "kaguya", "kaguya-unl-gist", "", true, true, true},
{":", "kaguya", "kaguya-priv-gist", "", true, true, true},
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false},
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true},
}
for _, test := range testsRequireLogin {
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
}
login(t, s, admin)
err = s.Request("PUT", "/admin-panel/set-config", settingSet{"allow-gists-without-login", "1"}, 200)
require.NoError(t, err)
for _, test := range tests {
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
}
}
func TestGitInit(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
admin := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, admin)
s.sessionCookie = ""
register(t, s, db.UserDTO{Username: "fujiwara", Password: "fujiwara"})
s.sessionCookie = ""
register(t, s, db.UserDTO{Username: "kaguya", Password: "kaguya"})
testsNewWithPush := []struct {
credentials string
user string
url string
pushOptions string
expectErrorClone bool
expectErrorCheck bool
expectErrorPush bool
}{
{":", "kaguya", "gist1", "", true, true, true},
{"kaguya:wrongpass", "kaguya", "gist2", "", true, true, true},
{"fujiwara:fujiwara", "kaguya", "gist3", "", true, true, true},
{"kaguya:kaguya", "kaguya", "gist4", "", false, false, false},
{"kaguya:kaguya", "kaguya", "gist5/g", "", true, true, true},
}
for _, test := range testsNewWithPush {
gitInitPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorPush)
}
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, "kaguya", gist1db.User.Username)
for _, test := range testsNewWithPush {
gitCloneCheckPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
}
count, err := db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(1), count)
testsNewWithInit := []struct {
credentials string
url string
pushOptions string
expectErrorPush bool
}{
{":", "init", "", true},
{"fujiwara:wrongpass", "init", "", true},
{"kaguya:kaguya", "init", "", false},
{"fujiwara:fujiwara", "init", "", false},
}
for _, test := range testsNewWithInit {
gitInitPush(t, test.credentials, "kaguya", test.url, "newfile.txt", test.pushOptions, test.expectErrorPush)
}
count, err = db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(3), count)
gist2db, err := db.GetGistByID("2")
require.NoError(t, err)
require.Equal(t, "kaguya", gist2db.User.Username)
gist3db, err := db.GetGistByID("3")
require.NoError(t, err)
require.Equal(t, "fujiwara", gist3db.User.Username)
}
func clientGitClone(creds string, user string, url string) error {
return exec.Command("git", "clone", "http://"+creds+"@localhost:6157/"+user+"/"+url, filepath.Join(config.GetHomeDir(), "tmp", url)).Run()
}
func clientGitPush(url string, pushOptions string, file string) error {
f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, file))
if err != nil {
return err
}
_, _ = f.WriteString("new file")
_ = f.Close()
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", file).Run()
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "commit", "-m", "new file").Run()
if pushOptions != "" {
err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", pushOptions, "origin").Run()
} else {
err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", "origin").Run()
}
_ = os.RemoveAll(filepath.Join(config.GetHomeDir(), "tmp", url))
return err
}
func clientGitInit(path string) error {
return exec.Command("git", "init", "--initial-branch=master", filepath.Join(config.GetHomeDir(), "tmp", path)).Run()
}
func clientGitSetRemote(path string, remoteName string, remoteUrl string) error {
return exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", path), "remote", "add", remoteName, remoteUrl).Run()
}
func clientCheckRepo(url string, file string) error {
_, err := os.ReadFile(filepath.Join(config.GetHomeDir(), "tmp", url, file))
return err
}
func gitCloneCheckPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorClone, expectErrorCheck, expectErrorPush bool) {
log.Debug().Msgf("Testing %s %s %t %t %t", credentials, url, expectErrorClone, expectErrorCheck, expectErrorPush)
err := clientGitClone(credentials, owner, url)
if expectErrorClone {
require.Error(t, err)
} else {
require.NoError(t, err)
}
err = clientCheckRepo(url, filename)
if expectErrorCheck {
require.Error(t, err)
} else {
require.NoError(t, err)
}
err = clientGitPush(url, pushOptions, filename)
if expectErrorPush {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}
func gitInitPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorPush bool) {
log.Debug().Msgf("Testing %s %s %t", credentials, url, expectErrorPush)
err := clientGitInit(url)
require.NoError(t, err)
if url == "init" {
err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/init/")
} else {
err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/"+owner+"/"+url)
}
require.NoError(t, err)
err = clientGitPush(url, pushOptions, filename)
if expectErrorPush {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}

View File

@@ -1,342 +0,0 @@
package test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
)
func TestGists(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
err := s.Request("GET", "/", nil, 302)
require.NoError(t, err)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
err = s.Request("GET", "/all", nil, 200)
require.NoError(t, err)
err = s.Request("POST", "/", nil, 400)
require.NoError(t, err)
gist1 := db.GistDTO{
Title: "gist1",
Description: "my first gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"},
Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"},
Topics: "",
}
err = s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, uint(1), gist1db.ID)
require.Equal(t, gist1.Title, gist1db.Title)
require.Equal(t, gist1.Description, gist1db.Description)
require.Regexp(t, "[a-f0-9]{32}", gist1db.Uuid)
require.Equal(t, user1.Username, gist1db.User.Username)
err = s.Request("GET", "/"+gist1db.User.Username+"/"+gist1db.Uuid, nil, 200)
require.NoError(t, err)
gist1files, err := git.GetFilesOfRepository(gist1db.User.Username, gist1db.Uuid, "HEAD")
require.NoError(t, err)
require.Equal(t, 3, len(gist1files))
gist1fileContent, _, err := git.GetFileContent(gist1db.User.Username, gist1db.Uuid, "HEAD", gist1.Name[0], false)
require.NoError(t, err)
require.Equal(t, gist1.Content[0], gist1fileContent)
gist2 := db.GistDTO{
Title: "gist2",
Description: "my second gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"", "gist2.txt", "gist3.txt"},
Content: []string{"", "yeah\ncool", "yeah\ncool gist actually"},
Topics: "",
}
err = s.Request("POST", "/", gist2, 302)
require.NoError(t, err)
gist3 := db.GistDTO{
Title: "gist3",
Description: "my third gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{""},
Content: []string{"yeah"},
Topics: "",
}
err = s.Request("POST", "/", gist3, 302)
require.NoError(t, err)
gist3db, err := db.GetGistByID("3")
require.NoError(t, err)
gist3files, err := git.GetFilesOfRepository(gist3db.User.Username, gist3db.Uuid, "HEAD")
require.NoError(t, err)
require.Equal(t, "gistfile1.txt", gist3files[0])
err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/edit", nil, 400)
require.NoError(t, err)
gist1.Name = []string{"gist1.txt"}
gist1.Content = []string{"only want one gist"}
err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/edit", gist1, 302)
require.NoError(t, err)
gist1files, err = git.GetFilesOfRepository(gist1db.User.Username, gist1db.Uuid, "HEAD")
require.NoError(t, err)
require.Equal(t, 1, len(gist1files))
err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/delete", nil, 302)
require.NoError(t, err)
}
func TestVisibility(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "gist1",
Description: "my first gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.UnlistedVisibility,
},
Name: []string{""},
Content: []string{"yeah"},
Topics: "",
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, db.UnlistedVisibility, gist1db.Private)
err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/visibility", db.VisibilityDTO{Private: db.PrivateVisibility}, 302)
require.NoError(t, err)
gist1db, err = db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, db.PrivateVisibility, gist1db.Private)
err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/visibility", db.VisibilityDTO{Private: db.PublicVisibility}, 302)
require.NoError(t, err)
gist1db, err = db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, db.PublicVisibility, gist1db.Private)
err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/visibility", db.VisibilityDTO{Private: db.UnlistedVisibility}, 302)
require.NoError(t, err)
gist1db, err = db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, db.UnlistedVisibility, gist1db.Private)
}
func TestLikeFork(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "gist1",
Description: "my first gist",
VisibilityDTO: db.VisibilityDTO{
Private: 1,
},
Name: []string{""},
Content: []string{"yeah"},
Topics: "",
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
s.sessionCookie = ""
user2 := db.UserDTO{Username: "kaguya", Password: "kaguya"}
register(t, s, user2)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, 0, gist1db.NbLikes)
likeCount, err := db.CountAll(db.Like{})
require.NoError(t, err)
require.Equal(t, int64(0), likeCount)
err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/like", nil, 302)
require.NoError(t, err)
gist1db, err = db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, 1, gist1db.NbLikes)
likeCount, err = db.CountAll(db.Like{})
require.NoError(t, err)
require.Equal(t, int64(1), likeCount)
err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/like", nil, 302)
require.NoError(t, err)
gist1db, err = db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, 0, gist1db.NbLikes)
likeCount, err = db.CountAll(db.Like{})
require.NoError(t, err)
require.Equal(t, int64(0), likeCount)
err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/fork", nil, 302)
require.NoError(t, err)
gist2db, err := db.GetGistByID("2")
require.NoError(t, err)
require.Equal(t, gist1db.Title, gist2db.Title)
require.Equal(t, gist1db.Description, gist2db.Description)
require.Equal(t, gist1db.Private, gist2db.Private)
require.Equal(t, user2.Username, gist2db.User.Username)
}
func TestCustomUrl(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "gist1",
URL: "my-gist",
Description: "my first gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"},
Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"},
Topics: "",
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, uint(1), gist1db.ID)
require.Equal(t, gist1.Title, gist1db.Title)
require.Equal(t, gist1.Description, gist1db.Description)
require.Regexp(t, "[a-f0-9]{32}", gist1db.Uuid)
require.Equal(t, gist1.URL, gist1db.URL)
require.Equal(t, user1.Username, gist1db.User.Username)
gist1dbUuid, err := db.GetGist(user1.Username, gist1db.Uuid)
require.NoError(t, err)
require.Equal(t, gist1db, gist1dbUuid)
gist1dbUrl, err := db.GetGist(user1.Username, gist1.URL)
require.NoError(t, err)
require.Equal(t, gist1db, gist1dbUrl)
require.Equal(t, gist1.URL, gist1db.Identifier())
gist2 := db.GistDTO{
Title: "gist2",
Description: "my second gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"},
Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"},
Topics: "",
}
err = s.Request("POST", "/", gist2, 302)
require.NoError(t, err)
gist2db, err := db.GetGistByID("2")
require.NoError(t, err)
require.Equal(t, gist2db.Uuid, gist2db.Identifier())
require.NotEqual(t, gist2db.URL, gist2db.Identifier())
}
func TestTopics(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "gist1",
URL: "my-gist",
Description: "my first gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"},
Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"},
Topics: "topic1 topic2 topic3",
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, []db.GistTopic{
{GistID: 1, Topic: "topic1"},
{GistID: 1, Topic: "topic2"},
{GistID: 1, Topic: "topic3"},
}, gist1db.Topics)
gist2 := db.GistDTO{
Title: "gist2",
URL: "my-gist",
Description: "my second gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"},
Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"},
Topics: "topic1 topic2 topic3 topic2 topic4 topic1",
}
err = s.Request("POST", "/", gist2, 302)
require.NoError(t, err)
gist2db, err := db.GetGistByID("2")
require.NoError(t, err)
require.Equal(t, []db.GistTopic{
{GistID: 2, Topic: "topic1"},
{GistID: 2, Topic: "topic2"},
{GistID: 2, Topic: "topic3"},
{GistID: 2, Topic: "topic4"},
}, gist2db.Topics)
gist3 := db.GistDTO{
Title: "gist3",
URL: "my-gist",
Description: "my third gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"},
Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"},
Topics: "topic1 topic2 topic3 topic4 topic5 topic6 topic7 topic8 topic9 topic10 topic11",
}
err = s.Request("POST", "/", gist3, 400)
require.NoError(t, err)
gist3.Topics = "topictoolongggggggggggggggggggggggggggggggggggggggg"
err = s.Request("POST", "/", gist3, 400)
require.NoError(t, err)
}

View File

@@ -1,8 +1,6 @@
package test package test
import ( import (
"errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -10,80 +8,74 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect"
"runtime"
"strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/rs/zerolog/log" "github.com/gorilla/schema"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/index"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers/metrics" "github.com/thomiceli/opengist/internal/web/handlers/metrics"
"github.com/thomiceli/opengist/internal/web/server" "github.com/thomiceli/opengist/internal/web/server"
) )
var databaseType string var databaseType string
var formEncoder *schema.Encoder
type TestServer struct { func init() {
formEncoder = schema.NewEncoder()
formEncoder.SetAliasTag("form")
}
type Server struct {
server *server.Server server *server.Server
sessionCookie string SessionCookie string
contextData echo.Map
} }
func newTestServer() (*TestServer, error) { func (s *Server) Request(t *testing.T, method, uri string, data interface{}, expectedCode int) *http.Response {
s := &TestServer{ return s.RequestWithHeaders(t, method, uri, data, expectedCode, nil)
server: server.NewServer(true, filepath.Join(config.GetHomeDir(), "tmp", "sessions"), true),
}
go s.start()
return s, nil
} }
func (s *TestServer) start() { func (s *Server) RequestWithHeaders(t *testing.T, method, uri string, data interface{}, expectedCode int, headers map[string]string) *http.Response {
s.server.Start()
}
func (s *TestServer) stop() {
s.server.Stop()
}
func (s *TestServer) Request(method, uri string, data interface{}, expectedCode int, responsePtr ...*http.Response) error {
return s.RequestWithHeaders(method, uri, data, expectedCode, nil, responsePtr...)
}
func (s *TestServer) RequestWithHeaders(method, uri string, data interface{}, expectedCode int, headers map[string]string, responsePtr ...*http.Response) error {
var bodyReader io.Reader var bodyReader io.Reader
if method == http.MethodPost || method == http.MethodPut { if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete {
values := structToURLValues(data) if values, ok := data.(url.Values); ok {
bodyReader = strings.NewReader(values.Encode()) bodyReader = strings.NewReader(values.Encode())
} else if data != nil {
values := url.Values{}
_ = formEncoder.Encode(data, values)
bodyReader = strings.NewReader(values.Encode())
}
} }
req := httptest.NewRequest(method, "http://localhost:6157"+uri, bodyReader) req := httptest.NewRequest(method, uri, bodyReader)
w := httptest.NewRecorder() w := httptest.NewRecorder()
if method == http.MethodPost || method == http.MethodPut { if method == http.MethodPost || method == http.MethodPut {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} }
req.Header.Set("Sec-Fetch-Site", "same-origin")
for key, value := range headers { for key, value := range headers {
req.Header.Set(key, value) req.Header.Set(key, value)
} }
if s.sessionCookie != "" { if s.SessionCookie != "" {
req.AddCookie(&http.Cookie{Name: "session", Value: s.sessionCookie}) req.AddCookie(&http.Cookie{Name: "session", Value: s.SessionCookie})
} }
s.server.ServeHTTP(w, req) s.server.ServeHTTP(w, req)
if expectedCode != 0 {
if w.Code != expectedCode { require.Equalf(t, expectedCode, w.Code, "Unexpected status code for %s %s: got %d, expected %d", method, uri, w.Code, expectedCode)
return fmt.Errorf("unexpected status code %d, expected %d", w.Code, expectedCode)
} }
if method == http.MethodPost { if method == http.MethodPost {
if strings.Contains(uri, "/login") || strings.Contains(uri, "/register") { if strings.Contains(uri, "/login") {
cookie := "" cookie := ""
h := w.Header().Get("Set-Cookie") h := w.Header().Get("Set-Cookie")
parts := strings.Split(h, "; ") parts := strings.Split(h, "; ")
@@ -93,91 +85,127 @@ func (s *TestServer) RequestWithHeaders(method, uri string, data interface{}, ex
break break
} }
} }
if cookie == "" { s.SessionCookie = strings.TrimPrefix(cookie, "session=")
return errors.New("unable to find access session token in response headers")
}
s.sessionCookie = strings.TrimPrefix(cookie, "session=")
} else if strings.Contains(uri, "/logout") { } else if strings.Contains(uri, "/logout") {
s.sessionCookie = "" s.SessionCookie = ""
} }
} }
// If a response pointer was provided, fill it with the response data return w.Result()
if len(responsePtr) > 0 && responsePtr[0] != nil { }
*responsePtr[0] = *w.Result()
func (s *Server) RawRequest(t *testing.T, req *http.Request, expectedCode int) *http.Response {
w := httptest.NewRecorder()
req.Header.Set("Sec-Fetch-Site", "same-origin")
if s.SessionCookie != "" {
req.AddCookie(&http.Cookie{Name: "session", Value: s.SessionCookie})
} }
s.server.ServeHTTP(w, req)
require.Equal(t, expectedCode, w.Code, "unexpected status code for %s %s", req.Method, req.URL.Path)
return w.Result()
}
func (s *Server) StartHttpServer(t *testing.T) string {
hs := httptest.NewServer(s.server)
t.Cleanup(hs.Close)
return hs.URL
}
func (s *Server) User() *db.User {
s.Request(nil, "GET", "/", nil, 0)
if user, ok := s.contextData["userLogged"].(*db.User); ok {
return user
}
return nil return nil
} }
func structToURLValues(s interface{}) url.Values { func (s *Server) TestCtxData(t *testing.T, expected echo.Map) {
v := url.Values{} for key, expectedValue := range expected {
if s == nil { actualValue, exists := s.contextData[key]
return v require.True(t, exists, "Key %q not found in context data", key)
require.Equal(t, expectedValue, actualValue, "Context data mismatch for key %q", key)
} }
rValue := reflect.ValueOf(s)
if rValue.Kind() != reflect.Struct {
return v
}
for i := 0; i < rValue.NumField(); i++ {
field := rValue.Type().Field(i)
tag := field.Tag.Get("form")
if tag != "" || field.Anonymous {
if field.Type.Kind() == reflect.Int {
fieldValue := rValue.Field(i).Int()
v.Add(tag, strconv.FormatInt(fieldValue, 10))
} else if field.Type.Kind() == reflect.Uint {
fieldValue := rValue.Field(i).Uint()
v.Add(tag, strconv.FormatUint(fieldValue, 10))
} else if field.Type.Kind() == reflect.Slice {
fieldValue := rValue.Field(i).Interface().([]string)
for _, va := range fieldValue {
v.Add(tag, va)
}
} else if field.Type.Kind() == reflect.Struct {
for key, val := range structToURLValues(rValue.Field(i).Interface()) {
for _, vv := range val {
v.Add(key, vv)
}
}
} else {
fieldValue := rValue.Field(i).String()
v.Add(tag, fieldValue)
}
}
}
return v
} }
func Setup(t *testing.T) *TestServer { func (s *Server) Register(t *testing.T, user string) {
_ = os.Setenv("OPENGIST_SKIP_GIT_HOOKS", "1") s.Request(t, "POST", "/register", db.UserDTO{Username: user, Password: user}, 302)
}
func (s *Server) Login(t *testing.T, user string) {
s.Request(t, "POST", "/login", db.UserDTO{Username: user, Password: user}, 302)
}
func (s *Server) Logout() {
s.SessionCookie = ""
}
func (s *Server) CreateGist(t *testing.T, visibility string) (gistPath string, gist *db.Gist, username, identifier string) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "thomas", Password: "thomas"}, 0)
s.Login(t, "thomas")
resp := s.Request(t, "POST", "/", url.Values{
"title": {"Test"},
"name": {"file.txt", "otherfile.txt"},
"content": {"hello world", "other content"},
"topics": {"hello opengist"},
"private": {visibility},
}, 302)
// Extract gist identifier from redirect
location := resp.Header.Get("Location")
parts := strings.Split(strings.TrimPrefix(location, "/"), "/")
require.Len(t, parts, 2, "Expected redirect format: /{username}/{identifier}")
gistUsername := parts[0]
gistIdentifier := parts[1]
gist, err := db.GetGist(gistUsername, gistIdentifier)
require.NoError(t, err)
require.NotNil(t, gist)
gistPath = filepath.Join(config.GetHomeDir(), git.ReposDirectory, "thomas", gist.Uuid)
// Verify gist exists on filesystem
_, err = os.Stat(gistPath)
require.NoError(t, err, "Gist repository should exist at %s", gistPath)
username = gist.User.Username
identifier = gist.Identifier()
s.Logout()
return gistPath, gist, username, identifier
}
func Setup(t *testing.T) *Server {
tmpDir := t.TempDir()
t.Setenv("OPENGIST_SKIP_GIT_HOOKS", "1")
err := config.InitConfig("", io.Discard) err := config.InitConfig("", io.Discard)
require.NoError(t, err, "Could not init config") require.NoError(t, err, "Could not init config")
err = os.MkdirAll(filepath.Join(config.GetHomeDir()), 0755) config.C.LogLevel = "warn"
require.NoError(t, err, "Could not create Opengist home directory") config.C.LogOutput = "stdout"
config.C.GitDefaultBranch = "master"
config.C.OpengistHome = tmpDir
config.SetupSecretKey() config.SetupSecretKey()
git.ReposDirectory = filepath.Join("tests")
config.C.Index = ""
config.C.LogLevel = "error"
config.C.GitDefaultBranch = "master"
config.InitLog() config.InitLog()
tmpGitConfig := filepath.Join(tmpDir, "gitconfig")
t.Setenv("GIT_CONFIG_GLOBAL", tmpGitConfig)
err = exec.Command("git", "config", "--global", "--type", "bool", "push.autoSetupRemote", "true").Run() err = exec.Command("git", "config", "--global", "--type", "bool", "push.autoSetupRemote", "true").Run()
require.NoError(t, err) require.NoError(t, err)
err = exec.Command("git", "config", "--global", "user.email", "test@opengist.io").Run() err = exec.Command("git", "config", "--global", "user.email", "test@opengist.io").Run()
require.NoError(t, err) require.NoError(t, err)
err = exec.Command("git", "config", "--global", "user.name", "test").Run() err = exec.Command("git", "config", "--global", "user.name", "test").Run()
require.NoError(t, err) require.NoError(t, err)
homePath := config.GetHomeDir() homePath := config.GetHomeDir()
log.Info().Msg("Data directory: " + homePath)
var databaseDsn string var databaseDsn string
databaseType = os.Getenv("OPENGIST_TEST_DB") databaseType = os.Getenv("OPENGIST_TEST_DB")
@@ -187,70 +215,55 @@ func Setup(t *testing.T) *TestServer {
case "mysql": case "mysql":
databaseDsn = "mysql://root:opengist@localhost:3306/opengist_test" databaseDsn = "mysql://root:opengist@localhost:3306/opengist_test"
default: default:
databaseDsn = "file:" + filepath.Join(homePath, "tmp", "opengist_test.db") databaseDsn = config.C.DBUri
} }
err = os.MkdirAll(filepath.Join(homePath, "tests"), 0755) err = os.MkdirAll(filepath.Join(homePath, "sessions"), 0755)
require.NoError(t, err, "Could not create tests directory")
err = os.MkdirAll(filepath.Join(homePath, "tmp", "sessions"), 0755)
require.NoError(t, err, "Could not create sessions directory") require.NoError(t, err, "Could not create sessions directory")
err = os.MkdirAll(filepath.Join(homePath, "repos"), 0755)
require.NoError(t, err, "Could not create repos directory")
err = os.MkdirAll(filepath.Join(homePath, "tmp", "repos"), 0755) err = os.MkdirAll(filepath.Join(homePath, "tmp", "repos"), 0755)
require.NoError(t, err, "Could not create tmp repos directory") require.NoError(t, err, "Could not create tmp repos directory")
err = os.MkdirAll(filepath.Join(homePath, "custom"), 0755)
require.NoError(t, err, "Could not create custom directory")
err = db.Setup(databaseDsn) err = db.Setup(databaseDsn)
require.NoError(t, err, "Could not initialize database") require.NoError(t, err, "Could not initialize database")
if err != nil { t.Cleanup(func() {
log.Fatal().Err(err).Msg("Could not initialize database") db.Close()
})
if index.IndexEnabled() {
go index.NewIndexer(index.IndexType())
} }
// err = index.Open(filepath.Join(homePath, "testsindex", "opengist.index")) s := &Server{
// require.NoError(t, err, "Could not open index") server: server.NewServer(true),
}
s, err := newTestServer() s.server.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
require.NoError(t, err, "Failed to create test server") return func(c echo.Context) error {
err := next(c)
if data, ok := c.Request().Context().Value(context.DataKeyStr).(echo.Map); ok {
s.contextData = data
}
return err
}
})
return s return s
} }
func Teardown(t *testing.T, s *TestServer) { func Teardown(t *testing.T) {
s.stop() switch databaseType {
case "postgres", "mysql":
//err := db.Close() err := db.TruncateDatabase()
//require.NoError(t, err, "Could not close database") require.NoError(t, err, "Could not truncate database")
err := db.TruncateDatabase()
require.NoError(t, err, "Could not truncate database")
err = os.RemoveAll(filepath.Join(config.GetHomeDir(), "tests"))
require.NoError(t, err, "Could not remove repos directory")
if runtime.GOOS == "windows" {
err = db.Close()
require.NoError(t, err, "Could not close database")
time.Sleep(2 * time.Second)
} }
err = os.RemoveAll(filepath.Join(config.GetHomeDir(), "tmp"))
require.NoError(t, err, "Could not remove tmp directory")
// err = os.RemoveAll(path.Join(config.C.OpengistHome, "testsindex"))
// require.NoError(t, err, "Could not remove repos directory")
// err = index.Close()
// require.NoError(t, err, "Could not close index")
}
type settingSet struct {
key string `form:"key"`
value string `form:"value"`
}
type invitationAdmin struct {
nbMax string `form:"nbMax"`
expiredAtUnix string `form:"expiredAtUnix"`
} }
func NewTestMetricsServer() *metrics.Server { func NewTestMetricsServer() *metrics.Server {

View File

@@ -1,22 +0,0 @@
package test
import (
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"testing"
)
func TestSettingsPage(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
err := s.Request("GET", "/settings", nil, 302)
require.NoError(t, err)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
login(t, s, user1)
err = s.Request("GET", "/settings", nil, 200)
require.NoError(t, err)
}

1441
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,24 +12,24 @@
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@catppuccin/highlightjs": "^1.0.1", "@catppuccin/highlightjs": "^1.0.1",
"@codemirror/commands": "^6.10.1", "@codemirror/commands": "^6.10.3",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.1", "@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.6.0",
"@codemirror/text": "^0.19.6", "@codemirror/text": "^0.19.6",
"@codemirror/view": "^6.39.11", "@codemirror/view": "^6.41.1",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.2.4",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.9.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"katex": "^0.16.28", "katex": "^0.16.45",
"marked": "^17.0.1", "marked": "^18.0.2",
"nodemon": "^3.1.11", "nodemon": "^3.1.14",
"pdfobject": "^2.3.1", "pdfobject": "^2.3.1",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.2.4",
"vite": "^7.3.1" "vite": "^8.0.10"
} }
} }

View File

@@ -35,6 +35,14 @@
--border-width-1: 1px; --border-width-1: 1px;
} }
@layer base {
:host {
--tw-border-style: solid;
--tw-border-width: 0;
display: block;
}
}
.opengist-embed { .opengist-embed {
@import "tailwindcss"; @import "tailwindcss";
@layer base { @layer base {

View File

@@ -116,10 +116,12 @@
<div class="p-4 text-xs space-y-1"> <div class="p-4 text-xs space-y-1">
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">user:thomas</code> {{ .locale.Tr "gist.search.help.user" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">user:thomas</code> {{ .locale.Tr "gist.search.help.user" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">title:mygist</code> {{ .locale.Tr "gist.search.help.title" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">title:mygist</code> {{ .locale.Tr "gist.search.help.title" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">description:sync</code> {{ .locale.Tr "gist.search.help.description" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">filename:myfile.txt</code> {{ .locale.Tr "gist.search.help.filename" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">filename:myfile.txt</code> {{ .locale.Tr "gist.search.help.filename" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">extension:yml</code> {{ .locale.Tr "gist.search.help.extension" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">extension:yml</code> {{ .locale.Tr "gist.search.help.extension" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">language:go</code> {{ .locale.Tr "gist.search.help.language" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">language:go</code> {{ .locale.Tr "gist.search.help.language" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">topic:homelab</code> {{ .locale.Tr "gist.search.help.topic" }}</p> <p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">topic:homelab</code> {{ .locale.Tr "gist.search.help.topic" }}</p>
<p class="text-gray-400"><code class="text-slate-800 dark:text-slate-300 pr-1">all:systemctl</code> {{ .locale.Tr "gist.search.help.all" }}</p>
</div> </div>
</div> </div>
</div> </div>

85
templates/pages/oauth_register.html vendored Normal file
View File

@@ -0,0 +1,85 @@
{{ template "header" .}}
<div class="py-10">
<header>
<h1 class="text-2xl font-bold leading-tight text-slate-700 dark:text-slate-300">
{{ .title }}
</h1>
</header>
<main class="mt-4">
<div class="grid sm:grid-cols-2">
<div class="">
<div class="mt-8 sm:w-full sm:max-w-md">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div class="mb-6 text-center">
{{ if .oauthAvatarURL }}
<img src="{{ .oauthAvatarURL }}" alt="Avatar" class="w-16 h-16 rounded-full mx-auto mb-2">
{{ end }}
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ .locale.Tr "auth.oauth.signing-in-with" $.c.OIDCProviderName }}
</p>
</div>
<form class="space-y-6" method="post">
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300">
{{ .locale.Tr "auth.username" }}
</label>
<div class="mt-1">
<input id="username" name="username" type="text" value="{{ .oauthNickname }}" required
class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div>
</div>
<div class="mt-8">
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300">
{{ .locale.Tr "settings.email" }}
</label>
<div class="mt-1">
<input id="email" name="email" type="email" value="{{ .oauthEmail }}"
class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ .locale.Tr "settings.email-help" }}
</p>
</div>
<div class="flex">
<div class="flex-auto">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
{{ .locale.Tr "auth.oauth.complete-registration-button" }}
</button>
</div>
<span class="float-right text-sm py-2 underline">
<a href="{{ $.c.ExternalUrl }}/login">{{ .locale.Tr "auth.oauth.cancel" }}</a>
</span>
</div>
{{ .csrfHtml }}
</form>
</div>
</div>
</div>
<div class="">
<div class="mt-8 sm:w-full sm:max-w-md">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<p class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.oauth.existing-account" }}</p>
<div class="flex items-center justify-center mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-14 text-gray-400">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
</div>
<p class="mt-4 text-sm text-center">
{{ .locale.Tr "auth.oauth.already-have-account" $.c.OIDCProviderName }}
</p>
<div class="flex items-center justify-center mt-4">
<a href="{{ $.c.ExternalUrl }}/login" class="inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-slate-700 dark:text-slate-300 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
{{ .locale.Tr "auth.login" }}
</a>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
{{ template "footer" .}}