Compare commits

...

124 Commits

Author SHA1 Message Date
Anonymous
69b9b215d6 Translated using Weblate (Ukrainian)
Currently translated at 69.4% (243 of 350 strings)

Translation: Opengist/Opengist
Translate-URL: http://tr.opengist.io/projects/_/opengist/uk/
2026-03-09 23:52:08 +00:00
Thomas Miceli
f8b3bbce6a Rebuild search index in admin options (#647)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-09 07:30:28 +08:00
Johannes Kirchner
a697b0f273 fix: port template string and updateStrategy indentation (#643) 2026-03-09 05:50:07 +08:00
Thomas Miceli
33cbfb0904 Bump meili version (#646)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-03-09 05:40:16 +08:00
dependabot[bot]
dfea4eb435 Bump github.com/meilisearch/meilisearch-go from 0.36.0 to 0.36.1 (#634)
Bumps [github.com/meilisearch/meilisearch-go](https://github.com/meilisearch/meilisearch-go) from 0.36.0 to 0.36.1.
- [Release notes](https://github.com/meilisearch/meilisearch-go/releases)
- [Commits](https://github.com/meilisearch/meilisearch-go/compare/v0.36.0...v0.36.1)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: FunNikita <mainik1111@icloud.com>
2026-01-27 23:27:11 +08:00
Thomas Miceli
48f2c4f5c8 Update Go + JS deps (#603) 2026-01-27 15:02:37 +08:00
Thomas Miceli
5ddea2265d Add access tokens (#602) 2026-01-27 14:43:12 +08:00
Nova Cat
1128a81071 Ignore TCP errors (#601) 2026-01-27 13:49:37 +08:00
Thomas Miceli
145bf9d81a Move Prom metrics to a dedicated port + improve Helm chart (#599) 2026-01-26 17:28:51 +08:00
Thomas Miceli
24d0918e73 Resize editor (#600) 2026-01-25 22:40:32 +08:00
Thomas Miceli
4ff71fb255 Translations update from Opengist (#516)
* Translated using Weblate (German)

Currently translated at 98.1% (310 of 316 strings)

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

* Translated using Weblate (Italian)

Currently translated at 99.3% (318 of 320 strings)

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

---------

Co-authored-by: Marc <mbg14.gaming@gmail.com>
Co-authored-by: HardcodedNyxie <leonardotoschi07@gmail.com>
2026-01-25 22:16:40 +08:00
Thomas Miceli
67f7c4cadd Allow unicode letters/numbers in topics (#597) 2026-01-25 22:08:14 +08:00
dependabot[bot]
a17effb10f Bump @codemirror/view from 6.39.7 to 6.39.8 (#593)
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.39.7 to 6.39.8.
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.39.7...6.39.8)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-version: 6.39.8
  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-01-21 09:24:33 +08:00
dependabot[bot]
b2161d8859 Bump github.com/meilisearch/meilisearch-go from 0.35.0 to 0.35.1 (#591)
Bumps [github.com/meilisearch/meilisearch-go](https://github.com/meilisearch/meilisearch-go) from 0.35.0 to 0.35.1.
- [Release notes](https://github.com/meilisearch/meilisearch-go/releases)
- [Commits](https://github.com/meilisearch/meilisearch-go/compare/v0.35.0...v0.35.1)

---
updated-dependencies:
- dependency-name: github.com/meilisearch/meilisearch-go
  dependency-version: 0.35.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-01-21 09:24:23 +08:00
dependabot[bot]
61bb22ebe9 Bump github.com/yuin/goldmark from 1.7.13 to 1.7.15 (#592)
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.13 to 1.7.15.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.13...v1.7.15)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-version: 1.7.15
  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-01-21 09:24:14 +08:00
dependabot[bot]
6813c14e3a Bump github.com/labstack/echo/v4 from 4.14.0 to 4.15.0 (#590)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.14.0 to 4.15.0.
- [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.14.0...v4.15.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.15.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-01-21 09:24:05 +08:00
Guillem Riera Galmés
4ae25144a0 Adds StatefulSet support (#549)
* Adds StatefulSet support

# Conflicts:
#	helm/opengist/templates/pvc.yaml

* Adds statefulset support for replicaCount gt 1

* Improves the setup of multiple replicas in a stateful set

* Adds config wrangling logic to the secret template

* Adds shared PV functionality

* Adds missing pvc-shared template

* Adds stateful set and documentation

---------

Co-authored-by: Guillem Riera <guillem@rieragalm.es>
2026-01-21 09:22:44 +08:00
Thomas Miceli
03420e4f91 Fix img 2026-01-18 18:30:46 +08:00
Zheyi Zhu
22376d6cd3 [helm] use existing pvc claim of provided (#547) 2025-12-28 17:39:38 +08:00
Michael M. Chang
f3dc45fe0f fix: reduce footprint of docker builds (#515)
* fix: reduce footprint of docker builds

- bump to alpine 3.22
- don't add build dependencies to final image
- add runtime depencies, devtools to dev image

* fix base image deps

---------

Co-authored-by: Thomas Miceli <27960254+thomiceli@users.noreply.github.com>
2025-12-28 16:37:57 +08:00
Thomas Miceli
7b4dab143b Update Meili to 0.35.0 (#588) 2025-12-28 14:53:48 +08:00
dependabot[bot]
f874b81e2e Bump @codemirror/commands from 6.9.0 to 6.10.1 (#587)
Bumps [@codemirror/commands](https://github.com/codemirror/commands) from 6.9.0 to 6.10.1.
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/compare/6.9.0...6.10.1)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.10.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>
2025-12-28 13:34:08 +08:00
dependabot[bot]
5fe6238da1 Bump github.com/labstack/echo/v4 from 4.13.4 to 4.14.0 (#584)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.13.4 to 4.14.0.
- [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.13.4...v4.14.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.14.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>
2025-12-28 13:33:43 +08:00
dependabot[bot]
f4e472a77b Bump @tailwindcss/forms from 0.5.10 to 0.5.11 (#583)
Bumps [@tailwindcss/forms](https://github.com/tailwindlabs/tailwindcss-forms) from 0.5.10 to 0.5.11.
- [Release notes](https://github.com/tailwindlabs/tailwindcss-forms/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss-forms/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss-forms/compare/v0.5.10...v0.5.11)

---
updated-dependencies:
- dependency-name: "@tailwindcss/forms"
  dependency-version: 0.5.11
  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>
2025-12-28 13:31:52 +08:00
dependabot[bot]
4350a66afd Bump github.com/alecthomas/chroma/v2 from 2.20.0 to 2.21.1 (#582)
Bumps [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) from 2.20.0 to 2.21.1.
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.20.0...v2.21.1)

---
updated-dependencies:
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-version: 2.21.1
  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>
2025-12-28 13:31:36 +08:00
dependabot[bot]
8a958de3d7 Bump github.com/go-webauthn/webauthn from 0.14.0 to 0.15.0 (#585)
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.14.0 to 0.15.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.14.0...v0.15.0)

---
updated-dependencies:
- dependency-name: github.com/go-webauthn/webauthn
  dependency-version: 0.15.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>
2025-12-28 13:31:09 +08:00
dependabot[bot]
871cb356b7 Bump @tailwindcss/vite from 4.1.14 to 4.1.18 (#586)
Bumps [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) from 4.1.14 to 4.1.18.
- [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.1.18/packages/@tailwindcss-vite)

---
updated-dependencies:
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.1.18
  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>
2025-12-28 13:30:34 +08:00
dependabot[bot]
0958e80d8e Bump marked from 16.4.1 to 17.0.1 (#581)
Bumps [marked](https://github.com/markedjs/marked) from 16.4.1 to 17.0.1.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v16.4.1...v17.0.1)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 17.0.1
  dependency-type: direct:development
  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>
2025-12-28 13:30:01 +08:00
dependabot[bot]
cc27899b6c Bump gorm.io/gorm from 1.31.0 to 1.31.1 (#580)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.31.0 to 1.31.1.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.31.0...v1.31.1)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-version: 1.31.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>
2025-12-28 13:28:34 +08:00
dependabot[bot]
256da0077a Bump @codemirror/language from 6.11.3 to 6.12.1 (#579)
Bumps [@codemirror/language](https://github.com/codemirror/language) from 6.11.3 to 6.12.1.
- [Changelog](https://github.com/codemirror/language/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/language/compare/6.11.3...6.12.1)

---
updated-dependencies:
- dependency-name: "@codemirror/language"
  dependency-version: 6.12.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>
2025-12-28 13:27:59 +08:00
dependabot[bot]
0e5007dbad Bump nodemon from 3.1.10 to 3.1.11 (#578)
Bumps [nodemon](https://github.com/remy/nodemon) from 3.1.10 to 3.1.11.
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.10...v3.1.11)

---
updated-dependencies:
- dependency-name: nodemon
  dependency-version: 3.1.11
  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>
2025-12-28 03:34:50 +08:00
dependabot[bot]
91de091874 Bump actions/checkout from 5 to 6 (#560)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  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>
2025-12-28 03:30:08 +08:00
dependabot[bot]
07bdf983af Bump golangci/golangci-lint-action from 8 to 9 (#557)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8 to 9.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '9'
  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>
2025-12-28 03:29:45 +08:00
dependabot[bot]
a5907c313c Bump @codemirror/state from 6.5.2 to 6.5.3 (#566)
Bumps [@codemirror/state](https://github.com/codemirror/state) from 6.5.2 to 6.5.3.
- [Changelog](https://github.com/codemirror/state/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/state/compare/6.5.2...6.5.3)

---
updated-dependencies:
- dependency-name: "@codemirror/state"
  dependency-version: 6.5.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>
2025-12-28 03:28:40 +08:00
dependabot[bot]
dc0b429121 Bump github.com/go-playground/validator/v10 from 10.28.0 to 10.30.1 (#568)
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.28.0 to 10.30.1.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.28.0...v10.30.1)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.30.1
  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>
2025-12-27 21:55:44 +08:00
dependabot[bot]
b2373109b8 Bump tailwindcss from 4.1.14 to 4.1.18 (#569)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.1.14 to 4.1.18.
- [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.1.18/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.1.18
  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>
2025-12-27 21:55:26 +08:00
dependabot[bot]
0a106b27db Bump github.com/gabriel-vasile/mimetype from 1.4.10 to 1.4.12 (#570)
Bumps [github.com/gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype) from 1.4.10 to 1.4.12.
- [Release notes](https://github.com/gabriel-vasile/mimetype/releases)
- [Commits](https://github.com/gabriel-vasile/mimetype/compare/v1.4.10...v1.4.12)

---
updated-dependencies:
- dependency-name: github.com/gabriel-vasile/mimetype
  dependency-version: 1.4.12
  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>
2025-12-27 21:55:07 +08:00
dependabot[bot]
f10d656355 Bump katex from 0.16.23 to 0.16.27 (#571)
Bumps [katex](https://github.com/KaTeX/KaTeX) from 0.16.23 to 0.16.27.
- [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.23...v0.16.27)

---
updated-dependencies:
- dependency-name: katex
  dependency-version: 0.16.27
  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>
2025-12-27 21:54:44 +08:00
dependabot[bot]
fe211b949b Bump @codemirror/view from 6.38.5 to 6.39.7 (#572)
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.38.5 to 6.39.7.
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.38.5...6.39.7)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-version: 6.39.7
  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>
2025-12-27 21:54:11 +08:00
dependabot[bot]
a5778e77eb Bump github.com/blevesearch/bleve/v2 from 2.5.3 to 2.5.7 (#573)
Bumps [github.com/blevesearch/bleve/v2](https://github.com/blevesearch/bleve) from 2.5.3 to 2.5.7.
- [Release notes](https://github.com/blevesearch/bleve/releases)
- [Commits](https://github.com/blevesearch/bleve/compare/v2.5.3...v2.5.7)

---
updated-dependencies:
- dependency-name: github.com/blevesearch/bleve/v2
  dependency-version: 2.5.7
  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>
2025-12-27 21:53:53 +08:00
dependabot[bot]
f24c78d0a2 Bump golang.org/x/crypto from 0.42.0 to 0.46.0 (#574)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.46.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.46.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>
2025-12-27 21:53:28 +08:00
dependabot[bot]
34bd7bec20 Bump vite from 7.1.9 to 7.3.0 (#575)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.9 to 7.3.0.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.0/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.0/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.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>
2025-12-27 21:41:33 +08:00
Thomas Miceli
4d6809bc2d Feat/fix test (#577) 2025-12-27 21:29:52 +08:00
Thomas Miceli
a493de4325 quick fix test (#576) 2025-12-27 20:50:15 +08:00
dependabot[bot]
a67c80d148 Bump marked from 16.4.0 to 16.4.1 (#544)
Bumps [marked](https://github.com/markedjs/marked) from 16.4.0 to 16.4.1.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Changelog](https://github.com/markedjs/marked/blob/master/.releaserc.json)
- [Commits](https://github.com/markedjs/marked/compare/v16.4.0...v16.4.1)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 16.4.1
  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>
2025-12-27 20:26:40 +08:00
dependabot[bot]
feac9dcb66 Bump actions/setup-node from 5 to 6 (#545)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  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>
2025-12-27 20:26:20 +08:00
dependabot[bot]
38024310df Bump golang.org/x/text from 0.29.0 to 0.30.0 (#533)
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.29.0 to 0.30.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.29.0...v0.30.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.30.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>
2025-12-27 20:25:51 +08:00
Sebastian Ertz
9512ba84b0 Fix indentation and newline at eof (#564) 2025-12-27 20:24:30 +08:00
Thomas Miceli
b11306851b Fuzzy search + tests (#555) 2025-12-26 22:36:28 +08:00
Thomas Miceli
3957dfb3ea Add some tests (#553) 2025-10-31 15:37:45 +07:00
dependabot[bot]
8129906b02 Bump docker/login-action from 2 to 3 (#530)
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/login-action
  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>
2025-10-07 17:51:27 +02:00
dependabot[bot]
7880a3438e Bump actions/setup-node from 4 to 5 (#529)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  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>
2025-10-07 17:49:49 +02:00
dependabot[bot]
d5a3400bf0 Bump actions/checkout from 3 to 5 (#528)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  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>
2025-10-07 17:49:23 +02:00
dependabot[bot]
f529bf6a22 Bump softprops/action-gh-release from 1 to 2 (#527)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [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/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '2'
  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>
2025-10-07 17:48:16 +02:00
dependabot[bot]
425b123dd9 Bump docker/setup-qemu-action from 2 to 3 (#526)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  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>
2025-10-07 17:47:48 +02:00
Thomas Miceli
a7eaffbf02 Add Dockerfile for Dependabot (#525) 2025-10-07 17:20:21 +02:00
dependabot[bot]
5d19825949 Bump @codemirror/view from 6.38.4 to 6.38.5 (#523)
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.38.4 to 6.38.5.
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.38.4...6.38.5)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-version: 6.38.5
  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>
2025-10-07 17:10:58 +02:00
dependabot[bot]
c6dc2072bd Bump marked from 16.3.0 to 16.4.0 (#524)
Bumps [marked](https://github.com/markedjs/marked) from 16.3.0 to 16.4.0.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Changelog](https://github.com/markedjs/marked/blob/master/.releaserc.json)
- [Commits](https://github.com/markedjs/marked/compare/v16.3.0...v16.4.0)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 16.4.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>
2025-10-07 17:10:35 +02:00
dependabot[bot]
4d4f1c36a9 Bump docker/metadata-action from 4 to 5 (#522)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 4 to 5.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '5'
  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>
2025-10-07 17:09:24 +02:00
dependabot[bot]
a7ad82e29a Bump docker/setup-buildx-action from 2 to 3 (#521)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  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>
2025-10-07 17:08:27 +02:00
dependabot[bot]
98d216038b Bump actions/setup-go from 4 to 6 (#520)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  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>
2025-10-07 17:07:47 +02:00
dependabot[bot]
395ea7bfc7 Bump azure/setup-helm from 4.3.0 to 4.3.1 (#519)
Bumps [azure/setup-helm](https://github.com/azure/setup-helm) from 4.3.0 to 4.3.1.
- [Release notes](https://github.com/azure/setup-helm/releases)
- [Changelog](https://github.com/Azure/setup-helm/blob/main/CHANGELOG.md)
- [Commits](https://github.com/azure/setup-helm/compare/v4.3.0...v4.3.1)

---
updated-dependencies:
- dependency-name: azure/setup-helm
  dependency-version: 4.3.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>
2025-10-07 17:07:08 +02:00
dependabot[bot]
1c145e09c5 Bump docker/build-push-action from 4 to 6 (#518)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-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>
2025-10-07 17:06:30 +02:00
Philipp Eckel
32ea7befaf feat: configure Dependabot for updates on Go and NPM (#449) 2025-10-07 17:01:56 +02:00
Thomas Miceli
f653179cbf Upgrade JS and Go deps versions (#517) 2025-10-07 16:59:37 +02:00
Thomas Miceli
f0a596aed0 v1.11.1 2025-09-30 02:23:45 +02:00
Thomas Miceli
a468f0ecfa Translated using Weblate (Turkish) (#511)
Currently translated at 100.0% (308 of 308 strings)

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

Co-authored-by: Sinan Eldem <sinan@sinaneldem.com.tr>
2025-09-29 19:02:45 +02:00
Thomas Miceli
5ef5518795 Fix CSV errors for rendering (#514) 2025-09-29 19:02:33 +02:00
Thomas Miceli
92c5569538 Reset default log level to warn 2025-09-21 05:23:21 +02:00
Thomas Miceli
132e4faed2 Update Opengist version for Helm chart 2025-09-21 05:13:02 +02:00
Thomas Miceli
c7b947580d v1.11.0 2025-09-21 04:51:49 +02:00
Thomas Miceli
4106956f6d Fix human date on iOS devices (#510) 2025-09-21 04:31:58 +02:00
Fabio Manganiello
c02bf97b63 feat: Add support for rendering .ipynb Jupyter/IPython notebooks (#491) 2025-09-21 03:48:59 +02:00
Thomas Miceli
53ce41e0e4 Add file upload on gist creation/edition (#507) 2025-09-16 01:56:38 +02:00
Thomas Miceli
594d876ba8 Add binary files support (#503) 2025-09-16 01:35:54 +02:00
Thomas Miceli
905276f24b Init gist with regular urls via git CLI (http) (#501) 2025-08-28 02:44:09 +02:00
Sebastian Ertz
2976173658 Update go dep chroma (#493) 2025-08-18 16:05:07 +02:00
Thomas Miceli
b048203216 Use db for queue (#498) 2025-08-18 16:01:50 +02:00
Thomas Miceli
a7a25c4100 Fix LDAP with valid old password login (#497) 2025-08-14 11:10:45 +02:00
Alex Martens
bb1991f3ca Add OIDC group claim name to OpenID request (#490)
This fixes Kanidm compatibility.
2025-08-01 17:55:34 +02:00
Thomas Miceli
979b302e4c Add listen to Unix websocket (#484) 2025-08-01 17:34:52 +02:00
s1shed
b18cdb9188 Redirect to $baseUrl after auth with passkey instead of / (#482)
Fixes: #481
2025-07-01 14:40:33 +02:00
Aly Smith
867aa6e57b Replace unicode characters with HTML entity codes in embed template (#480) 2025-07-01 14:39:47 +02:00
Thomas Miceli
3c0115d829 Fix Markdown preview links (#475) 2025-05-15 15:16:40 +02:00
Thomas Miceli
d796895b75 Fix filename unescape (#474) 2025-05-14 11:51:42 +02:00
Andy Piper
5542497622 Add Proxmox VE Helper-Script (#473) 2025-05-14 10:49:27 +02:00
Thomas Miceli
546f1968e0 Fix helm ci 2025-05-09 20:16:57 +02:00
Thomas Miceli
75e71fd042 Use Helm deployment.env[] values (#471) 2025-05-09 20:08:25 +02:00
Thomas Miceli
897dc43790 Add LDAP authentication (#470)
* Introduce basic LDAP authentication.

* Reformat LDAP code; use ldap in Git HTTP

* lint

---------

Co-authored-by: Santhosh Raju <santhosh.raju@gmail.com>
2025-05-09 19:32:22 +02:00
Johannes Kirchner
72e02700ec fix: Correct German spelling, use consistent wording (#468) 2025-05-05 15:04:28 +02:00
Thomas Miceli
dc43fccc04 Style preference tab for user (#467) 2025-05-05 01:31:42 +02:00
Sergey Ryazanov
0e9b778b45 Fix Gitlab avatar (#461)
* Fix GitLab user avatar method

* Fix size of Gitlab avatar
2025-05-05 00:46:29 +02:00
Johannes Kirchner
3c940cd81f feat: read psql sslmode from db uri (#462) 2025-05-05 00:29:13 +02:00
Thomas Miceli
de144d09d3 Update README.md 2025-04-09 15:45:38 +02:00
211 changed files with 14781 additions and 9012 deletions

18
.github/dependabot.yml vendored Normal file
View File

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

View File

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

View File

@@ -17,18 +17,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go 1.23
uses: actions/setup-go@v4
- name: Set up Go 1.25
uses: actions/setup-go@v6
with:
go-version: "1.23"
go-version: "1.25"
- name: Lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v9
with:
version: v1.60
args: --out-format=colored-line-number --timeout=20m
version: v2.5
args: --timeout=20m --disable=errcheck
- name: Format
run: make fmt check_changes
@@ -38,12 +38,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go 1.23
uses: actions/setup-go@v4
- name: Set up Go 1.25
uses: actions/setup-go@v6
with:
go-version: "1.23"
go-version: "1.25"
- name: Check Go modules
run: make go_mod check_changes
@@ -57,7 +57,7 @@ jobs:
fail-fast: false
matrix:
os: ["ubuntu-latest"]
go: ["1.23"]
go: ["1.25"]
database: [postgres, mysql]
include:
- database: postgres
@@ -85,10 +85,10 @@ jobs:
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v4
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
@@ -100,16 +100,16 @@ jobs:
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
go: ["1.23"]
os: ["ubuntu-latest", "macOS-latest"]
go: ["1.25"]
database: ["sqlite"]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v4
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
@@ -122,14 +122,14 @@ jobs:
fail-fast: false
matrix:
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
go: ["1.23"]
go: ["1.25"]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go 1.23
uses: actions/setup-go@v4
- name: Set up Go 1.25
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}

View File

@@ -8,10 +8,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Helm
uses: azure/setup-helm@v4.3.0
uses: azure/setup-helm@v4.3.1
with:
version: 'latest'
@@ -26,6 +26,7 @@ jobs:
helm package ./opengist
# First time, create the index
wget -q https://helm.opengist.io/index.yaml
if [ ! -f index.yaml ]; then
helm repo index --url https://helm.opengist.io .
else
@@ -33,20 +34,16 @@ jobs:
helm repo index --url https://helm.opengist.io --merge index.yaml .
fi
- name: Deploy to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "./helm/*.tgz,./helm/index.yaml"
target: ${{ secrets.HELM_SERVER_PATH }}
- name: Update remote helm repository
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
${{ secrets.UPDATE_HELM_REPO }}
- name: Push to docs repository
run: |
git clone https://${{ secrets.STATIC_REPO_TOKEN }}@github.com/${{ secrets.STATIC_REPO }}.git target-repo
mkdir -p target-repo/helm
cp helm/*.tgz target-repo/srv/helm/
cp helm/index.yaml target-repo/srv/helm/
cd target-repo
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Deploy helm chart from ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit"
git pull --rebase
git push

View File

@@ -11,18 +11,18 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go 1.23
uses: actions/setup-go@v4
- name: Set up Go 1.25
uses: actions/setup-go@v6
with:
go-version: "1.23"
go-version: "1.25"
- name: Cross compile build
run: make all_crosscompile
- name: Upload Release Assets
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
files: |
build/*.tar.gz
@@ -38,11 +38,11 @@ jobs:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/thomiceli/opengist
@@ -54,26 +54,26 @@ jobs:
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64

5
.gitignore vendored
View File

@@ -1,13 +1,16 @@
node_modules/
gist.db
.idea/
.vscode/
.DS_Store
/**/.DS_Store
public/assets/*
public/manifest.json
public/.vite/*
./opengist
opengist
build/
docs/.vitepress/dist/
docs/.vitepress/cache/
helm/opengist/charts/
helm/opengist/charts/
vendor/

View File

@@ -1,5 +1,86 @@
# Changelog
## [1.12.1](https://github.com/thomiceli/opengist/compare/v1.12.0...v1.12.1) - 2026-02-03
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- More translation strings (#605)
### Fixed
- Allow Access Tokens with Required Login (#611)
- Make text files renderable with mimetypes different than text/plain (#612)
- Improve security on raw files endpoint (#613)
> Admins of Opengist instances may want to run "Synchronize all gists previews" in the admin panel.
## [1.12.0](https://github.com/thomiceli/opengist/compare/v1.11.1...v1.12.0) - 2026-01-27
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- Access tokens (#602)
- Fuzzy search for gist search (#555)
- Allow Unicode letters/numbers in topics (#597)
- Resize editor height (#600)
- More translation strings (#516) (#604)
### Fixed
- Don't panic on Go TCP errors (#601)
### Other
- Reduce footprint of Docker image (#515)
- Update Go + JS deps (#603)
- Configure Dependabot for updates on Go and NPM (#449)
### [Helm Chart](helm/opengist)
- Use existing pvc claim of provided (#547)
- Adds StatefulSet support (#549)
- Move Prom metrics to a dedicated port + support ServiceMonitor (#599)
## [1.11.1](https://github.com/thomiceli/opengist/compare/v1.11.0...v1.11.1) - 2025-09-30
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- More translation strings (#511)
### Fixed
- CSV errors for rendering (#514)
### Other
- Reset default log level to warn
## [1.11.0](https://github.com/thomiceli/opengist/compare/v1.10.0...v1.11.0) - 2025-09-21
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- LDAP authentication (#470)
- Listen to Unix websocket (#484)
- Binary files support (#503)
- Support for rendering .ipynb Jupyter/IPython notebooks (#491)
- File upload on gist creation/edition (#507)
- Read psql sslmode from db uri (#462)
- OIDC group claim name to OpenID request (#490)
- Reworked user settings page (#467)
- Style preference tab for user (#467)
- Init gist with regular urls via git CLI (http) (#501)
### Fixed
- Gitlab avatar (#461)
- Correct German spelling, use consistent wording (#468)
- Filename unescape (#474)
- Fix Markdown preview links (#475)
- Replace Unicode characters with HTML entity codes in embed template (#480)
- Redirect to $baseUrl after auth with passkey instead of / (#482)
- Human date on iOS devices (#510)
### Docs
- Add Proxmox VE Helper-Script (#473)
### Other
- Use Helm deployment.env[] values (#471)
- Update Helm Postgres version
- Use database for Gist init queue (#498)
- Update go dep chroma (#493)
## [1.10.0](https://github.com/thomiceli/opengist/compare/v1.9.1...v1.10.0) - 2025-04-07
See here how to [update](https://opengist.io/docs/update) Opengist.

View File

@@ -1,25 +1,18 @@
FROM alpine:3.19 AS base
FROM alpine:3.22 AS base
RUN apk update && \
apk add --no-cache \
make \
shadow \
openssl \
openssh \
curl \
wget \
git \
gnupg \
xz \
gcc \
git \
musl-dev \
libstdc++
COPY --from=golang:1.23-alpine /usr/local/go/ /usr/local/go/
COPY --from=golang:1.25.6-alpine3.22 /usr/local/go/ /usr/local/go/
ENV PATH="/usr/local/go/bin:${PATH}"
ENV CGO_ENABLED=0
COPY --from=node:20-alpine /usr/local/ /usr/local/
COPY --from=node:24.13.0-alpine3.22 /usr/local/ /usr/local/
ENV NODE_PATH="/usr/local/lib/node_modules"
ENV PATH="/usr/local/bin:${PATH}"
@@ -29,8 +22,20 @@ COPY . .
FROM base AS dev
RUN apk add --no-cache \
openssl \
openssh-server \
curl \
wget \
git \
gnupg \
xz
EXPOSE 6157 6158 2222 16157
RUN git config --global --add safe.directory /opengist
RUN make install
EXPOSE 6157 2222 16157
VOLUME /opengist
CMD ["make", "watch"]
@@ -41,33 +46,25 @@ FROM base AS build
RUN make
FROM alpine:3.19 as prod
FROM alpine:3.22 AS prod
RUN apk update && \
apk add --no-cache \
shadow \
openssl \
openssh \
openssh-server \
curl \
wget \
git \
gnupg \
xz \
gcc \
musl-dev \
libstdc++
git
RUN addgroup -S opengist && \
adduser -S -G opengist -s /bin/ash -g 'Opengist User' opengist
COPY --from=build --chown=opengist:opengist /opengist/config.yml config.yml
WORKDIR /app/opengist
COPY --from=build --chown=opengist:opengist /opengist/config.yml /config.yml
COPY --from=build --chown=opengist:opengist /opengist/opengist .
COPY --from=build --chown=opengist:opengist /opengist/docker ./docker
EXPOSE 6157 2222
EXPOSE 6157 6158 2222
VOLUME /opengist
HEALTHCHECK --interval=60s --timeout=30s --start-period=15s --retries=3 CMD curl -f http://localhost:6157/healthcheck || exit 1
ENTRYPOINT ["./docker/entrypoint.sh"]

View File

@@ -19,7 +19,6 @@ install:
build_frontend:
@echo "Building frontend assets..."
npx vite -c public/vite.config.js build
@EMBED=1 npx postcss 'public/assets/embed-*.css' -c public/postcss.config.js --replace # until we can .nest { @tailwind } in Sass
build_backend:
@echo "Building Opengist binary..."
@@ -39,11 +38,11 @@ build_dev_docker:
docker build -t $(BINARY_NAME)-dev:latest --target dev .
run_dev_docker:
docker run -v .:/opengist -p 6157:6157 -p 16157:16157 -p 2222:2222 -v $(HOME)/.opengist-dev:/root/.opengist --rm $(BINARY_NAME)-dev:latest
docker run -v .:/opengist -v /opengist/node_modules -p 6157:6157 -p 16157:16157 -p 2222:2222 -v $(HOME)/.opengist-dev:/root/.opengist --rm $(BINARY_NAME)-dev:latest
watch_frontend:
@echo "Building frontend assets..."
npx vite -c public/vite.config.js dev --port 16157 --host
npx vite -c public/vite.config.js --port 16157 --host
watch_backend:
@echo "Building Opengist binary..."
@@ -54,8 +53,8 @@ watch:
clean:
@echo "Cleaning up build artifacts..."
@rm -f $(BINARY_NAME) public/manifest.json
@rm -rf public/assets build
@rm -f $(BINARY_NAME)
@rm -rf public/assets public/.vite build
clean_docker:
@echo "Cleaning up Docker image..."

View File

@@ -1,6 +1,6 @@
# Opengist
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg" alt="Opengist" align="right" />
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg" alt="Opengist" align="right" />
Opengist is a **self-hosted** Pastebin **powered by Git**. All snippets are stored in a Git repository and can be
read and/or modified using standard Git commands, or with the web interface.
@@ -28,7 +28,7 @@ It is similar to [GitHub Gist](https://gist.github.com/), but open-source and co
* Download raw files or as a ZIP archive
* OAuth2 login with GitHub, GitLab, Gitea, and OpenID Connect
* Restrict or unrestrict snippets visibility to anonymous users
* Docker support
* Docker support / Helm Chart
* [More...](/docs/introduction.md#features)
## Quick start
@@ -38,7 +38,7 @@ It is similar to [GitHub Gist](https://gist.github.com/), but open-source and co
Docker [images](https://github.com/thomiceli/opengist/pkgs/container/opengist) are available for each release :
```shell
docker pull ghcr.io/thomiceli/opengist:1.10
docker pull ghcr.io/thomiceli/opengist:1.12
```
It can be used in a `docker-compose.yml` file :
@@ -50,7 +50,7 @@ It can be used in a `docker-compose.yml` file :
```yml
services:
opengist:
image: ghcr.io/thomiceli/opengist:1.10
image: ghcr.io/thomiceli/opengist:1.12
container_name: opengist
restart: unless-stopped
ports:
@@ -77,9 +77,9 @@ Download the archive for your system from the release page [here](https://github
```shell
# example for linux amd64
wget https://github.com/thomiceli/opengist/releases/download/v1.10.0/opengist1.10.0-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.12.1/opengist1.12.1-linux-amd64.tar.gz
tar xzvf opengist1.10.0-linux-amd64.tar.gz
tar xzvf opengist1.12.1-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`

View File

@@ -43,6 +43,7 @@ sqlite.journal-mode: WAL
# HTTP server configuration
# Host to bind to. Default: 0.0.0.0
# Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock)
http.host: 0.0.0.0
# Port to bind to. Default: 6157
@@ -51,9 +52,18 @@ http.port: 6157
# Enable or disable git operations (clone, pull, push) via HTTP (either `true` or `false`). Default: true
http.git-enabled: true
# Enable or disable the metrics endpoint (either `true` or `false`). Default: false
# File permissions for Unix socket (octal format). Default: 0666
unix-socket-permissions: 0666
# Enable or disable the Prometheus metrics server (either `true` or `false`). Default: false
metrics.enabled: false
# The host on which the metrics server should bind. Default: 0.0.0.0
metrics.host: 0.0.0.0
# The port on which the metrics server should listen. Default: 6158
metrics.port: 6158
# SSH built-in server configuration
# Note: it is not using the SSH daemon from your machine (yet)
@@ -111,6 +121,18 @@ oidc.group-claim-name:
# The name of the group that should receive admin rights
oidc.admin-group:
# LDAP authentication configuration
# URL of the LDAP instance e.g: ldap://ldap.example.com:389 ; if not set, LDAP authentication is disabled
ldap.url:
# Bind DN to authenticate against the LDAP e.g: cn=read-only-admin,dc=example,dc=com
ldap.bind-dn:
# The password for the Bind DN.
ldap.bind-credentials:
# The Base DN to start search from e.g: ou=People,dc=example,dc=com
ldap.search-base:
# The filter to search against (the format string %s will be replaced with the username) e.g: (uid=%s)
ldap.search-filter:
# Instance name
# Set your own custom name to be displayed instead of 'Opengist'
custom.name:

View File

@@ -11,7 +11,7 @@ export default defineConfig({
},
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
logo: 'https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg',
logo: 'https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg',
logoLink: '/',
nav: [
{ text: 'Demo', link: 'https://demo.opengist.io' },
@@ -55,6 +55,7 @@ export default defineConfig({
text: 'Usage', base: '/docs/usage', items: [
{text: 'Init via Git', link: '/init-via-git'},
{text: 'Embed Gist', link: '/embed'},
{text: 'Access Tokens', link: '/access-tokens'},
{text: 'Gist as JSON', link: '/gist-json'},
{text: 'Import Gists from Github', link: '/import-from-github-gist'},
{text: 'Git push options', link: '/git-push-options'},

View File

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

View File

@@ -4,43 +4,53 @@ aside: false
# Configuration Cheat Sheet
| YAML Config Key | Environment Variable | Default value | Description |
|-----------------------|-------------------------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| log-level | OG_LOG_LEVEL | `warn` | Set the log level to one of the following: `debug`, `info`, `warn`, `error`, `fatal`. |
| log-output | OG_LOG_OUTPUT | `stdout,file` | Set the log output to one or more of the following: `stdout`, `file`. |
| external-url | OG_EXTERNAL_URL | none | Public URL to access to Opengist. |
| opengist-home | OG_OPENGIST_HOME | home directory | Path to the directory where Opengist stores its data. |
| secret-key | OG_SECRET_KEY | randomized 32 bytes | Secret key used for session store & encrypt MFA data on database. |
| db-uri | OG_DB_URI | `opengist.db` | URI of the database. |
| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. |
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics endpoint at `/metrics` (`true` or `false`) |
| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) |
| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. |
| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. |
| ssh.external-domain | OG_SSH_EXTERNAL_DOMAIN | none | Public domain for the Git SSH connection, if it has to be different from the HTTP one. If not set, uses the URL from the request. |
| ssh.keygen-executable | OG_SSH_KEYGEN_EXECUTABLE | `ssh-keygen` | Path to the SSH key generation executable. |
| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. |
| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. |
| gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. |
| gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. |
| gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. |
| gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. |
| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. |
| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. |
| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. |
| gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. |
| oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider |
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
| custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title |
| custom.logo | OG_CUSTOM_LOGO | none | Path to an image, relative to $opengist-home/custom. |
| custom.favicon | OG_CUSTOM_FAVICON | none | Path to an image, relative to $opengist-home/custom. |
| custom.static-links | OG_CUSTOM_STATIC_LINK_#_(PATH,NAME) | none | Path and name to custom links, more info [here](custom-links.md). |
| YAML Config Key | Environment Variable | Default value | Description |
|-------------------------|-------------------------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| log-level | OG_LOG_LEVEL | `warn` | Set the log level to one of the following: `debug`, `info`, `warn`, `error`, `fatal`. |
| log-output | OG_LOG_OUTPUT | `stdout,file` | Set the log output to one or more of the following: `stdout`, `file`. |
| external-url | OG_EXTERNAL_URL | none | Public URL to access to Opengist. |
| opengist-home | OG_OPENGIST_HOME | home directory | Path to the directory where Opengist stores its data. |
| secret-key | OG_SECRET_KEY | randomized 32 bytes | Secret key used for session store & encrypt MFA data on database. |
| db-uri | OG_DB_URI | `opengist.db` | URI of the database. |
| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) |
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
| unix-socket-permissions | OG_UNIX_SOCKET_PERMISSIONS | `0666` | File permissions for Unix socket (octal format). |
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics server (`true` or `false`) |
| metrics.host | OG_METRICS_HOST | `0.0.0.0` | The host on which the metrics server should bind. |
| metrics.port | OG_METRICS_PORT | `6158` | The port on which the metrics server should listen. |
| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) |
| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. |
| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. |
| ssh.external-domain | OG_SSH_EXTERNAL_DOMAIN | none | Public domain for the Git SSH connection, if it has to be different from the HTTP one. If not set, uses the URL from the request. |
| ssh.keygen-executable | OG_SSH_KEYGEN_EXECUTABLE | `ssh-keygen` | Path to the SSH key generation executable. |
| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. |
| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. |
| gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. |
| gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. |
| gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. |
| gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. |
| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. |
| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. |
| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. |
| gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. |
| oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider |
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
| oidc.group-claim-name | OG_OIDC_GROUP_CLAIM_NAME | none | Name of the claim containing the groups. |
| oidc.admin-group | OG_OIDC_ADMIN_GROUP | none | Name of the group that should receive admin rights. |
| ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled |
| ldap.bind-dn | OG_LDAP_BIND_DN | none | Bind DN to authenticate against the LDAP. e.g: cn=read-only-admin,dc=example,dc=com |
| ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. |
| ldap.search-base | OG_LDAP_SEARCH_BASE | none | The Base DN to start search from. e.g: ou=People,dc=example,dc=com |
| ldap.search-filter | OG_LDAP_SEARCH_FILTER | none | The filter to search against (the format string %s will be replaced with the username). e.g: (uid=%s) |
| custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title |
| custom.logo | OG_CUSTOM_LOGO | none | Path to an image, relative to $opengist-home/custom. |
| custom.favicon | OG_CUSTOM_FAVICON | none | Path to an image, relative to $opengist-home/custom. |
| custom.static-links | OG_CUSTOM_STATIC_LINK_#_(PATH,NAME) | none | Path and name to custom links, more info [here](custom-links.md). |

View File

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

View File

@@ -4,3 +4,4 @@ The following is a list of resources made by happy users of Opengist. Feel free
- [Aetherinox/opengist-debian](https://github.com/Aetherinox/opengist-debian) - A Debian package for Opengist
- [How to Install Opengist on Your Synology NAS](https://mariushosting.com/how-to-install-opengist-on-your-synology-nas/) - A guide to install Opengist on a Synology NAS
- [Proxmox VE Helper-Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=opengist) - A script to install Opengist on Proxmox VE

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# Opengist
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg" alt="Opengist" align="right" />
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg" alt="Opengist" align="right" />
Opengist is a **self-hosted** pastebin **powered by Git**. All snippets are stored in a Git repository and can be
read and/or modified using standard Git commands, or with the web interface.

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# Init Gists via Git
Opengist allows you to create new snippets via Git over HTTP.
Opengist allows you to create new snippets via Git over HTTP. You can create gists with either auto-generated URLs or custom URLs of your choice.
Simply init a new Git repository where your file(s) is/are located:
@@ -10,19 +10,41 @@ git add .
git commit -m "My cool snippet"
```
Then add this Opengist special remote URL and push your changes:
### Option A: Regular URL
Create a gist with a custom URL using the format `http://opengist.url/username/custom-url`, where `username` is your authenticated username and `custom-url` is your desired gist identifier.
The gist must not exist yet if you want to create it, otherwise you will just push to the existing gist.
```shell
git remote add origin http://localhost:6157/init
git remote add origin http://opengist.url/thomas/my-custom-gist
git push -u origin master
```
Log in with your Opengist account credentials, and your snippet will be created at the specified URL:
**Requirements for custom URLs:**
- The username must match your authenticated username
- URL format: `http://opengist.url/username/custom-url`
- The custom URL becomes your gist's identifier and title
- `.git` suffix is automatically removed if present
### Option B: Init endpoint
Use the special `http://opengist.url/init` endpoint to create a gist with an automatically generated URL:
```shell
Username for 'http://localhost:6157': thomas
Password for 'http://thomas@localhost:6157':
git remote add origin http://opengist.url/init
git push -u origin master
```
## Authentication
When you push, you'll be prompted to authenticate:
```shell
Username for 'http://opengist.url': thomas
Password for 'http://thomas@opengist.url': [your-password]
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Delta compression using up to 8 threads
@@ -30,12 +52,12 @@ Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 416 bytes | 416.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
remote:
remote: Your new repository has been created here: http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066
remote: Your new repository has been created here: http://opengist.url/thomas/6051e930f140429f9a2f3bb1fa101066
remote:
remote: If you want to keep working with your gist, you could set the remote URL via:
remote: git remote set-url origin http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066
remote: git remote set-url origin http://opengist.url/thomas/6051e930f140429f9a2f3bb1fa101066
remote:
To http://localhost:6157/init
To http://opengist.url/init
* [new branch] master -> master
```

141
go.mod
View File

@@ -1,126 +1,125 @@
module github.com/thomiceli/opengist
go 1.23.0
go 1.25.5
require (
github.com/Kunde21/markdownfmt/v3 v3.1.0
github.com/alecthomas/chroma/v2 v2.16.0
github.com/blevesearch/bleve/v2 v2.5.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/blevesearch/bleve/v2 v2.5.7
github.com/dustin/go-humanize v1.0.1
github.com/gabriel-vasile/mimetype v1.4.13
github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.26.0
github.com/go-webauthn/webauthn v0.12.3
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.16.0
github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.4.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/labstack/echo-contrib v0.17.3
github.com/labstack/echo/v4 v4.13.3
github.com/markbates/goth v1.81.0
github.com/meilisearch/meilisearch-go v0.31.0
github.com/pquerna/otp v1.4.0
github.com/prometheus/client_golang v1.21.1
github.com/labstack/echo-contrib v0.17.4
github.com/labstack/echo/v4 v4.15.1
github.com/markbates/goth v1.82.0
github.com/meilisearch/meilisearch-go v0.36.1
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.6
github.com/yuin/goldmark v1.7.8
github.com/yuin/goldmark-emoji v1.0.5
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark-emoji v1.0.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/mermaid v0.5.0
golang.org/x/crypto v0.36.0
golang.org/x/text v0.23.0
go.abhg.dev/goldmark/mermaid v0.6.0
golang.org/x/crypto v0.48.0
golang.org/x/text v0.34.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blevesearch/bleve_index_api v1.2.7 // indirect
github.com/blevesearch/geo v0.1.20 // indirect
github.com/blevesearch/go-faiss v1.0.25 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/blevesearch/bleve_index_api v1.3.1 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.27 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 // indirect
github.com/blevesearch/mmap-go v1.2.0 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.4.1 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.1 // indirect
github.com/blevesearch/zapx/v12 v12.4.1 // indirect
github.com/blevesearch/zapx/v13 v13.4.1 // indirect
github.com/blevesearch/zapx/v14 v14.4.1 // indirect
github.com/blevesearch/zapx/v15 v15.4.1 // indirect
github.com/blevesearch/zapx/v16 v16.2.2 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/blevesearch/vellum v1.2.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.3.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-chi/chi/v5 v5.2.1 // 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-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/go-webauthn/x v0.1.20 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.4 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
modernc.org/libc v1.62.1 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
modernc.org/sqlite v1.37.0 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
)

363
go.sum
View File

@@ -1,74 +1,81 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0=
github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc=
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA=
github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4=
github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk=
github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY=
github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.3.1 h1:LdH3CQgBbIZ5UI/5Pykz87e0jfeQtVnrdZ2WUBrHHwU=
github.com/blevesearch/bleve_index_api v1.3.1/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/go-faiss v1.0.27 h1:7cBImYDDQ82WJd5RUZ1ie6zXztCsC73W94ZzwOjkatk=
github.com/blevesearch/go-faiss v1.0.27/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 h1:X6nJXnNHl7nasXW+U6y2Ns2Aw8F9STszkYkyBfQ+p0o=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9/go.mod h1:IrzspZlVjhf4X29oJiEhBxEteTqOY9RlYlk1lCmYHr4=
github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
github.com/blevesearch/scorch_segment_api/v2 v2.4.1 h1:os52/JeCSLZ0YUkOuLk/Z7pu0SKUMofDPUg+VnbrRD0=
github.com/blevesearch/scorch_segment_api/v2 v2.4.1/go.mod h1:zvilBm4BNfbnTRLW7KgCTNgk2R31JaWzwRc2BEcD7Is=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zapx/v11 v11.4.1 h1:qFCPlFbsEdwbbckJkysptSQOsHn4s6ZOHL5GMAIAVHA=
github.com/blevesearch/zapx/v11 v11.4.1/go.mod h1:qNOGxIqdPC1MXauJCD9HBG487PxviTUUbmChFOAosGs=
github.com/blevesearch/zapx/v12 v12.4.1 h1:K77bhypII60a4v8mwvav7r4IxWA8qxhNjgF9xGdb9eQ=
github.com/blevesearch/zapx/v12 v12.4.1/go.mod h1:QRPrlPOzAxBNMI0MkgdD+xsTqx65zbuPr3Ko4Re49II=
github.com/blevesearch/zapx/v13 v13.4.1 h1:EnkEMZFUK0lsW/jOJJF2xOcp+W8TjEsyeN5BeAZEYYE=
github.com/blevesearch/zapx/v13 v13.4.1/go.mod h1:e6duBMlCvgbH9rkzNMnUa9hRI9F7ri2BRcHfphcmGn8=
github.com/blevesearch/zapx/v14 v14.4.1 h1:G47kGCshknBZzZAtjcnIAMn3oNx8XBLxp8DMq18ogyE=
github.com/blevesearch/zapx/v14 v14.4.1/go.mod h1:O7sDxiaL2r2PnCXbhh1Bvm7b4sP+jp4unE9DDPWGoms=
github.com/blevesearch/zapx/v15 v15.4.1 h1:B5IoTMUCEzFdc9FSQbhVOxAY+BO17c05866fNruiI7g=
github.com/blevesearch/zapx/v15 v15.4.1/go.mod h1:b/MreHjYeQoLjyY2+UaM0hGZZUajEbE0xhnr1A2/Q6Y=
github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc6r0Rzk=
github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk=
github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.3.0 h1:hF6VlN15E9CB40RMPyqOIhlDw1OOo9RItumhKMQktxw=
github.com/blevesearch/zapx/v16 v16.3.0/go.mod h1:zCFjv7McXWm1C8rROL+3mUoD5WYe2RKsZP3ufqcYpLY=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 h1:wMSvdj3BswqfQOXp2R1bJOAE7xIQLt2dlMQDMf836VY=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0=
github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.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.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -79,53 +86,55 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY=
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw=
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.0 h1:A9BkfYIwWAMPSQCbM2HoWqo6JO5LFI8aqYAzo6nW7AY=
github.com/go-webauthn/webauthn v0.16.0/go.mod h1:hm9RS/JNYeUu3KqGbzqlnHClhDGCZzTZlABjathwnN0=
github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc=
github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3 h1:8COTSTFIIXnaD81+kfCw4dRANNAKuCp06EdYLqwX30g=
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -141,22 +150,36 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -167,18 +190,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo-contrib v0.17.3 h1:hj+qXksKZG1scSe9ksUXMtv7fZYN+PtQT+bPcYA3/TY=
github.com/labstack/echo-contrib v0.17.3/go.mod h1:TcRBrzW8jcC4JD+5Dc/pvOyAps0rtgzj7oBqoR3nYsc=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
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.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
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/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE=
github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k=
github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -186,12 +207,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/meilisearch/meilisearch-go v0.31.0 h1:yZRhY1qJqdH8h6GFZALGtkDLyj8f9v5aJpsNMyrUmnY=
github.com/meilisearch/meilisearch-go v0.31.0/go.mod h1:aNtyuwurDg/ggxQIcKqWH6G9g2ptc8GyY7PLY4zMn/g=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -201,26 +220,23 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
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.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -229,101 +245,102 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
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/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.abhg.dev/goldmark/mermaid v0.5.0 h1:mDkykpSPJ+5wCQ8bSXgzJ2KQskjXkI5Ndxz7JYDHW38=
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY=
go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

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

View File

@@ -1,9 +1,9 @@
dependencies:
- name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts
version: 16.5.6
version: 16.7.27
- name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.12.0
digest: sha256:31084e570aa16e3a26317aeb6d0d5dec62540c314ee4f703374e6e7827399fa6
generated: "2025-03-27T11:34:51.840778733+01:00"
version: 0.26.0
digest: sha256:7182bad3df032b3cb21a793ea6b027eaa96e142ff207b607b62df974bc82de90
generated: "2026-03-09T03:39:04.820136+07:00"

View File

@@ -2,18 +2,18 @@ apiVersion: v2
name: opengist
description: Opengist Helm chart for Kubernetes
type: application
version: 0.1.0
appVersion: 1.10.0
version: 0.6.0
appVersion: 1.12.1
home: https://opengist.io
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg
sources:
- https://github.com/thomiceli/opengist
dependencies:
- name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts
version: 16.5.6
version: 16.7.27
condition: postgresql.enabled
- name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.12.0
version: 0.26.0
condition: meilisearch.enabled

View File

@@ -1,11 +1,12 @@
# Opengist Helm Chart
![Version: 0.1.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![AppVersion: 1.10.0](https://img.shields.io/badge/AppVersion-1.10.0-informational?style=flat-square)
![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)
Opengist Helm chart for Kubernetes.
Opengist Helm chart for Kubernetes. Check [CHANGELOG.md](CHANGELOG.md) for release notes.
* [Install](#install)
* [Configuration](#configuration)
* [Metrics & Monitoring](#metrics--monitoring)
* [Dependencies](#dependencies)
* [Meilisearch Indexer](#meilisearch-indexer)
* [PostgreSQL Database](#postgresql-database)
@@ -47,6 +48,76 @@ If defined, this existing secret will be used instead of creating a new one.
configExistingSecret: <name of the secret>
```
## Metrics & Monitoring
Opengist exposes Prometheus metrics on a separate port (default: `6158`). The metrics server runs independently from the main HTTP server for security.
### Enabling Metrics
To enable metrics, set `metrics.enabled: true` in your Opengist config:
```yaml
config:
metrics.enabled: true
```
This will:
1. Start a metrics server on port 6158 inside the container
2. Create a Kubernetes Service exposing the metrics ports
### Available Metrics
| Metric Name | Type | Description |
|-------------|------|-------------|
| `opengist_users_total` | Gauge | Total number of registered users |
| `opengist_gists_total` | Gauge | Total number of gists |
| `opengist_ssh_keys_total` | Gauge | Total number of SSH keys |
| `opengist_request_duration_seconds_*` | Histogram | HTTP request duration metrics |
### ServiceMonitor for Prometheus Operator
If you're using [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator), you can enable automatic service discovery with a ServiceMonitor:
```yaml
config:
metrics.enabled: true
service:
metrics:
serviceMonitor:
enabled: true
labels:
release: prometheus # match your Prometheus serviceMonitorSelector
```
### Manual Prometheus Configuration
If you're not using Prometheus Operator, you can configure Prometheus to scrape the metrics endpoint directly:
```yaml
scrape_configs:
- job_name: 'opengist'
static_configs:
- targets: ['opengist-metrics:6158']
metrics_path: /metrics
```
Or use Kubernetes service discovery:
```yaml
scrape_configs:
- job_name: 'opengist'
kubernetes_sd_configs:
- role: service
relabel_configs:
- source_labels: [__meta_kubernetes_service_label_app_kubernetes_io_component]
regex: metrics
action: keep
- source_labels: [__meta_kubernetes_service_label_app_kubernetes_io_name]
regex: opengist
action: keep
```
## Dependencies
### Meilisearch Indexer
@@ -66,6 +137,40 @@ index.meili.api-key: MASTER_KEY # generated by Meilisearch
If you want to use the `bleve` indexer, you need to set the `replicas` to `1`.
#### Passing Meilisearch configuration via nested Helm values
When using the Helm CLI with `--set`, avoid mixing a scalar `config.index` value with nested `config.index.meili.*` keys. Instead use a nested map and a `type` field which the chart flattens automatically. Example:
```bash
helm template opengist ./helm/opengist \
--set statefulSet.enabled=true \
--set replicaCount=2 \
--set persistence.enabled=true \
--set persistence.existingClaim=opengist-shared-rwx \
--set postgresql.enabled=false \
--set config.db-uri="postgres://user:pass@db-host:5432/opengist" \
--set meilisearch.enabled=true \
--set config.index.type=meilisearch \
--set config.index.meili.host="http://opengist-meilisearch:7700" \
--set config.index.meili.api-key="MASTER_KEY"
```
Rendered `config.yml` fragment:
```yaml
index: meilisearch
index.meili.host: http://opengist-meilisearch:7700
index.meili.api-key: MASTER_KEY
```
How it works:
* You provide a map under `config.index` with keys `type` and `meili`.
* The template detects `config.index.type` and rewrites `index: <type>`.
* Nested `config.index.meili.host` / `api-key` are lifted to flat keys `index.meili.host` and `index.meili.api-key` required by Opengist.
If you set `--set config.index=meilisearch` directly and also try to set `--set config.index.meili.host=...`, Helm will first create the nested structure then overwrite it with the scalar, losing the host. Always prefer the `config.index.type` pattern for CLI usage.
### PostgreSQL Database
By default, Opengist uses the `sqlite` database. If needed, this chart also deploys a PostgreSQL instance.
@@ -79,3 +184,268 @@ Then define the connection string in your Opengist config:
db-uri: postgres://user:password@opengist-postgresql:5432/opengist
```
Note: `opengist-postgresql` is the name of the K8S Service deployed by this chart.
### Database Configuration
You can supply an externally managed database connection explicitly via `config.db-uri` (PostgreSQL/MySQL) or enable the bundled PostgreSQL subchart.
Behavior:
* If `postgresql.enabled: true` and `config.db-uri` is omitted, the chart auto-generates:
`postgres://<username>:<password>@<release-name>-postgresql:<port>/<database>` using values under `postgresql.global.postgresql.auth.*`.
* If any of username/password/database are missing, templating fails fast with an error message.
* If you prefer an external database or a different Postgres distribution, set `postgresql.enabled: false` and provide `config.db-uri` yourself.
**Licensing note**: Bitnami's PostgreSQL distribution may have licensing constraints. For strictly open alternatives use an external managed PostgreSQL/MySQL service and disable the subchart.
### Multi-Replica Requirements
Running more than one Opengist replica (Deployment or StatefulSet) requires:
1. Non-SQLite database (`config.db-uri` must start with `postgres://` or `mysql://`).
2. Shared RWX storage if using StatefulSet with `replicaCount > 1` (provide `persistence.existingClaim`). The chart now fails fast if you attempt `replicaCount > 1` without an explicit shared claim to prevent silent data divergence across perpod PVCs.
The chart will fail fast during templating if these conditions are not met when scaling above 1 replica.
Examples:
* External PostgreSQL:
```yaml
postgresql:
enabled: false
config:
db-uri: postgres://user:pass@db-host:5432/opengist
index: meilisearch
statefulSet:
enabled: true
replicaCount: 2
persistence:
existingClaim: opengist-shared-rwx
```
Bundled PostgreSQL (auto db-uri):
```yaml
postgresql:
enabled: true
config:
index: meilisearch
statefulSet:
enabled: true
replicaCount: 2
persistence:
existingClaim: opengist-shared-rwx
```
#### Recovering from an initial misconfiguration
If you previously scaled a StatefulSet above 1 replica **without** an `existingClaim`, each pod received its own PVC and only one held the authoritative `/opengist` data. To consolidate:
1. Scale down to 1 replica (keep the pod with the desired data):
```bash
kubectl scale sts/opengist --replicas=1
```
1. (Optional) Inspect other PVCs and manually copy any missing files by temporarily attaching them to a debug pod.
1. Create or provision a ReadWriteMany (NFS / CephFS / Longhorn RWX / etc.) PersistentVolumeClaim named (for example) `opengist-shared-rwx`.
1. Update values with `persistence.existingClaim: opengist-shared-rwx` and redeploy.
1. Scale back up:
```bash
kubectl scale sts/opengist --replicas=2
```
Going forward, all replicas mount the same shared volume and data remains consistent.
### Quick Start Examples
Common deployment scenarios with copy-paste configurations:
#### Scenario 1: Single replica with SQLite (default)
Minimal local development setup with ephemeral or persistent storage:
```yaml
# Ephemeral (emptyDir)
statefulSet:
enabled: true
replicaCount: 1
persistence:
enabled: false
# OR with persistent RWO storage
statefulSet:
enabled: true
replicaCount: 1
persistence:
enabled: true
mode: perReplica # default
```
#### Scenario 2: Multi-replica with external PostgreSQL + existing RWX PVC
Production HA setup with your own database and storage:
```yaml
statefulSet:
enabled: true
replicaCount: 2
postgresql:
enabled: false
config:
db-uri: "postgres://user:pass@db-host:5432/opengist"
index: meilisearch # required for multi-replica
persistence:
enabled: true
mode: shared
existingClaim: "opengist-shared-rwx" # pre-created RWX PVC
meilisearch:
enabled: true
```
#### Scenario 3: Multi-replica with bundled PostgreSQL + auto-created RWX PVC
Chart manages both database and storage:
```yaml
statefulSet:
enabled: true
replicaCount: 2
postgresql:
enabled: true
global:
postgresql:
auth:
username: opengist
password: changeme
database: opengist
config:
index: meilisearch
persistence:
enabled: true
mode: shared
existingClaim: "" # empty to trigger auto-creation
create:
enabled: true
accessModes: [ReadWriteMany]
storageClass: "nfs-client" # your RWX-capable storage class
size: 20Gi
meilisearch:
enabled: true
```
### Persistence Modes
The chart supports two persistence strategies controlled by `persistence.mode`:
| Mode | Behavior | Scaling | Storage Objects | Recommended Use |
|-------------|----------|---------|-----------------|-----------------|
| `perReplica` (default) | One PVC per pod via StatefulSet `volumeClaimTemplates` (RWO) when no `existingClaim` | Safe only at `replicaCount=1` unless you supply `existingClaim` | One PVC per replica | Local dev, quick single-node trials |
| `shared` | Single RWX PVC (existing or auto-created) mounted by all pods | Horizontally scalable | One shared PVC | Production / HA |
Configuration examples:
Per-replica (single node):
```yaml
statefulSet:
enabled: true
persistence:
mode: perReplica
enabled: true
accessModes:
- ReadWriteOnce
```
Shared (scale ready) with an existing RWX claim:
```yaml
statefulSet:
enabled: true
replicaCount: 2
persistence:
mode: shared
existingClaim: opengist-shared-rwx
```
Shared with chart-created RWX PVC:
```yaml
statefulSet:
enabled: true
replicaCount: 2
persistence:
mode: shared
existingClaim: "" # leave empty
create:
enabled: true
accessModes: [ReadWriteMany]
size: 10Gi
```
When `mode=shared` and `existingClaim` is empty, the chart creates a single PVC named `<release>-shared` (suffix configurable via `persistence.create.nameSuffix`).
Fail-fast conditions:
* `replicaCount>1` & missing external DB (still enforced).
* `replicaCount>1` & persistence disabled.
* `replicaCount>1` & neither `existingClaim` nor `mode=shared`.
* `mode=shared` & create.enabled=true but `accessModes` lacks `ReadWriteMany`.
Migration (perReplica → shared): scale down to 1, create RWX claim (or rely on create.enabled), copy data, switch mode to shared, scale up.
### Troubleshooting
#### Common Errors and Solutions
##### Error: "replicaCount=2 requires PostgreSQL/MySQL config.db-uri; scheme 'sqlite' unsupported"
* **Cause**: Multi-replica with SQLite database
* **Solution**: Either scale down to `replicaCount: 1` or configure external database:
```yaml
config:
db-uri: "postgres://user:pass@host:5432/opengist"
```
##### Error: "replicaCount=2 requires either persistence.existingClaim OR persistence.mode=shared"
* **Cause**: Multi-replica without shared storage
* **Solution**: Choose one approach:
```yaml
# Option A: Use existing PVC
persistence:
existingClaim: "my-rwx-pvc"
# Option B: Let chart create PVC
persistence:
mode: shared
create:
enabled: true
accessModes: [ReadWriteMany]
```
##### Error: "persistence.mode=shared create.accessModes must include ReadWriteMany for multi-replica"
* **Cause**: Chart-created PVC lacks RWX access mode
* **Solution**: Ensure RWX is specified:
```yaml
persistence:
create:
accessModes:
- ReadWriteMany
```
##### Pods mount different data (data divergence)
* **Cause**: Previously scaled with `perReplica` mode and `replicaCount > 1`
* **Solution**: Follow recovery steps in "Recovering from an initial misconfiguration" section above
##### PVC creation fails: "no storage class available with ReadWriteMany"
* **Cause**: Cluster lacks RWX-capable storage provisioner
* **Solution**: Install a storage provider (NFS, CephFS, Longhorn) or use external managed storage and provide `existingClaim`

View File

@@ -1,3 +1,4 @@
{{- if not .Values.statefulSet.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -32,6 +33,9 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- if .Values.deployment.terminationGracePeriodSeconds }}
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
@@ -49,6 +53,10 @@ spec:
mountPath: /init/config
- name: config-volume
mountPath: /config-volume
{{- if .Values.deployment.env }}
env:
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
@@ -59,6 +67,11 @@ spec:
- name: http
containerPort: {{ .Values.service.http.port }}
protocol: TCP
{{- if index .Values.config "metrics.enabled" }}
- name: metrics
containerPort: {{ .Values.service.metrics.port }}
protocol: TCP
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}
@@ -88,7 +101,11 @@ spec:
- name: opengist-data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
{{- if .Values.persistence.existingClaim }}
claimName: {{ .Values.persistence.existingClaim }}
{{- else }}
claimName: {{ include "opengist.fullname" . }}-data
{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
@@ -113,3 +130,5 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,48 @@
{{- /*
This template creates a standalone PersistentVolumeClaim for shared persistence mode.
Rendering conditions:
- statefulSet.enabled=true
- persistence.enabled=true
- persistence.mode=shared
- persistence.existingClaim is empty/unset
- persistence.create.enabled=true
When rendered, this PVC is mounted by ALL replicas in the StatefulSet (typically with ReadWriteMany
access mode for multi-replica deployments). This avoids per-replica volumeClaimTemplates and enables
horizontal scaling with a single shared storage backend.
If persistence.existingClaim is set, this template does NOT render; the StatefulSet instead references
the existing claim name directly.
*/}}
{{- if and .Values.statefulSet.enabled .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "shared") (ne (default "" .Values.persistence.existingClaim) "") | not }}{{- end }}
{{- if and .Values.statefulSet.enabled .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "shared") (eq (default "" .Values.persistence.existingClaim) "") .Values.persistence.create.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "opengist.fullname" . }}-{{ default "shared" .Values.persistence.create.nameSuffix }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- with .Values.persistence.create.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.persistence.create.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- if .Values.persistence.create.accessModes }}
{{- toYaml .Values.persistence.create.accessModes | nindent 4 }}
{{- else }}
- ReadWriteMany
{{- end }}
resources:
requests:
storage: {{ default .Values.persistence.size .Values.persistence.create.size }}
volumeMode: Filesystem
{{- $sc := default .Values.persistence.storageClass .Values.persistence.create.storageClass }}
{{- if $sc }}
storageClassName: {{ $sc | quote }}
{{- end }}
{{- end }}

View File

@@ -1,4 +1,4 @@
{{- if .Values.persistence.enabled }}
{{- if and .Values.persistence.enabled (not .Values.statefulSet.enabled) (not .Values.persistence.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
@@ -25,4 +25,4 @@ spec:
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
{{- end }}

View File

@@ -1,4 +1,53 @@
{{- if (not .Values.configExistingSecret) }}
{{- $cfg := deepCopy .Values.config }}
{{- /* Backward compatibility: map db-uri (deprecated) to db-uri key still expected by app, also accept dbUri coming from user */}}
{{- if and (hasKey $cfg "dbUri") (not (hasKey $cfg "db-uri")) }}
{{- $_ := set $cfg "db-uri" (index $cfg "dbUri") }}
{{- end }}
{{- $dburi := default "" (index $cfg "db-uri") }}
{{- /* Flatten possible nested index.meili.* structure if user passed --set config.index.meili.host=... */}}
{{- if and (hasKey $cfg "index") (kindIs "map" (index $cfg "index")) }}
{{- $indexMap := (index $cfg "index") }}
{{- if hasKey $indexMap "type" }}
{{- $_ := set $cfg "index" (index $indexMap "type") }}
{{- end }}
{{- if hasKey $indexMap "meili" }}
{{- $meili := (index $indexMap "meili") }}
{{- if hasKey $meili "host" }}
{{- $_ := set $cfg "index.meili.host" (index $meili "host") }}
{{- end }}
{{- if hasKey $meili "api-key" }}
{{- $_ := set $cfg "index.meili.api-key" (index $meili "api-key") }}
{{- end }}
{{- end }}
{{- end }}
{{- if and .Values.postgresql.enabled (eq $dburi "") }}
{{- $user := default "" .Values.postgresql.global.postgresql.auth.username }}
{{- $pass := default "" .Values.postgresql.global.postgresql.auth.password }}
{{- $db := default "" .Values.postgresql.global.postgresql.auth.database }}
{{- $port := int (default 5432 .Values.postgresql.global.postgresql.service.ports.postgresql) }}
{{- if or (eq $user "") (eq $pass "") (eq $db "") }}
{{- fail "postgresql.enabled=true requires username/password/database (postgresql.global.postgresql.auth.*) or set config.db-uri manually" }}
{{- end }}
{{- $autoHost := printf "%s-postgresql" (include "opengist.fullname" .) }}
{{- $autoUri := printf "postgres://%s:%s@%s:%d/%s" $user $pass $autoHost $port $db }}
{{- $_ := set $cfg "db-uri" $autoUri }}
{{- end }}
{{- $replicas := int .Values.replicaCount }}
{{- $index := default "" (index $cfg "index") }}
{{- /* Auto-set Meilisearch host if subchart enabled and host missing */}}
{{- $meiliHost := default "" (index $cfg "index.meili.host") }}
{{- if and .Values.meilisearch.enabled (eq $meiliHost "") }}
{{- $autoMeiliHost := printf "http://%s-meilisearch:7700" (include "opengist.fullname" .) }}
{{- $_ := set $cfg "index.meili.host" $autoMeiliHost }}
{{- if or (eq $index "") (ne $index "meilisearch") }}
{{- $_ := set $cfg "index" "meilisearch" }}
{{- $index = "meilisearch" }}
{{- end }}
{{- end }}
{{- if and (gt $replicas 1) (or (eq $index "") (eq $index "bleve")) }}
{{- fail "replicaCount>1 requires index set to 'meilisearch' (bleve not supported with multiple replicas)" }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
@@ -9,5 +58,5 @@ metadata:
type: Opaque
stringData:
config.yml: |-
{{- .Values.config | toYaml | nindent 4 }}
{{- $cfg | toYaml | nindent 4 }}
{{- end }}

View File

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

View File

@@ -0,0 +1,267 @@
{{- if .Values.statefulSet.enabled }}
{{- /*
========================================
VALIDATION BLOCK: Multi-replica requirements
========================================
Enforces constraints for scaling beyond 1 replica:
1. Database: Must use PostgreSQL/MySQL (not SQLite)
2. Persistence: Must be enabled
3. Storage sharing: Must use either existingClaim or mode=shared with create.enabled
4. Access mode: For mode=shared + create, must specify ReadWriteMany
*/}}
{{- $replicas := int .Values.replicaCount }}
{{- $dburi := "" }}
{{- if and .Values.config (hasKey .Values.config "dbUri") }}
{{- $dburi = (index .Values.config "dbUri") }}
{{- else if and .Values.config (hasKey .Values.config "db-uri") }}
{{- $dburi = (index .Values.config "db-uri") }}
{{- end }}
{{- $scheme := "" }}
{{- if ne $dburi "" }}
{{- $parts := splitList "://" $dburi }}
{{- if gt (len $parts) 0 }}
{{- $scheme = lower (index $parts 0) }}
{{- end }}
{{- end }}
{{- $multiAllowed := or (eq $scheme "postgres") (eq $scheme "postgresql") (eq $scheme "mysql") (eq $scheme "mariadb") }}
{{- $p := .Values.persistence }}
{{- $mode := default "perReplica" $p.mode }}
{{- $hasExisting := ne (default "" $p.existingClaim) "" }}
{{- $isShared := eq $mode "shared" }}
{{- /* Fail fast: Database validation */}}
{{- if and (gt $replicas 1) (not $multiAllowed) }}
{{- fail (printf "replicaCount=%d requires PostgreSQL/MySQL config.db-uri; scheme '%s' unsupported" $replicas $scheme) }}
{{- end }}
{{- /* Fail fast: Persistence must be enabled */}}
{{- if and (gt $replicas 1) (not $p.enabled) }}
{{- fail (printf "replicaCount=%d requires persistence.enabled=true" $replicas) }}
{{- end }}
{{- /* Fail fast: Prevent per-replica PVC divergence */}}
{{- if and (gt $replicas 1) (not (or $hasExisting $isShared)) }}
{{- fail (printf "replicaCount=%d requires either persistence.existingClaim (shared RWX PVC) OR persistence.mode=shared to create one; perReplica PVCs would diverge" $replicas) }}
{{- end }}
{{- /* Fail fast: Shared mode requires PVC source */}}
{{- if and (gt $replicas 1) $isShared (not $hasExisting) (hasKey $p "create") (not (get $p.create "enabled")) }}
{{- fail (printf "persistence.mode=shared but neither existingClaim nor create.enabled=true provided") }}
{{- end }}
{{- /* Fail fast: Auto-created shared PVC must be RWX */}}
{{- if and (gt $replicas 1) $isShared (not $hasExisting) $p.create.enabled }}
{{- $am := list }}
{{- if hasKey $p.create "accessModes" }}
{{- $am = $p.create.accessModes }}
{{- end }}
{{- $rwxOk := false }}
{{- range $am }}
{{- if or (eq . "ReadWriteMany") (eq . "RWX") }}
{{- $rwxOk = true }}
{{- end }}
{{- end }}
{{- if not $rwxOk }}
{{- fail "persistence.mode=shared create.accessModes must include ReadWriteMany for multi-replica" }}
{{- end }}
{{- end }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "opengist.fullname" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- if .Values.deployment.labels }}
{{- toYaml .Values.deployment.labels | nindent 4 }}
{{- end }}
{{- with .Values.deployment.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.replicaCount }}
serviceName: {{ include "opengist.fullname" . }}-http
podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }}
updateStrategy:
{{- toYaml .Values.statefulSet.updateStrategy | nindent 4 }}
selector:
matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "opengist.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- if .Values.deployment.terminationGracePeriodSeconds }}
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "opengist.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
initContainers:
- name: init-config
image: busybox:1.37
imagePullPolicy: IfNotPresent
command: ['sh', '-c', 'cp /init/config/config.yml /config-volume/config.yml']
volumeMounts:
- name: config-secret
mountPath: /init/config
- name: config-volume
mountPath: /config-volume
{{- if .Values.deployment.env }}
env:
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.http.port }}
protocol: TCP
{{- if .Values.service.ssh.enabled }}
- name: ssh
containerPort: {{ .Values.service.ssh.port }}
protocol: TCP
{{- end }}
{{- if index .Values.config "metrics.enabled" }}
- name: metrics
containerPort: {{ .Values.service.metrics.port }}
protocol: TCP
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}
httpGet:
port: http
path: /healthcheck
{{- end }}
{{- if .Values.readinessProbe.enabled }}
readinessProbe:
{{- toYaml (omit .Values.readinessProbe "enabled") | nindent 12 }}
httpGet:
port: http
path: /healthcheck
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: config-volume
mountPath: /config.yml
subPath: config.yml
- name: opengist-data
mountPath: /opengist
{{- if gt (len .Values.extraVolumeMounts) 0 }}
{{- toYaml .Values.extraVolumeMounts | nindent 12 }}
{{- end }}
volumes:
- name: config-secret
secret:
secretName: {{ include "opengist.secretName" . }}
defaultMode: 511
- name: config-volume
emptyDir: {}
{{- /*
========================================
VOLUME MOUNTING DECISION TREE
========================================
Priority order:
1. existingClaim (user-provided PVC) → mount directly
2. mode=shared (chart-created PVC) → mount shared PVC
3. mode=perReplica → use volumeClaimTemplates (defined below)
4. persistence disabled → use emptyDir (ephemeral)
*/}}
{{- if .Values.persistence.enabled }}
{{- if ne (default "" .Values.persistence.existingClaim) "" }}
{{- /* User-provided existing claim: mount directly */}}
- name: opengist-data
persistentVolumeClaim:
claimName: {{ .Values.persistence.existingClaim }}
{{- else if eq (default "perReplica" .Values.persistence.mode) "shared" }}
{{- /* Chart creates shared PVC (via pvc-shared.yaml), reference by name */}}
- name: opengist-data
persistentVolumeClaim:
claimName: {{ include "opengist.fullname" . }}-{{ default "shared" .Values.persistence.create.nameSuffix }}
{{- else if not .Values.persistence.enabled }}
- name: opengist-data
emptyDir: {}
{{- end }}
{{- else }}
- name: opengist-data
emptyDir: {}
{{- end }}
{{- if gt (len .Values.extraVolumes) 0 }}
{{- toYaml .Values.extraVolumes | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- /*
========================================
VOLUMECLAIMTEMPLATES DECISION TREE
========================================
volumeClaimTemplates are ONLY used for perReplica mode when:
- persistence.enabled=true
- persistence.existingClaim is empty
- persistence.mode=perReplica (default)
This creates one PVC per replica (RWO typically).
NOT used when:
- existingClaim is set (PVC already exists, referenced in volumes above)
- mode=shared (standalone PVC created via pvc-shared.yaml)
- persistence disabled (emptyDir used)
WARNING: perReplica + replicaCount>1 causes data divergence. Use shared mode for multi-replica.
*/}}
{{- if and .Values.persistence.enabled (ne (default "" .Values.persistence.existingClaim) "") }}
{{- /* existingClaim path: no volumeClaimTemplates, already mounted above */}}
{{- else if and .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "shared") }}
{{- /* shared mode: no volumeClaimTemplates, standalone PVC rendered via pvc-shared.yaml */}}
{{- else if and .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "perReplica") }}
volumeClaimTemplates:
- metadata:
name: opengist-data
labels:
{{- include "opengist.labels" . | nindent 10 }}
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 10 }}
{{- end }}
spec:
accessModes:
{{- .Values.persistence.accessModes | toYaml | nindent 10 }}
volumeMode: Filesystem
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size | default "10Gi" }}
{{- end }}
{{- end }}

View File

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

View File

@@ -8,6 +8,7 @@ namespace: ""
config:
log-level: "warn"
log-output: "stdout"
metrics.enabled: false
## If defined, the existing secret will be used instead of creating a new one.
## The secret must contain a key named `config.yml` with the YAML configuration.
@@ -17,7 +18,7 @@ configExistingSecret: ""
image:
repository: ghcr.io/thomiceli/opengist
pullPolicy: Always
tag: "1.10.0"
tag: "1.12.1"
digest: ""
imagePullSecrets: []
# - name: "image-pull-secret"
@@ -32,6 +33,34 @@ strategy:
maxSurge: "100%"
maxUnavailable: 0
## StatefulSet configuration
## Enables StatefulSet workload instead of Deployment (required for volumeClaimTemplates or stable pod identities).
##
## Single-replica SQLite example (default behavior):
## statefulSet.enabled: true
## replicaCount: 1
## persistence.mode: perReplica # or omit (default)
## # Creates one PVC per pod via volumeClaimTemplates (RWO)
##
## Multi-replica requirements (replicaCount > 1):
## 1. External database: config.db-uri must be postgres:// or mysql:// (SQLite NOT supported)
## 2. Shared storage: Use ONE of:
## a) Existing claim: persistence.existingClaim: "my-rwx-pvc"
## b) Chart-created: persistence.mode: shared + persistence.create.enabled: true + accessModes: [ReadWriteMany]
## 3. Chart will FAIL FAST if constraints are not met to prevent data divergence
##
## Persistence decision tree:
## - persistence.existingClaim set → mount that PVC directly (no volumeClaimTemplates)
## - persistence.mode=shared + create.* → chart creates single RWX PVC, all pods mount it
## - persistence.mode=perReplica (default) → volumeClaimTemplates (one PVC/pod, RWO typically)
## - persistence.enabled=false → emptyDir (ephemeral)
statefulSet:
enabled: false
podManagementPolicy: OrderedReady
updateStrategy:
type: RollingUpdate
## Security Context settings
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
podSecurityContext:
@@ -73,6 +102,26 @@ service:
loadBalancerSourceRanges: []
externalTrafficPolicy:
# A metrics K8S service on port 6158 is created when the Opengist config metrics.enabled: true
metrics:
type: ClusterIP
clusterIP:
port: 6158
nodePort:
labels: {}
annotations: {}
# A service monitor can be used to work with your Prometheus setup.
serviceMonitor:
enabled: true
labels: {}
# release: kube-prom-stack
interval:
scrapeTimeout:
annotations: {}
relabelings: []
metricRelabelings: []
## HTTP Ingress for Opengist
## ref: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress:
@@ -99,20 +148,66 @@ serviceAccount:
annotations: {}
name: ""
## Set persistence using a Persistent Volume Claim
## If more than 2 replicas are set, the access mode must be ReadWriteMany
## Persistent storage for /opengist data directory
## ref: https://kubernetes.io/docs/concepts/storage/persistent-volumes/
persistence:
enabled: true
## Persistence mode controls how storage is provisioned:
##
## perReplica (DEFAULT):
## - StatefulSet creates one PVC per replica via volumeClaimTemplates
## - Typically RWO (ReadWriteOnce) storage
## - Safe ONLY for replicaCount=1 (multi-replica causes data divergence)
## - Use when: single-node dev/test, no horizontal scaling needed
##
## shared:
## - Single RWX (ReadWriteMany) PVC shared by all replicas
## - Required for replicaCount > 1
## - Two provisioning paths:
## a) existingClaim: "my-rwx-pvc" (you manage the PVC lifecycle)
## b) existingClaim: "" + create.enabled: true (chart creates PVC automatically)
## - Use when: multi-replica HA, horizontal scaling, shared file access
##
## WARNING: Switching modes after initial deploy requires manual data migration:
## 1. Scale down to 1 replica
## 2. Create/provision RWX PVC and copy data
## 3. Update values: mode=shared, existingClaim or create.enabled
## 4. Scale up
mode: perReplica
## Reference an existing PVC (takes precedence over create.*)
## When set:
## - Chart will NOT create a PVC
## - StatefulSet mounts this claim directly (no volumeClaimTemplates)
## - Must be RWX for replicaCount > 1
## Example: existingClaim: "opengist-shared-rwx"
existingClaim: ""
storageClass: ""
## Common persistence parameters (apply to perReplica mode OR as defaults for create.*)
storageClass: "" # Empty = cluster default
labels: {}
annotations:
helm.sh/resource-policy: keep
helm.sh/resource-policy: keep # Prevents PVC deletion on helm uninstall
size: 5Gi
accessModes:
- ReadWriteOnce
subPath: ""
- ReadWriteOnce # perReplica default; override to [ReadWriteMany] if using existingClaim
subPath: "" # Optional subpath within volume
## Chart-managed PVC creation (ONLY for mode=shared when existingClaim is empty)
## Renders templates/pvc-shared.yaml
create:
enabled: true
nameSuffix: shared # PVC name: <release-name>-shared
storageClass: "" # Empty = cluster default; override if you need specific storage class
size: 5Gi # Override top-level persistence.size if needed
accessModes:
- ReadWriteMany # REQUIRED for multi-replica; NFS/CephFS/Longhorn RWX/etc.
labels: {}
annotations: {}
## Example for specific storage:
## storageClass: "nfs-client"
## size: 20Gi
extraVolumes: []
extraVolumeMounts: []

View File

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

View File

@@ -0,0 +1,64 @@
package ldap
import (
"fmt"
"github.com/go-ldap/ldap/v3"
"github.com/thomiceli/opengist/internal/config"
)
func Enabled() bool {
return config.C.LDAPUrl != ""
}
// Authenticate attempts to authenticate a user against the configured LDAP instance.
func Authenticate(username, password string) (bool, error) {
l, err := ldap.DialURL(config.C.LDAPUrl)
if err != nil {
return false, fmt.Errorf("unable to connect to URI: %v", config.C.LDAPUrl)
}
defer func(l *ldap.Conn) {
_ = l.Close()
}(l)
// First bind with a read only user
err = l.Bind(config.C.LDAPBindDn, config.C.LDAPBindCredentials)
if err != nil {
return false, err
}
searchFilter := fmt.Sprintf(config.C.LDAPSearchFilter, username)
searchRequest := ldap.NewSearchRequest(
config.C.LDAPSearchBase,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0,
0,
false,
searchFilter,
[]string{"dn"},
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
return false, err
}
if len(sr.Entries) != 1 {
return false, nil
}
// Bind as the user to verify their password
err = l.Bind(sr.Entries[0].DN, password)
if err != nil {
return false, nil
}
// Rebind as the read only user for any further queries
err = l.Bind(config.C.LDAPBindDn, config.C.LDAPBindCredentials)
if err != nil {
return false, err
}
return true, nil
}

View File

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

View File

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

View File

@@ -2,13 +2,17 @@ package oauth
import (
gocontext "context"
gojson "encoding/json"
"io"
"net/http"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/gitlab"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"net/http"
)
type GitLabProvider struct {
@@ -77,7 +81,38 @@ func (p *GitLabCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
func (p *GitLabCallbackProvider) UpdateUserDB(user *db.User) {
user.GitlabID = p.User.UserID
user.AvatarURL = urlJoin(config.C.GitlabUrl, "/uploads/-/system/user/avatar/", p.User.UserID, "/avatar.png") + "?width=400"
resp, err := http.Get(urlJoin(config.C.GitlabUrl, "/api/v4/avatar?size=400&email=", p.User.Email))
if err != nil {
log.Error().Err(err).Msg("Cannot get user avatar from GitLab")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("Cannot read Gitlab response body")
return
}
var result map[string]interface{}
err = gojson.Unmarshal(body, &result)
if err != nil {
log.Error().Err(err).Msg("Cannot unmarshal Gitlab response body")
return
}
field, ok := result["avatar_url"]
if !ok {
log.Error().Msg("Field 'avatar_url' not found in Gitlab JSON response")
return
}
user.AvatarURL = field.(string)
}
func (p *GitLabCallbackProvider) IsAdmin() bool {
return false
}
func NewGitLabCallbackProvider(user *goth.User) CallbackProvider {

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package auth
package password
import (
"crypto/rand"
@@ -6,8 +6,9 @@ import (
"encoding/base64"
"errors"
"fmt"
"golang.org/x/crypto/argon2"
"strings"
"golang.org/x/crypto/argon2"
)
type argon2ID struct {

View File

@@ -0,0 +1,427 @@
package password
import (
"encoding/base64"
"strings"
"testing"
)
func TestArgon2ID_Hash(t *testing.T) {
tests := []struct {
name string
plain string
wantErr bool
}{
{
name: "basic password",
plain: "password123",
wantErr: false,
},
{
name: "empty string",
plain: "",
wantErr: false,
},
{
name: "long password",
plain: strings.Repeat("a", 10000),
wantErr: false,
},
{
name: "unicode password",
plain: "パスワード🔒",
wantErr: false,
},
{
name: "special characters",
plain: "!@#$%^&*()_+-=[]{}|;:',.<>?/`~",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash, err := Argon2id.Hash(tt.plain)
if (err != nil) != tt.wantErr {
t.Errorf("Argon2id.Hash() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
// Verify the hash format
if !strings.HasPrefix(hash, "$argon2id$") {
t.Errorf("Hash does not start with $argon2id$: %v", hash)
}
// Verify all parts are present
parts := strings.Split(hash, "$")
if len(parts) != 6 {
t.Errorf("Hash has %d parts, expected 6: %v", len(parts), hash)
}
// Verify salt is properly encoded
if len(parts) >= 5 {
_, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
t.Errorf("Salt is not properly base64 encoded: %v", err)
}
}
// Verify hash is properly encoded
if len(parts) >= 6 {
_, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
t.Errorf("Hash is not properly base64 encoded: %v", err)
}
}
}
})
}
}
func TestArgon2ID_Verify(t *testing.T) {
// Generate a valid hash for testing
testPassword := "correctpassword"
validHash, err := Argon2id.Hash(testPassword)
if err != nil {
t.Fatalf("Failed to generate test hash: %v", err)
}
tests := []struct {
name string
plain string
hash string
wantMatch bool
wantErr bool
}{
{
name: "correct password",
plain: testPassword,
hash: validHash,
wantMatch: true,
wantErr: false,
},
{
name: "incorrect password",
plain: "wrongpassword",
hash: validHash,
wantMatch: false,
wantErr: false,
},
{
name: "empty password",
plain: "",
hash: validHash,
wantMatch: false,
wantErr: false,
},
{
name: "empty hash",
plain: testPassword,
hash: "",
wantMatch: false,
wantErr: false,
},
{
name: "invalid hash - too few parts",
plain: testPassword,
hash: "$argon2id$v=19$m=65536",
wantMatch: false,
wantErr: true,
},
{
name: "invalid hash - too many parts",
plain: testPassword,
hash: "$argon2id$v=19$m=65536,t=1,p=4$salt$hash$extra",
wantMatch: false,
wantErr: true,
},
{
name: "invalid hash - malformed parameters",
plain: testPassword,
hash: "$argon2id$v=19$invalid$salt$hash",
wantMatch: false,
wantErr: true,
},
{
name: "invalid hash - bad base64 salt",
plain: testPassword,
hash: "$argon2id$v=19$m=65536,t=1,p=4$not-valid-base64!@#$hash",
wantMatch: false,
wantErr: true,
},
{
name: "invalid hash - bad base64 hash",
plain: testPassword,
hash: "$argon2id$v=19$m=65536,t=1,p=4$dGVzdA$not-valid-base64!@#",
wantMatch: false,
wantErr: true,
},
{
name: "wrong algorithm prefix",
plain: testPassword,
hash: "$bcrypt$rounds=10$saltsaltsaltsaltsalt",
wantMatch: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
match, err := Argon2id.Verify(tt.plain, tt.hash)
if (err != nil) != tt.wantErr {
t.Errorf("Argon2id.Verify() error = %v, wantErr %v", err, tt.wantErr)
return
}
if match != tt.wantMatch {
t.Errorf("Argon2id.Verify() match = %v, wantMatch %v", match, tt.wantMatch)
}
})
}
}
func TestArgon2ID_SaltUniqueness(t *testing.T) {
password := "testpassword"
iterations := 10
hashes := make(map[string]bool)
salts := make(map[string]bool)
for i := 0; i < iterations; i++ {
hash, err := Argon2id.Hash(password)
if err != nil {
t.Fatalf("Hash iteration %d failed: %v", i, err)
}
// Check hash uniqueness
if hashes[hash] {
t.Errorf("Duplicate hash generated at iteration %d", i)
}
hashes[hash] = true
// Extract and check salt uniqueness
parts := strings.Split(hash, "$")
if len(parts) >= 5 {
salt := parts[4]
if salts[salt] {
t.Errorf("Duplicate salt generated at iteration %d", i)
}
salts[salt] = true
}
// Verify each hash works
match, err := Argon2id.Verify(password, hash)
if err != nil || !match {
t.Errorf("Hash %d failed verification: err=%v, match=%v", i, err, match)
}
}
}
func TestArgon2ID_HashFormat(t *testing.T) {
password := "testformat"
hash, err := Argon2id.Hash(password)
if err != nil {
t.Fatalf("Hash failed: %v", err)
}
parts := strings.Split(hash, "$")
if len(parts) != 6 {
t.Fatalf("Expected 6 parts, got %d: %v", len(parts), hash)
}
// Part 0 should be empty (before first $)
if parts[0] != "" {
t.Errorf("Part 0 should be empty, got: %v", parts[0])
}
// Part 1 should be "argon2id"
if parts[1] != "argon2id" {
t.Errorf("Part 1 should be 'argon2id', got: %v", parts[1])
}
// Part 2 should be version
if !strings.HasPrefix(parts[2], "v=") {
t.Errorf("Part 2 should start with 'v=', got: %v", parts[2])
}
// Part 3 should be parameters
if !strings.Contains(parts[3], "m=") || !strings.Contains(parts[3], "t=") || !strings.Contains(parts[3], "p=") {
t.Errorf("Part 3 should contain m=, t=, and p=, got: %v", parts[3])
}
// Part 4 should be base64 encoded salt
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
t.Errorf("Salt (part 4) is not valid base64: %v", err)
}
if len(salt) != int(Argon2id.saltLen) {
t.Errorf("Salt length is %d, expected %d", len(salt), Argon2id.saltLen)
}
// Part 5 should be base64 encoded hash
decodedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
t.Errorf("Hash (part 5) is not valid base64: %v", err)
}
if len(decodedHash) != int(Argon2id.keyLen) {
t.Errorf("Hash length is %d, expected %d", len(decodedHash), Argon2id.keyLen)
}
}
func TestArgon2ID_CaseModification(t *testing.T) {
// Passwords should be case-sensitive
password := "TestPassword"
hash, err := Argon2id.Hash(password)
if err != nil {
t.Fatalf("Hash failed: %v", err)
}
// Correct case should match
match, err := Argon2id.Verify(password, hash)
if err != nil || !match {
t.Errorf("Correct password failed: err=%v, match=%v", err, match)
}
// Wrong case should not match
match, err = Argon2id.Verify("testpassword", hash)
if err != nil {
t.Errorf("Verify returned error: %v", err)
}
if match {
t.Error("Password verification should be case-sensitive")
}
match, err = Argon2id.Verify("TESTPASSWORD", hash)
if err != nil {
t.Errorf("Verify returned error: %v", err)
}
if match {
t.Error("Password verification should be case-sensitive")
}
}
func TestArgon2ID_InvalidParameters(t *testing.T) {
password := "testpassword"
tests := []struct {
name string
hash string
wantErr bool
}{
{
name: "negative memory parameter",
hash: "$argon2id$v=19$m=-1,t=1,p=4$dGVzdHNhbHQ$testhash",
wantErr: true,
},
{
name: "negative time parameter",
hash: "$argon2id$v=19$m=65536,t=-1,p=4$dGVzdHNhbHQ$testhash",
wantErr: true,
},
{
name: "negative parallelism parameter",
hash: "$argon2id$v=19$m=65536,t=1,p=-4$dGVzdHNhbHQ$testhash",
wantErr: true,
},
{
name: "zero memory parameter",
hash: "$argon2id$v=19$m=0,t=1,p=4$dGVzdHNhbHQ$testhash",
wantErr: false, // argon2 may handle this, we just test parsing
},
{
name: "missing parameter value",
hash: "$argon2id$v=19$m=,t=1,p=4$dGVzdHNhbHQ$testhash",
wantErr: true,
},
{
name: "non-numeric parameter",
hash: "$argon2id$v=19$m=abc,t=1,p=4$dGVzdHNhbHQ$testhash",
wantErr: true,
},
{
name: "missing parameters separator",
hash: "$argon2id$v=19$m=65536 t=1 p=4$dGVzdHNhbHQ$testhash",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Argon2id.Verify(password, tt.hash)
if (err != nil) != tt.wantErr {
t.Errorf("Argon2id.Verify() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestArgon2ID_ConcurrentHashing(t *testing.T) {
password := "testpassword"
concurrency := 10
type result struct {
hash string
err error
}
results := make(chan result, concurrency)
// Generate hashes concurrently
for i := 0; i < concurrency; i++ {
go func() {
hash, err := Argon2id.Hash(password)
results <- result{hash: hash, err: err}
}()
}
// Collect results
hashes := make(map[string]bool)
for i := 0; i < concurrency; i++ {
res := <-results
if res.err != nil {
t.Errorf("Concurrent hash %d failed: %v", i, res.err)
continue
}
// Check for duplicates
if hashes[res.hash] {
t.Errorf("Duplicate hash generated in concurrent test")
}
hashes[res.hash] = true
// Verify each hash works
match, err := Argon2id.Verify(password, res.hash)
if err != nil || !match {
t.Errorf("Hash %d failed verification: err=%v, match=%v", i, err, match)
}
}
}
func TestArgon2ID_VeryLongPassword(t *testing.T) {
// Test with extremely long password (100KB)
password := strings.Repeat("a", 100*1024)
hash, err := Argon2id.Hash(password)
if err != nil {
t.Fatalf("Failed to hash very long password: %v", err)
}
match, err := Argon2id.Verify(password, hash)
if err != nil {
t.Fatalf("Failed to verify very long password: %v", err)
}
if !match {
t.Error("Very long password failed verification")
}
// Verify wrong password still fails
wrongPassword := strings.Repeat("b", 100*1024)
match, err = Argon2id.Verify(wrongPassword, hash)
if err != nil {
t.Errorf("Verify returned error: %v", err)
}
if match {
t.Error("Wrong very long password should not match")
}
}

View File

@@ -1,11 +1,9 @@
package password
import "github.com/thomiceli/opengist/internal/auth"
func HashPassword(code string) (string, error) {
return auth.Argon2id.Hash(code)
return Argon2id.Hash(code)
}
func VerifyPassword(code, hashedCode string) (bool, error) {
return auth.Argon2id.Verify(code, hashedCode)
return Argon2id.Verify(code, hashedCode)
}

View File

@@ -0,0 +1,193 @@
package password
import (
"strings"
"testing"
)
func TestHashPassword(t *testing.T) {
tests := []struct {
name string
password string
wantErr bool
}{
{
name: "simple password",
password: "password123",
wantErr: false,
},
{
name: "empty password",
password: "",
wantErr: false,
},
{
name: "long password",
password: strings.Repeat("a", 1000),
wantErr: false,
},
{
name: "special characters",
password: "p@ssw0rd!#$%^&*()",
wantErr: false,
},
{
name: "unicode characters",
password: "パスワード123",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash, err := HashPassword(tt.password)
if (err != nil) != tt.wantErr {
t.Errorf("HashPassword() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
// Verify hash format
if !strings.HasPrefix(hash, "$argon2id$") {
t.Errorf("HashPassword() returned invalid hash format: %v", hash)
}
// Verify hash has correct number of parts
parts := strings.Split(hash, "$")
if len(parts) != 6 {
t.Errorf("HashPassword() returned hash with incorrect number of parts: %v", len(parts))
}
}
})
}
}
func TestVerifyPassword(t *testing.T) {
// Pre-generate a known hash for testing
testPassword := "testpassword123"
testHash, err := HashPassword(testPassword)
if err != nil {
t.Fatalf("Failed to generate test hash: %v", err)
}
tests := []struct {
name string
password string
hash string
wantMatch bool
wantErr bool
}{
{
name: "correct password",
password: testPassword,
hash: testHash,
wantMatch: true,
wantErr: false,
},
{
name: "incorrect password",
password: "wrongpassword",
hash: testHash,
wantMatch: false,
wantErr: false,
},
{
name: "empty password against valid hash",
password: "",
hash: testHash,
wantMatch: false,
wantErr: false,
},
{
name: "empty hash",
password: testPassword,
hash: "",
wantMatch: false,
wantErr: false,
},
{
name: "invalid hash format",
password: testPassword,
hash: "invalid",
wantMatch: false,
wantErr: true,
},
{
name: "malformed hash - wrong prefix",
password: testPassword,
hash: "$bcrypt$invalid$hash",
wantMatch: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
match, err := VerifyPassword(tt.password, tt.hash)
if (err != nil) != tt.wantErr {
t.Errorf("VerifyPassword() error = %v, wantErr %v", err, tt.wantErr)
return
}
if match != tt.wantMatch {
t.Errorf("VerifyPassword() match = %v, wantMatch %v", match, tt.wantMatch)
}
})
}
}
func TestHashPasswordUniqueness(t *testing.T) {
password := "testpassword"
// Generate multiple hashes of the same password
hash1, err := HashPassword(password)
if err != nil {
t.Fatalf("Failed to hash password: %v", err)
}
hash2, err := HashPassword(password)
if err != nil {
t.Fatalf("Failed to hash password: %v", err)
}
// Hashes should be different due to different salts
if hash1 == hash2 {
t.Error("HashPassword() should generate unique hashes for the same password")
}
// But both should verify correctly
match1, err := VerifyPassword(password, hash1)
if err != nil || !match1 {
t.Errorf("Failed to verify first hash: err=%v, match=%v", err, match1)
}
match2, err := VerifyPassword(password, hash2)
if err != nil || !match2 {
t.Errorf("Failed to verify second hash: err=%v, match=%v", err, match2)
}
}
func TestPasswordRoundTrip(t *testing.T) {
tests := []string{
"simple",
"with spaces and special chars !@#$%",
"パスワード",
strings.Repeat("long", 100),
"",
}
for _, password := range tests {
t.Run(password, func(t *testing.T) {
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("HashPassword() failed: %v", err)
}
match, err := VerifyPassword(password, hash)
if err != nil {
t.Fatalf("VerifyPassword() failed: %v", err)
}
if !match {
t.Error("Password round trip failed: hashed password does not verify")
}
})
}
}

View File

@@ -1,4 +1,4 @@
package auth
package totp
import (
"crypto/aes"
@@ -19,7 +19,8 @@ func AESEncrypt(key, text []byte) ([]byte, error) {
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
return nil, err
}
// TODO: remove deprecated
//nolint:staticcheck
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(ciphertext[aes.BlockSize:], text)
@@ -38,7 +39,8 @@ func AESDecrypt(key, ciphertext []byte) ([]byte, error) {
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
// TODO: remove deprecated
//nolint:staticcheck
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(ciphertext, ciphertext)

View File

@@ -0,0 +1,430 @@
package totp
import (
"bytes"
"crypto/aes"
"testing"
)
func TestAESEncrypt(t *testing.T) {
tests := []struct {
name string
key []byte
text []byte
wantErr bool
}{
{
name: "basic encryption with 16-byte key",
key: []byte("1234567890123456"), // 16 bytes (AES-128)
text: []byte("hello world"),
wantErr: false,
},
{
name: "basic encryption with 24-byte key",
key: []byte("123456789012345678901234"), // 24 bytes (AES-192)
text: []byte("hello world"),
wantErr: false,
},
{
name: "basic encryption with 32-byte key",
key: []byte("12345678901234567890123456789012"), // 32 bytes (AES-256)
text: []byte("hello world"),
wantErr: false,
},
{
name: "empty text",
key: []byte("1234567890123456"),
text: []byte(""),
wantErr: false,
},
{
name: "long text",
key: []byte("1234567890123456"),
text: []byte("This is a much longer text that spans multiple blocks and should be encrypted properly without any issues"),
wantErr: false,
},
{
name: "binary data",
key: []byte("1234567890123456"),
text: []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD},
wantErr: false,
},
{
name: "invalid key length - too short",
key: []byte("short"),
text: []byte("hello world"),
wantErr: true,
},
{
name: "invalid key length - 17 bytes",
key: []byte("12345678901234567"), // 17 bytes (invalid)
text: []byte("hello world"),
wantErr: true,
},
{
name: "nil key",
key: nil,
text: []byte("hello world"),
wantErr: true,
},
{
name: "empty key",
key: []byte(""),
text: []byte("hello world"),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ciphertext, err := AESEncrypt(tt.key, tt.text)
if (err != nil) != tt.wantErr {
t.Errorf("AESEncrypt() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
// Verify ciphertext is not empty
if len(ciphertext) == 0 {
t.Error("AESEncrypt() returned empty ciphertext")
}
// Verify ciphertext length is correct (IV + encrypted text)
expectedLen := aes.BlockSize + len(tt.text)
if len(ciphertext) != expectedLen {
t.Errorf("AESEncrypt() ciphertext length = %d, want %d", len(ciphertext), expectedLen)
}
// Verify ciphertext is different from plaintext (unless text is empty)
if len(tt.text) > 0 && bytes.Equal(ciphertext[aes.BlockSize:], tt.text) {
t.Error("AESEncrypt() ciphertext matches plaintext")
}
// Verify IV is present and non-zero
iv := ciphertext[:aes.BlockSize]
allZeros := true
for _, b := range iv {
if b != 0 {
allZeros = false
break
}
}
if allZeros {
t.Error("AESEncrypt() IV is all zeros")
}
}
})
}
}
func TestAESDecrypt(t *testing.T) {
validKey := []byte("1234567890123456")
validText := []byte("hello world")
// Encrypt some data to use for valid test cases
validCiphertext, err := AESEncrypt(validKey, validText)
if err != nil {
t.Fatalf("Failed to create valid ciphertext: %v", err)
}
tests := []struct {
name string
key []byte
ciphertext []byte
wantErr bool
}{
{
name: "valid decryption",
key: validKey,
ciphertext: validCiphertext,
wantErr: false,
},
{
name: "ciphertext too short - empty",
key: validKey,
ciphertext: []byte(""),
wantErr: true,
},
{
name: "ciphertext too short - less than block size",
key: validKey,
ciphertext: []byte("short"),
wantErr: true,
},
{
name: "ciphertext exactly block size (IV only, no data)",
key: validKey,
ciphertext: make([]byte, aes.BlockSize),
wantErr: false,
},
{
name: "invalid key length",
key: []byte("short"),
ciphertext: validCiphertext,
wantErr: true,
},
{
name: "wrong key",
key: []byte("6543210987654321"),
ciphertext: validCiphertext,
wantErr: false, // Decryption succeeds but produces garbage
},
{
name: "nil key",
key: nil,
ciphertext: validCiphertext,
wantErr: true,
},
{
name: "nil ciphertext",
key: validKey,
ciphertext: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plaintext, err := AESDecrypt(tt.key, tt.ciphertext)
if (err != nil) != tt.wantErr {
t.Errorf("AESDecrypt() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
// For valid decryption with correct key, verify we get original text
if tt.name == "valid decryption" && !bytes.Equal(plaintext, validText) {
t.Errorf("AESDecrypt() plaintext = %v, want %v", plaintext, validText)
}
// For ciphertext with only IV, plaintext should be empty
if tt.name == "ciphertext exactly block size (IV only, no data)" && len(plaintext) != 0 {
t.Errorf("AESDecrypt() plaintext length = %d, want 0", len(plaintext))
}
}
})
}
}
func TestAESEncryptDecrypt_RoundTrip(t *testing.T) {
tests := []struct {
name string
key []byte
text []byte
}{
{
name: "basic round trip",
key: []byte("1234567890123456"),
text: []byte("hello world"),
},
{
name: "empty text round trip",
key: []byte("1234567890123456"),
text: []byte(""),
},
{
name: "long text round trip",
key: []byte("1234567890123456"),
text: []byte("This is a very long text that contains multiple blocks of data and should be encrypted and decrypted correctly without any data loss or corruption"),
},
{
name: "binary data round trip",
key: []byte("1234567890123456"),
text: []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD, 0xFC},
},
{
name: "unicode text round trip",
key: []byte("1234567890123456"),
text: []byte("Hello 世界! 🔐 Encryption"),
},
{
name: "AES-192 round trip",
key: []byte("123456789012345678901234"),
text: []byte("testing AES-192"),
},
{
name: "AES-256 round trip",
key: []byte("12345678901234567890123456789012"),
text: []byte("testing AES-256"),
},
{
name: "special characters",
key: []byte("1234567890123456"),
text: []byte("!@#$%^&*()_+-=[]{}|;':\",./<>?"),
},
{
name: "newlines and tabs",
key: []byte("1234567890123456"),
text: []byte("line1\nline2\tline3\r\nline4"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Encrypt
ciphertext, err := AESEncrypt(tt.key, tt.text)
if err != nil {
t.Fatalf("AESEncrypt() failed: %v", err)
}
// Decrypt
plaintext, err := AESDecrypt(tt.key, ciphertext)
if err != nil {
t.Fatalf("AESDecrypt() failed: %v", err)
}
// Verify plaintext matches original
if !bytes.Equal(plaintext, tt.text) {
t.Errorf("Round trip failed: got %v, want %v", plaintext, tt.text)
}
})
}
}
func TestAESEncrypt_Uniqueness(t *testing.T) {
key := []byte("1234567890123456")
text := []byte("hello world")
iterations := 10
ciphertexts := make(map[string]bool)
for i := 0; i < iterations; i++ {
ciphertext, err := AESEncrypt(key, text)
if err != nil {
t.Fatalf("Iteration %d failed: %v", i, err)
}
// Each encryption should produce different ciphertext (due to random IV)
ciphertextStr := string(ciphertext)
if ciphertexts[ciphertextStr] {
t.Errorf("Duplicate ciphertext generated at iteration %d", i)
}
ciphertexts[ciphertextStr] = true
// But all should decrypt to the same plaintext
plaintext, err := AESDecrypt(key, ciphertext)
if err != nil {
t.Fatalf("Iteration %d decryption failed: %v", i, err)
}
if !bytes.Equal(plaintext, text) {
t.Errorf("Iteration %d: decrypted text doesn't match original", i)
}
}
}
func TestAESEncrypt_IVUniqueness(t *testing.T) {
key := []byte("1234567890123456")
text := []byte("test data")
iterations := 20
ivs := make(map[string]bool)
for i := 0; i < iterations; i++ {
ciphertext, err := AESEncrypt(key, text)
if err != nil {
t.Fatalf("Iteration %d failed: %v", i, err)
}
// Extract IV (first block)
iv := ciphertext[:aes.BlockSize]
ivStr := string(iv)
// Each IV should be unique
if ivs[ivStr] {
t.Errorf("Duplicate IV generated at iteration %d", i)
}
ivs[ivStr] = true
}
}
func TestAESDecrypt_WrongKey(t *testing.T) {
originalKey := []byte("1234567890123456")
wrongKey := []byte("6543210987654321")
text := []byte("secret message")
// Encrypt with original key
ciphertext, err := AESEncrypt(originalKey, text)
if err != nil {
t.Fatalf("AESEncrypt() failed: %v", err)
}
// Decrypt with wrong key - should not error but produce wrong plaintext
plaintext, err := AESDecrypt(wrongKey, ciphertext)
if err != nil {
t.Fatalf("AESDecrypt() with wrong key failed: %v", err)
}
// Plaintext should be different from original
if bytes.Equal(plaintext, text) {
t.Error("AESDecrypt() with wrong key produced correct plaintext")
}
}
func TestAESDecrypt_CorruptedCiphertext(t *testing.T) {
key := []byte("1234567890123456")
text := []byte("hello world")
// Encrypt
ciphertext, err := AESEncrypt(key, text)
if err != nil {
t.Fatalf("AESEncrypt() failed: %v", err)
}
// Corrupt the ciphertext (flip a bit in the encrypted data, not the IV)
if len(ciphertext) > aes.BlockSize {
corruptedCiphertext := make([]byte, len(ciphertext))
copy(corruptedCiphertext, ciphertext)
corruptedCiphertext[aes.BlockSize] ^= 0xFF
// Decrypt corrupted ciphertext - should not error but produce wrong plaintext
plaintext, err := AESDecrypt(key, corruptedCiphertext)
if err != nil {
t.Fatalf("AESDecrypt() with corrupted ciphertext failed: %v", err)
}
// Plaintext should be different from original
if bytes.Equal(plaintext, text) {
t.Error("AESDecrypt() with corrupted ciphertext produced correct plaintext")
}
}
}
func TestAESEncryptDecrypt_DifferentKeySizes(t *testing.T) {
tests := []struct {
name string
keySize int
}{
{"AES-128", 16},
{"AES-192", 24},
{"AES-256", 32},
}
text := []byte("test message for different key sizes")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Generate key of specified size
key := make([]byte, tt.keySize)
for i := range key {
key[i] = byte(i)
}
// Encrypt
ciphertext, err := AESEncrypt(key, text)
if err != nil {
t.Fatalf("AESEncrypt() failed: %v", err)
}
// Decrypt
plaintext, err := AESDecrypt(key, ciphertext)
if err != nil {
t.Fatalf("AESDecrypt() failed: %v", err)
}
// Verify
if !bytes.Equal(plaintext, text) {
t.Errorf("Round trip failed for %s", tt.name)
}
})
}
}

View File

@@ -4,20 +4,21 @@ import (
"bytes"
"crypto/rand"
"encoding/base64"
"github.com/pquerna/otp/totp"
"html/template"
"image/png"
"strings"
"github.com/pquerna/otp/totp"
)
const secretSize = 16
func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.URL, error, []byte) {
func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.URL, []byte, error) {
var err error
if secret == nil {
secret, err = generateSecret()
if err != nil {
return "", "", err, nil
return "", "", nil, err
}
}
@@ -28,22 +29,22 @@ func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.U
Secret: secret,
})
if err != nil {
return "", "", err, nil
return "", "", nil, err
}
qrcode, err := otpKey.Image(320, 240)
if err != nil {
return "", "", err, nil
return "", "", nil, err
}
var imgBytes bytes.Buffer
if err = png.Encode(&imgBytes, qrcode); err != nil {
return "", "", err, nil
return "", "", nil, err
}
qrcodeImage := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes()))
return otpKey.Secret(), qrcodeImage, nil, secret
return otpKey.Secret(), qrcodeImage, secret, nil
}
func Validate(passcode, secret string) bool {

View File

@@ -0,0 +1,431 @@
package totp
import (
"encoding/base64"
"strings"
"sync"
"testing"
"time"
"github.com/pquerna/otp/totp"
)
func TestGenerateQRCode(t *testing.T) {
tests := []struct {
name string
username string
siteUrl string
secret []byte
wantErr bool
}{
{
name: "basic generation with nil secret",
username: "testuser",
siteUrl: "opengist.io",
secret: nil,
wantErr: false,
},
{
name: "basic generation with provided secret",
username: "testuser",
siteUrl: "opengist.io",
secret: []byte("1234567890123456"),
wantErr: false,
},
{
name: "username with special characters",
username: "test.user",
siteUrl: "opengist.io",
secret: nil,
wantErr: false,
},
{
name: "site URL with protocol and port",
username: "testuser",
siteUrl: "https://opengist.io:6157",
secret: nil,
wantErr: false,
},
{
name: "empty username",
username: "",
siteUrl: "opengist.io",
secret: nil,
wantErr: true,
},
{
name: "empty site URL",
username: "testuser",
siteUrl: "",
secret: nil,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
secretStr, qrcode, secretBytes, err := GenerateQRCode(tt.username, tt.siteUrl, tt.secret)
if (err != nil) != tt.wantErr {
t.Errorf("GenerateQRCode() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
// Verify secret string is not empty
if secretStr == "" {
t.Error("GenerateQRCode() returned empty secret string")
}
// Verify QR code image is generated
if qrcode == "" {
t.Error("GenerateQRCode() returned empty QR code")
}
// Verify QR code has correct data URI prefix
if !strings.HasPrefix(string(qrcode), "data:image/png;base64,") {
t.Errorf("QR code does not have correct data URI prefix: %s", qrcode[:50])
}
// Verify QR code is valid base64 after prefix
base64Data := strings.TrimPrefix(string(qrcode), "data:image/png;base64,")
_, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
t.Errorf("QR code base64 data is invalid: %v", err)
}
// Verify secret bytes are returned
if secretBytes == nil {
t.Error("GenerateQRCode() returned nil secret bytes")
}
// Verify secret bytes have correct length
if len(secretBytes) != secretSize {
t.Errorf("Secret bytes length = %d, want %d", len(secretBytes), secretSize)
}
// If a secret was provided, verify it matches what was returned
if tt.secret != nil && string(secretBytes) != string(tt.secret) {
t.Error("Returned secret bytes do not match provided secret")
}
}
})
}
}
func TestGenerateQRCode_SecretUniqueness(t *testing.T) {
username := "testuser"
siteUrl := "opengist.io"
iterations := 10
secrets := make(map[string]bool)
secretBytes := make(map[string]bool)
for i := 0; i < iterations; i++ {
secretStr, _, secret, err := GenerateQRCode(username, siteUrl, nil)
if err != nil {
t.Fatalf("Iteration %d failed: %v", i, err)
}
// Check secret string uniqueness
if secrets[secretStr] {
t.Errorf("Duplicate secret string generated at iteration %d", i)
}
secrets[secretStr] = true
// Check secret bytes uniqueness
secretKey := string(secret)
if secretBytes[secretKey] {
t.Errorf("Duplicate secret bytes generated at iteration %d", i)
}
secretBytes[secretKey] = true
}
}
func TestGenerateQRCode_WithProvidedSecret(t *testing.T) {
username := "testuser"
siteUrl := "opengist.io"
providedSecret := []byte("mysecret12345678")
// Generate QR code multiple times with the same secret
secretStr1, _, secret1, err := GenerateQRCode(username, siteUrl, providedSecret)
if err != nil {
t.Fatalf("First generation failed: %v", err)
}
secretStr2, _, secret2, err := GenerateQRCode(username, siteUrl, providedSecret)
if err != nil {
t.Fatalf("Second generation failed: %v", err)
}
// Secret strings should be the same when using the same input secret
if secretStr1 != secretStr2 {
t.Error("Secret strings differ when using the same provided secret")
}
// Secret bytes should match the provided secret
if string(secret1) != string(providedSecret) {
t.Error("Returned secret bytes do not match provided secret (first call)")
}
if string(secret2) != string(providedSecret) {
t.Error("Returned secret bytes do not match provided secret (second call)")
}
}
func TestGenerateQRCode_ConcurrentGeneration(t *testing.T) {
username := "testuser"
siteUrl := "opengist.io"
concurrency := 10
type result struct {
secretStr string
secretBytes []byte
err error
}
results := make(chan result, concurrency)
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
secretStr, _, secretBytes, err := GenerateQRCode(username, siteUrl, nil)
results <- result{secretStr: secretStr, secretBytes: secretBytes, err: err}
}()
}
wg.Wait()
close(results)
secrets := make(map[string]bool)
for res := range results {
if res.err != nil {
t.Errorf("Concurrent generation failed: %v", res.err)
continue
}
// Check for duplicates
if secrets[res.secretStr] {
t.Error("Duplicate secret generated in concurrent test")
}
secrets[res.secretStr] = true
}
}
func TestValidate(t *testing.T) {
// Generate a valid secret for testing
_, _, secret, err := GenerateQRCode("testuser", "opengist.io", nil)
if err != nil {
t.Fatalf("Failed to generate secret: %v", err)
}
// Convert secret bytes to base32 string for TOTP
secretStr, _, _, err := GenerateQRCode("testuser", "opengist.io", secret)
if err != nil {
t.Fatalf("Failed to generate secret string: %v", err)
}
// Generate a valid passcode for the current time
validPasscode, err := totp.GenerateCode(secretStr, time.Now())
if err != nil {
t.Fatalf("Failed to generate valid passcode: %v", err)
}
tests := []struct {
name string
passcode string
secret string
wantValid bool
}{
{
name: "valid passcode",
passcode: validPasscode,
secret: secretStr,
wantValid: true,
},
{
name: "invalid passcode - wrong digits",
passcode: "000000",
secret: secretStr,
wantValid: false,
},
{
name: "invalid passcode - wrong length",
passcode: "123",
secret: secretStr,
wantValid: false,
},
{
name: "empty passcode",
passcode: "",
secret: secretStr,
wantValid: false,
},
{
name: "empty secret",
passcode: validPasscode,
secret: "",
wantValid: false,
},
{
name: "invalid secret format",
passcode: validPasscode,
secret: "not-a-valid-base32-secret!@#",
wantValid: false,
},
{
name: "passcode with letters",
passcode: "12345A",
secret: secretStr,
wantValid: false,
},
{
name: "passcode with spaces",
passcode: "123 456",
secret: secretStr,
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
valid := Validate(tt.passcode, tt.secret)
if valid != tt.wantValid {
t.Errorf("Validate() = %v, want %v", valid, tt.wantValid)
}
})
}
}
func TestValidate_TimeDrift(t *testing.T) {
// Generate a valid secret
secretStr, _, _, err := GenerateQRCode("testuser", "opengist.io", nil)
if err != nil {
t.Fatalf("Failed to generate secret: %v", err)
}
// Test that passcodes from previous and next time windows are accepted
// (TOTP typically accepts codes from ±1 time window for clock drift)
pastTime := time.Now().Add(-30 * time.Second)
futureTime := time.Now().Add(30 * time.Second)
pastPasscode, err := totp.GenerateCode(secretStr, pastTime)
if err != nil {
t.Fatalf("Failed to generate past passcode: %v", err)
}
futurePasscode, err := totp.GenerateCode(secretStr, futureTime)
if err != nil {
t.Fatalf("Failed to generate future passcode: %v", err)
}
// These should be valid due to time drift tolerance
if !Validate(pastPasscode, secretStr) {
t.Error("Validate() rejected passcode from previous time window")
}
if !Validate(futurePasscode, secretStr) {
t.Error("Validate() rejected passcode from next time window")
}
}
func TestValidate_ExpiredPasscode(t *testing.T) {
// Generate a valid secret
secretStr, _, _, err := GenerateQRCode("testuser", "opengist.io", nil)
if err != nil {
t.Fatalf("Failed to generate secret: %v", err)
}
// Generate a passcode from 2 minutes ago (should be expired)
oldTime := time.Now().Add(-2 * time.Minute)
oldPasscode, err := totp.GenerateCode(secretStr, oldTime)
if err != nil {
t.Fatalf("Failed to generate old passcode: %v", err)
}
// This should be invalid
if Validate(oldPasscode, secretStr) {
t.Error("Validate() accepted expired passcode from 2 minutes ago")
}
}
func TestValidate_RoundTrip(t *testing.T) {
// Test full round trip: generate secret, generate code, validate code
tests := []struct {
name string
username string
siteUrl string
}{
{
name: "basic round trip",
username: "testuser",
siteUrl: "opengist.io",
},
{
name: "round trip with dot in username",
username: "test.user",
siteUrl: "opengist.io",
},
{
name: "round trip with hyphen in username",
username: "test-user",
siteUrl: "opengist.io",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Generate QR code and secret
secretStr, _, _, err := GenerateQRCode(tt.username, tt.siteUrl, nil)
if err != nil {
t.Fatalf("GenerateQRCode() failed: %v", err)
}
// Generate a valid passcode
passcode, err := totp.GenerateCode(secretStr, time.Now())
if err != nil {
t.Fatalf("GenerateCode() failed: %v", err)
}
// Validate the passcode
if !Validate(passcode, secretStr) {
t.Error("Validate() rejected valid passcode")
}
// Validate wrong passcode fails
wrongPasscode := "000000"
if passcode == wrongPasscode {
wrongPasscode = "111111"
}
if Validate(wrongPasscode, secretStr) {
t.Error("Validate() accepted invalid passcode")
}
})
}
}
func TestGenerateSecret(t *testing.T) {
// Test the internal generateSecret function behavior through GenerateQRCode
for i := 0; i < 10; i++ {
_, _, secret, err := GenerateQRCode("testuser", "opengist.io", nil)
if err != nil {
t.Fatalf("Iteration %d: generateSecret() failed: %v", i, err)
}
if len(secret) != secretSize {
t.Errorf("Iteration %d: secret length = %d, want %d", i, len(secret), secretSize)
}
// Verify secret is not all zeros (extremely unlikely with crypto/rand)
allZeros := true
for _, b := range secret {
if b != 0 {
allZeros = false
break
}
}
if allZeros {
t.Errorf("Iteration %d: secret is all zeros", i)
}
}
}

View File

@@ -0,0 +1,83 @@
package auth
import (
"errors"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/ldap"
passwordpkg "github.com/thomiceli/opengist/internal/auth/password"
"github.com/thomiceli/opengist/internal/db"
"gorm.io/gorm"
)
type AuthError struct {
message string
}
func (e AuthError) Error() string {
return e.message
}
func TryAuthentication(username, password string) (*db.User, error) {
user, err := db.GetUserByUsername(username)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Error().Err(err).Msgf("Cannot get user by username %s", username)
return nil, err
}
}
if user.Password != "" {
return tryDbLogin(user, password)
} else {
if ldap.Enabled() {
return tryLdapLogin(username, password)
}
return nil, AuthError{"no authentication method available"}
}
}
func tryDbLogin(user *db.User, password string) (*db.User, error) {
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
if err != nil {
log.Error().Err(err).Msg("Password verification failed")
return nil, err
}
return nil, AuthError{"invalid password"}
}
return user, nil
}
func tryLdapLogin(username, password string) (user *db.User, err error) {
ok, err := ldap.Authenticate(username, password)
if err != nil {
log.Error().Err(err).Msg("LDAP authentication failed")
return nil, err
}
if !ok {
return nil, AuthError{"invalid LDAP credentials"}
}
if user, err = db.GetUserByUsername(username); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Error().Err(err).Msgf("Cannot get user by username %s", username)
return nil, err
}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
user = &db.User{
Username: username,
}
if err = user.Create(); err != nil {
log.Warn().Err(err).Msg("Cannot create user after LDAP authentication")
return nil, err
}
return user, nil
}
return user, nil
}

View File

@@ -2,13 +2,14 @@ package webauthn
import (
"encoding/json"
"net/http"
"net/url"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"net/http"
"net/url"
)
var webAuthn *webauthn.WebAuthn
@@ -101,7 +102,7 @@ func FinishDiscoverableLogin(jsonSession []byte, response *http.Request) (uint,
return 0, err
}
return waUser.(*user).User.ID, nil
return waUser.(*user).ID, nil
}
func BeginLogin(dbUser *db.User) (credCreation *protocol.CredentialAssertion, jsonSession []byte, err error) {

View File

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

View File

@@ -2,7 +2,6 @@ package config
import (
"fmt"
"github.com/thomiceli/opengist/internal/session"
"io"
"net/url"
"os"
@@ -13,6 +12,8 @@ import (
"strings"
"time"
"github.com/thomiceli/opengist/internal/session"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
@@ -51,6 +52,8 @@ type config struct {
HttpPort string `yaml:"http.port" env:"OG_HTTP_PORT"`
HttpGit bool `yaml:"http.git-enabled" env:"OG_HTTP_GIT_ENABLED"`
UnixSocketPermissions string `yaml:"unix-socket-permissions" env:"OG_UNIX_SOCKET_PERMISSIONS"`
SshGit bool `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"`
SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"`
SshPort string `yaml:"ssh.port" env:"OG_SSH_PORT"`
@@ -77,7 +80,15 @@ type config struct {
OIDCGroupClaimName string `yaml:"oidc.group-claim-name" env:"OG_OIDC_GROUP_CLAIM_NAME"`
OIDCAdminGroup string `yaml:"oidc.admin-group" env:"OG_OIDC_ADMIN_GROUP"`
MetricsEnabled bool `yaml:"metrics.enabled" env:"OG_METRICS_ENABLED"`
MetricsEnabled bool `yaml:"metrics.enabled" env:"OG_METRICS_ENABLED"`
MetricsHost string `yaml:"metrics.host" env:"OG_METRICS_HOST"`
MetricsPort string `yaml:"metrics.port" env:"OG_METRICS_PORT"`
LDAPUrl string `yaml:"ldap.url" env:"OG_LDAP_URL"`
LDAPBindDn string `yaml:"ldap.bind-dn" env:"OG_LDAP_BIND_DN"`
LDAPBindCredentials string `yaml:"ldap.bind-credentials" env:"OG_LDAP_BIND_CREDENTIALS"`
LDAPSearchBase string `yaml:"ldap.search-base" env:"OG_LDAP_SEARCH_BASE"`
LDAPSearchFilter string `yaml:"ldap.search-filter" env:"OG_LDAP_SEARCH_FILTER"`
CustomName string `yaml:"custom.name" env:"OG_CUSTOM_NAME"`
CustomLogo string `yaml:"custom.logo" env:"OG_CUSTOM_LOGO"`
@@ -107,6 +118,8 @@ func configWithDefaults() (*config, error) {
c.HttpPort = "6157"
c.HttpGit = true
c.UnixSocketPermissions = "0666"
c.SshGit = true
c.SshHost = "0.0.0.0"
c.SshPort = "2222"
@@ -118,6 +131,8 @@ func configWithDefaults() (*config, error) {
c.GiteaName = "Gitea"
c.MetricsEnabled = false
c.MetricsHost = "0.0.0.0"
c.MetricsPort = "6158"
return c, nil
}

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

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

View File

@@ -20,7 +20,7 @@ const (
func GetSetting(key string) (string, error) {
var setting AdminSetting
var err error
switch db.Dialector.Name() {
switch db.Name() {
case "mysql", "sqlite":
err = db.Where("`key` = ?", key).First(&setting).Error
case "postgres":

View File

@@ -3,16 +3,17 @@ package db
import (
"errors"
"fmt"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm/logger"
"net/url"
"path/filepath"
"slices"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm/logger"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"gorm.io/gorm"
@@ -39,6 +40,7 @@ type databaseInfo struct {
User string
Password string
Database string
SSLMode string
}
var DatabaseInfo *databaseInfo
@@ -46,6 +48,8 @@ var DatabaseInfo *databaseInfo
func parseDBURI(uri string) (*databaseInfo, error) {
info := &databaseInfo{}
info.SSLMode = "disable"
if uri == ":memory:" {
info.Type = SQLite
info.Database = uri
@@ -85,6 +89,13 @@ func parseDBURI(uri string) (*databaseInfo, error) {
info.Password, _ = u.User.Password()
}
if u.RawQuery != "" {
q, _ := url.ParseQuery(u.RawQuery)
if sslmode := q.Get("sslmode"); sslmode != "" && info.Type == PostgreSQL {
info.SSLMode = sslmode
}
}
switch info.Type {
case PostgreSQL, MySQL:
info.Database = strings.TrimPrefix(u.Path, "/")
@@ -144,7 +155,7 @@ func Setup(dbUri string) error {
return err
}
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}); err != nil {
return err
}
@@ -222,7 +233,7 @@ func setupSQLite(dbInfo databaseInfo) error {
func setupPostgres(dbInfo databaseInfo) error {
var err error
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", dbInfo.Host, dbInfo.Port, dbInfo.User, dbInfo.Password, dbInfo.Database)
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", dbInfo.Host, dbInfo.Port, dbInfo.User, dbInfo.Password, dbInfo.Database, dbInfo.SSLMode)
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
@@ -258,5 +269,5 @@ func DeprecationDBFilename() {
}
func TruncateDatabase() error {
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{})
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{})
}

View File

@@ -1,8 +1,6 @@
package db
import (
"bytes"
"encoding/gob"
"fmt"
"os/exec"
"path/filepath"
@@ -73,8 +71,10 @@ type Gist struct {
Uuid string
Title string
URL string
URLNormalized string
Preview string
PreviewFilename string
PreviewMimeType string
Description string
Private Visibility // 0: public, 1: unlisted, 2: private
UserID uint
@@ -99,6 +99,11 @@ type Like struct {
CreatedAt int64
}
func (gist *Gist) BeforeSave(_ *gorm.DB) error {
gist.URLNormalized = strings.ToLower(gist.URL)
return nil
}
func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
// Decrement fork counter if the gist was forked
err := tx.Model(&Gist{}).
@@ -111,7 +116,8 @@ func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
func GetGist(user string, gistUuid string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("(gists.uuid like ? OR gists.url = ?) AND users.username like ?", gistUuid+"%", gistUuid, user).
Where("(gists.uuid LIKE ? OR gists.url_normalized = ?) AND users.username_normalized = ?",
strings.ToLower(gistUuid)+"%", strings.ToLower(gistUuid), strings.ToLower(user)).
Joins("join users on gists.user_id = users.id").
First(&gist).Error
@@ -397,7 +403,7 @@ func (gist *Gist) GetForks(currentUserId uint, offset int) ([]*Gist, error) {
}
func (gist *Gist) CanWrite(user *User) bool {
return !(user == nil) && (gist.UserID == user.ID)
return user != nil && gist.UserID == user.ID
}
func (gist *Gist) InitRepository() error {
@@ -420,12 +426,20 @@ func (gist *Gist) Files(revision string, truncate bool) ([]*git.File, error) {
var files []*git.File
for _, fileCat := range filesCat {
var shortContent string
if len(fileCat.Content) > 512 {
shortContent = fileCat.Content[:512]
} else {
shortContent = fileCat.Content
}
files = append(files, &git.File{
Filename: fileCat.Name,
Size: fileCat.Size,
HumanSize: humanize.IBytes(fileCat.Size),
Content: fileCat.Content,
Truncated: fileCat.Truncated,
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(fileCat.Name)),
})
}
return files, err
@@ -446,12 +460,20 @@ func (gist *Gist) File(revision string, filename string, truncate bool) (*git.Fi
return nil, err
}
var shortContent string
if len(content) > 512 {
shortContent = content[:512]
} else {
shortContent = content
}
return &git.File{
Filename: filename,
Size: size,
HumanSize: humanize.IBytes(size),
Content: content,
Truncated: truncated,
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(filename)),
}, err
}
@@ -473,8 +495,14 @@ func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error {
}
for _, file := range *files {
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
return err
if file.SourcePath != "" { // if it's an uploaded file
if err := git.MoveFileToRepository(gist.Uuid, file.Filename, file.SourcePath); err != nil {
return err
}
} else { // else it's a text editor file
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
return err
}
}
}
@@ -531,20 +559,31 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
if len(filesStr) == 0 {
gist.Preview = ""
gist.PreviewFilename = ""
gist.PreviewMimeType = ""
} else {
file, err := gist.File("HEAD", filesStr[0], true)
if err != nil {
return err
}
for _, fileStr := range filesStr {
file, err := gist.File("HEAD", fileStr, true)
if err != nil {
return err
}
if file == nil {
continue
}
gist.Preview = ""
gist.PreviewFilename = file.Filename
gist.PreviewMimeType = file.MimeType.ContentType
split := strings.Split(file.Content, "\n")
if len(split) > 10 {
gist.Preview = strings.Join(split[:10], "\n")
} else {
gist.Preview = file.Content
}
if !file.MimeType.CanBeEdited() {
continue
}
gist.PreviewFilename = file.Filename
split := strings.Split(file.Content, "\n")
if len(split) > 10 {
gist.Preview = strings.Join(split[:10], "\n")
} else {
gist.Preview = file.Content
}
}
}
if withTimestampUpdate {
@@ -613,30 +652,6 @@ func (gist *Gist) TopicsSlice() []string {
return topics
}
func (gist *Gist) SerialiseInitRepository() error {
var gobBuffer bytes.Buffer
encoder := gob.NewEncoder(&gobBuffer)
if err := encoder.Encode(gist); err != nil {
return fmt.Errorf("gob encoding error: %v", err)
}
return git.SerialiseInitRepository(gist.User.Username, gobBuffer.Bytes())
}
func DeserialiseInitRepository(user string) (*Gist, error) {
data, err := git.DeserialiseInitRepository(user)
if err != nil {
return nil, err
}
var gist Gist
decoder := gob.NewDecoder(bytes.NewReader(data))
if err := decoder.Decode(&gist); err != nil {
return nil, fmt.Errorf("gob decoding error: %v", err)
}
return &gist, nil
}
func (gist *Gist) UpdateLanguages() {
languages, err := gist.GetLanguagesFromFiles()
if err != nil {
@@ -686,10 +701,15 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
fileDTOs := make([]FileDTO, 0, len(files))
for _, file := range files {
fileDTOs = append(fileDTOs, FileDTO{
f := FileDTO{
Filename: file.Filename,
Content: file.Content,
})
}
if file.MimeType.CanBeEdited() {
f.Content = file.Content
} else {
f.Binary = true
}
fileDTOs = append(fileDTOs, f)
}
return &GistDTO{
@@ -707,13 +727,17 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
// -- DTO -- //
type GistDTO struct {
Title string `validate:"max=250" form:"title"`
Description string `validate:"max=1000" form:"description"`
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
Files []FileDTO `validate:"min=1,dive"`
Name []string `form:"name"`
Content []string `form:"content"`
Topics string `validate:"gisttopics" form:"topics"`
Title string `validate:"max=250" form:"title"`
Description string `validate:"max=1000" form:"description"`
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
Files []FileDTO `validate:"min=1,dive"`
Name []string `form:"name"`
Content []string `form:"content"`
Topics string `validate:"gisttopics" form:"topics"`
UploadedFilesUUID []string `validate:"omitempty,dive,required,uuid" form:"uploadedfile_uuid"`
UploadedFilesNames []string `validate:"omitempty,dive,required" form:"uploadedfile_filename"`
BinaryFileOldName []string `form:"binary_old_name"`
BinaryFileNewName []string `form:"binary_new_name"`
VisibilityDTO
}
@@ -726,8 +750,10 @@ type VisibilityDTO struct {
}
type FileDTO struct {
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
Content string `validate:"required"`
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
Content string
Binary bool
SourcePath string // Path to uploaded file, used instead of Content when present
}
func (dto *GistDTO) ToGist() *Gist {

View File

@@ -0,0 +1,34 @@
package db
type GistInitQueue struct {
GistID uint `gorm:"primaryKey"`
Gist Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:GistID"`
UserID uint `gorm:"primaryKey"`
User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
}
func GetInitGistInQueueForUser(userID uint) (*Gist, error) {
queue := new(GistInitQueue)
err := db.Preload("Gist").Preload("Gist.User").
Where("user_id = ?", userID).
Order("gist_id asc").
First(&queue).Error
if err != nil {
return nil, err
}
err = db.Delete(&queue).Error
if err != nil {
return nil, err
}
return &queue.Gist, nil
}
func AddInitGistToQueue(gistID uint, userID uint) error {
queue := &GistInitQueue{
GistID: gistID,
UserID: userID,
}
return db.Create(&queue).Error
}

View File

@@ -16,7 +16,7 @@ type Invitation struct {
func GetAllInvitations() ([]*Invitation, error) {
var invitations []*Invitation
dialect := db.Dialector.Name()
dialect := db.Name()
query := db.Model(&Invitation{})
switch dialect {

View File

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

View File

@@ -6,11 +6,11 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"github.com/thomiceli/opengist/internal/auth"
"slices"
"github.com/thomiceli/opengist/internal/auth/password"
ogtotp "github.com/thomiceli/opengist/internal/auth/totp"
"github.com/thomiceli/opengist/internal/config"
"slices"
)
type TOTP struct {
@@ -31,7 +31,7 @@ func GetTOTPByUserID(userID uint) (*TOTP, error) {
func (totp *TOTP) StoreSecret(secret string) error {
secretBytes := []byte(secret)
encrypted, err := auth.AESEncrypt(config.SecretKey, secretBytes)
encrypted, err := ogtotp.AESEncrypt(config.SecretKey, secretBytes)
if err != nil {
return err
}
@@ -46,7 +46,7 @@ func (totp *TOTP) ValidateCode(code string) (bool, error) {
return false, err
}
secretBytes, err := auth.AESDecrypt(config.SecretKey, ciphertext)
secretBytes, err := ogtotp.AESDecrypt(config.SecretKey, ciphertext)
if err != nil {
return false, err
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
@@ -29,7 +30,7 @@ func (*binaryData) GormDataType() string {
}
func (*binaryData) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
switch db.Dialector.Name() {
switch db.Name() {
case "sqlite":
return "BLOB"
case "mysql":
@@ -67,7 +68,7 @@ func (*jsonData) GormDataType() string {
}
func (*jsonData) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
switch db.Dialector.Name() {
switch db.Name() {
case "mysql", "sqlite":
return "JSON"
case "postgres":

View File

@@ -1,28 +1,39 @@
package db
import (
"encoding/json"
"strings"
"github.com/thomiceli/opengist/internal/git"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"`
Password string
IsAdmin bool
CreatedAt int64
Email string
MD5Hash string // for gravatar, if no Email is specified, the value is random
AvatarURL string
GithubID string
GitlabID string
GiteaID string
OIDCID string `gorm:"column:oidc_id"`
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"`
UsernameNormalized string `gorm:"index"`
Password string
IsAdmin bool
CreatedAt int64
Email string
MD5Hash string // for gravatar, if no Email is specified, the value is random
AvatarURL string
GithubID string
GitlabID string
GiteaID string
OIDCID string `gorm:"column:oidc_id"`
StylePreferences string
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
AccessTokens []AccessToken `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
}
func (user *User) BeforeSave(_ *gorm.DB) error {
user.UsernameNormalized = strings.ToLower(user.Username)
return nil
}
func (user *User) BeforeDelete(tx *gorm.DB) error {
@@ -70,6 +81,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
return err
}
err = tx.Where("user_id = ?", user.ID).Delete(&AccessToken{}).Error
if err != nil {
return err
}
err = tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
if err != nil {
return err
@@ -85,7 +101,7 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
func UserExists(username string) (bool, error) {
var count int64
err := db.Model(&User{}).Where("username like ?", username).Count(&count).Error
err := db.Model(&User{}).Where("username_normalized = ?", strings.ToLower(username)).Count(&count).Error
return count > 0, err
}
@@ -103,7 +119,7 @@ func GetAllUsers(offset int) ([]*User, error) {
func GetUserByUsername(username string) (*User, error) {
user := new(User)
err := db.
Where("username like ?", username).
Where("username_normalized = ?", strings.ToLower(username)).
First(&user).Error
return user, err
}
@@ -234,6 +250,15 @@ func (user *User) HasMFA() (bool, bool, error) {
return webauthn, totp, err
}
func (user *User) GetStyle() *UserStyleDTO {
style := new(UserStyleDTO)
err := json.Unmarshal([]byte(user.StylePreferences), style)
if err != nil {
return nil
}
return style
}
// -- DTO -- //
type UserDTO struct {
@@ -241,6 +266,11 @@ type UserDTO struct {
Password string `form:"password" validate:"required"`
}
type OAuthRegisterDTO struct {
Username string `form:"username" validate:"required,max=24,alphanumdash,notreserved"`
Email string `form:"email" validate:"omitempty,email"`
}
func (dto *UserDTO) ToUser() *User {
return &User{
Username: dto.Username,
@@ -251,3 +281,19 @@ func (dto *UserDTO) ToUser() *User {
type UserUsernameDTO struct {
Username string `form:"username" validate:"required,max=24,alphanumdash,notreserved"`
}
type UserStyleDTO struct {
SoftWrap bool `form:"softwrap" json:"soft_wrap"`
RemovedLineColor string `form:"removedlinecolor" json:"removed_line_color" validate:"min=0,max=7"`
AddedLineColor string `form:"addedlinecolor" json:"added_line_color" validate:"min=0,max=7"`
GitLineColor string `form:"gitlinecolor" json:"git_line_color" validate:"min=0,max=7"`
Theme string `form:"theme" json:"theme" validate:"oneof=light dark auto"`
}
func (dto *UserStyleDTO) ToJson() string {
data, err := json.Marshal(dto)
if err != nil {
return "{}"
}
return string(data)
}

View File

@@ -2,8 +2,9 @@ package db
import (
"encoding/hex"
"github.com/go-webauthn/webauthn/webauthn"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
type WebAuthnCredential struct {
@@ -67,7 +68,7 @@ func GetUserByCredentialID(credID binaryData) (*User, error) {
var credential WebAuthnCredential
var err error
switch db.Dialector.Name() {
switch db.Name() {
case "postgres":
hexCredID := hex.EncodeToString(credID)
if err = db.Preload("User").Where("credential_id = decode(?, 'hex')", hexCredID).First(&credential).Error; err != nil {
@@ -93,7 +94,7 @@ func GetCredentialByID(id binaryData) (*WebAuthnCredential, error) {
var cred WebAuthnCredential
var err error
switch db.Dialector.Name() {
switch db.Name() {
case "postgres":
hexCredID := hex.EncodeToString(id)
if err = db.Where("credential_id = decode(?, 'hex')", hexCredID).First(&cred).Error; err != nil {

View File

@@ -4,7 +4,6 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"net/url"
@@ -203,6 +202,11 @@ func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*
return nil, err
}
// Don't truncate Jupyter notebooks
if strings.HasSuffix(file.Name, ".ipynb") {
truncate = false
}
sizeToRead := size
if truncate && sizeToRead > truncateLimit {
sizeToRead = truncateLimit
@@ -381,6 +385,17 @@ func SetFileContent(gistTmpId string, filename string, content string) error {
return os.WriteFile(filepath.Join(repositoryPath, filename), []byte(content), 0644)
}
func MoveFileToRepository(gistTmpId string, filename string, sourcePath string) error {
repositoryPath := TmpRepositoryPath(gistTmpId)
destPath := filepath.Join(repositoryPath, filename)
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
return os.Rename(sourcePath, destPath)
}
func AddAll(gistTmpId string) error {
tmpPath := TmpRepositoryPath(gistTmpId)
@@ -565,50 +580,6 @@ func DeleteUserDirectory(user string) error {
return os.RemoveAll(filepath.Join(config.GetHomeDir(), ReposDirectory, user))
}
func SerialiseInitRepository(user string, serialized []byte) error {
userRepositoryPath := UserRepositoriesPath(user)
initPath := filepath.Join(userRepositoryPath, "_init")
f, err := os.OpenFile(initPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
encodedData := base64.StdEncoding.EncodeToString(serialized)
_, err = f.Write(append([]byte(encodedData), '\n'))
return err
}
func DeserialiseInitRepository(user string) ([]byte, error) {
initPath := filepath.Join(UserRepositoriesPath(user), "_init")
content, err := os.ReadFile(initPath)
if err != nil {
return nil, err
}
idx := bytes.Index(content, []byte{'\n'})
if idx == -1 {
return base64.StdEncoding.DecodeString(string(content))
}
firstLine := content[:idx]
remaining := content[idx+1:]
if len(remaining) == 0 {
if err := os.Remove(initPath); err != nil {
return nil, fmt.Errorf("failed to remove file: %v", err)
}
} else {
if err := os.WriteFile(initPath, remaining, 0644); err != nil {
return nil, fmt.Errorf("failed to write remaining content: %v", err)
}
}
return base64.StdEncoding.DecodeString(string(firstLine))
}
func createDotGitHookFile(repositoryPath string, hook string, content string) error {
preReceiveDst, err := os.OpenFile(filepath.Join(repositoryPath, "hooks", hook), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0744)
if err != nil {
@@ -672,7 +643,7 @@ func convertUTF8ToOctal(name string) string {
}
func convertURLToOctal(name string) string {
decoded, err := url.QueryUnescape(name)
decoded, err := url.PathUnescape(name)
if err != nil {
return name
}

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
}

93
internal/git/mime.go Normal file
View File

@@ -0,0 +1,93 @@
package git
import (
"fmt"
"net/http"
"strings"
"github.com/gabriel-vasile/mimetype"
)
type MimeType struct {
ContentType string
extension string
golangContentType string // json, m3u, etc. still renderable as text
}
func (mt MimeType) IsText() bool {
return strings.HasPrefix(mt.ContentType, "text/") || strings.HasPrefix(mt.golangContentType, "text/")
}
func (mt MimeType) IsCSV() bool {
return strings.HasPrefix(mt.ContentType, "text/csv") &&
(strings.HasSuffix(mt.extension, ".csv"))
}
func (mt MimeType) IsImage() bool {
return strings.HasPrefix(mt.ContentType, "image/")
}
func (mt MimeType) IsSVG() bool {
return strings.HasPrefix(mt.ContentType, "image/svg+xml")
}
func (mt MimeType) IsPDF() bool {
return strings.HasPrefix(mt.ContentType, "application/pdf")
}
func (mt MimeType) IsAudio() bool {
return strings.HasPrefix(mt.ContentType, "audio/")
}
func (mt MimeType) IsVideo() bool {
return strings.HasPrefix(mt.ContentType, "video/")
}
func (mt MimeType) CanBeHighlighted() bool {
return mt.IsText() && !mt.IsCSV()
}
func (mt MimeType) CanBeEmbedded() bool {
return mt.IsImage() || mt.IsPDF() || mt.IsAudio() || mt.IsVideo()
}
func (mt MimeType) CanBeRendered() bool {
return mt.IsText() || mt.IsImage() || mt.IsSVG() || mt.IsPDF() || mt.IsAudio() || mt.IsVideo()
}
func (mt MimeType) CanBeEdited() bool {
return mt.IsText() || mt.IsSVG()
}
func (mt MimeType) RenderType() string {
t := strings.Split(mt.ContentType, "/")
str := ""
if len(t) == 2 {
str = fmt.Sprintf("(%s)", strings.ToUpper(t[1]))
}
// More user friendly description
if mt.IsImage() || mt.IsSVG() {
return fmt.Sprintf("Image %s", str)
}
if mt.IsAudio() {
return fmt.Sprintf("Audio %s", str)
}
if mt.IsVideo() {
return fmt.Sprintf("Video %s", str)
}
if mt.IsPDF() {
return "PDF"
}
if mt.IsCSV() {
return "CSV"
}
if mt.IsText() {
return "Text"
}
return "Binary"
}
func DetectMimeType(data []byte, extension string) MimeType {
return MimeType{mimetype.Detect(data).String(), extension, http.DetectContentType(data)}
}

View File

@@ -3,27 +3,23 @@ package git
import (
"bufio"
"bytes"
"encoding/csv"
"fmt"
"io"
"regexp"
"strings"
)
type File struct {
Filename string `json:"filename"`
Size uint64 `json:"size"`
HumanSize string `json:"human_size"`
OldFilename string `json:"-"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
IsCreated bool `json:"-"`
IsDeleted bool `json:"-"`
}
type CsvFile struct {
File
Header []string
Rows [][]string
Filename string `json:"filename"`
Size uint64 `json:"size"`
HumanSize string `json:"human_size"`
OldFilename string `json:"-"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
IsCreated bool `json:"-"`
IsDeleted bool `json:"-"`
IsBinary bool `json:"-"`
MimeType MimeType `json:"-"`
}
type Commit struct {
@@ -62,6 +58,8 @@ func truncateCommandOutput(out io.Reader, maxBytes int64) (string, bool, error)
return string(buf), truncated, nil
}
var reLogBinaryNames = regexp.MustCompile(`Binary files (.+) and (.+) differ`)
// inspired from https://github.com/go-gitea/gitea/blob/main/services/gitdiff/gitdiff.go
func parseLog(out io.Reader, maxFiles int, maxBytes int) ([]*Commit, error) {
var commits []*Commit
@@ -206,6 +204,20 @@ loopLog:
currentFile.IsCreated = true
case strings.HasPrefix(line, "deleted file"):
currentFile.IsDeleted = true
case strings.HasPrefix(line, "Binary files"):
currentFile.IsBinary = true
names := reLogBinaryNames.FindStringSubmatch(line)
if names[1][2:] != names[2][2:] {
if currentFile.IsCreated {
currentFile.Filename = convertOctalToUTF8(names[2])[2:]
}
if currentFile.IsDeleted {
currentFile.Filename = convertOctalToUTF8(names[1])[2:]
}
} else {
currentFile.OldFilename = convertOctalToUTF8(names[1])[2:]
currentFile.Filename = convertOctalToUTF8(names[2])[2:]
}
case strings.HasPrefix(line, "--- "):
name := convertOctalToUTF8(line[4 : len(line)-1])
if parseRename && currentFile.IsDeleted {
@@ -344,27 +356,3 @@ func skipToNextCommit(input *bufio.Reader) (line string, err error) {
}
return line, err
}
func ParseCsv(file *File) (*CsvFile, error) {
reader := csv.NewReader(strings.NewReader(file.Content))
records, err := reader.ReadAll()
if err != nil {
return nil, err
}
header := records[0]
numColumns := len(header)
for i := 1; i < len(records); i++ {
if len(records[i]) != numColumns {
return nil, fmt.Errorf("CSV file has invalid row at index %d", i)
}
}
return &CsvFile{
File: *file,
Header: header,
Rows: records[1:],
}, nil
}

View File

@@ -50,9 +50,10 @@ func (store *LocaleStore) loadLocaleFromYAML(localeCode, path string) error {
}
name := display.Self.Name(tag)
if tag == language.AmericanEnglish {
switch tag {
case language.AmericanEnglish:
name = "English"
} else if tag == language.EuropeanSpanish {
case language.EuropeanSpanish:
name = "Español"
}

View File

@@ -21,7 +21,7 @@ gist.header.embed: 'Einbetten'
gist.header.embed-help: 'Bette diese Gist in deine Webseite ein.'
gist.header.download-zip: 'ZIP Herunterladen'
gist.raw: 'Orginalformat'
gist.raw: 'Originalformat'
gist.file-truncated: 'Diese Datei wurde abgeschnitten.'
gist.watch-full-file: 'Die gesamte Datei anzeigen.'
gist.file-not-valid: 'Diese Datei ist keine korrekte CSV Datei.'
@@ -37,7 +37,7 @@ gist.new.indent-mode-space: 'Leerzeichen'
gist.new.indent-mode-tab: 'Tab'
gist.new.indent-size: 'Einrückungs Größe'
gist.new.wrap-mode: 'Textumbruch Modus'
gist.new.wrap-mode-no: 'kein Textumruch'
gist.new.wrap-mode-no: 'kein Textumbruch'
gist.new.wrap-mode-soft: 'weicher Zeilenumbruch'
gist.new.add-file: 'Datei hinzufügen'
gist.new.create-public-button: 'Öffentliche Gist erstellen'
@@ -53,7 +53,7 @@ gist.edit.delete: 'Löschen'
gist.edit.cancel: 'Abbrechen'
gist.edit.save: 'Speichern'
gist.list.joined: 'Gemeinsam'
gist.list.joined: 'Beigetreten'
gist.list.all: 'Alle Gists'
gist.list.search-results: 'Suchergebnisse'
gist.list.sort: 'Sortieren'
@@ -61,17 +61,17 @@ gist.list.sort-by-created: 'erstellt'
gist.list.sort-by-updated: 'bearbeitet'
gist.list.order-by-asc: 'Älteste'
gist.list.order-by-desc: 'Neueste'
gist.list.select-tab: 'Tab Auswählen'
gist.list.select-tab: 'Tab auswählen'
gist.list.liked: 'Favorisiert'
gist.list.likes: 'Favoriten'
gist.list.forked: 'Forked'
gist.list.forked-from: 'Forked von'
gist.list.forked: 'Geforkt'
gist.list.forked-from: 'Geforkt von'
gist.list.forks: 'Forks'
gist.list.files: 'Dateien'
gist.list.last-active: 'Zuletzt aktiv'
gist.list.no-gists: 'Keine Gists'
gist.list.all-liked-by: 'Alle Gists favorisiert von %s'
gist.list.all-forked-by: 'Alle Gists geforked von %s'
gist.list.all-forked-by: 'Alle Gists geforkt von %s'
gist.list.all-from: 'Alle Gists von %s'
gist.search.found: 'Gists gefunden'
@@ -89,7 +89,7 @@ gist.forks.for: 'Fork für %s'
gist.likes: 'Favoriten'
gist.likes.no: 'Keine Favorisierungen'
gist.likes.for: 'Favortitisiert für %s'
gist.likes.for: 'Favorisiert für %s'
gist.revisions: 'Revisionen'
gist.revision.revised: 'hat die Gist bearbeitet'
@@ -112,7 +112,7 @@ settings.link-accounts: 'Accounts verlinken'
settings.link-github-account: 'GitHub-Account verlinken'
settings.link-gitlab-account: 'GitLab-Account verlinken'
settings.link-gitea-account: 'Gitea-Account verlinken'
settings.unlink-github-account: 'Github-Account Verlinkung aufheben'
settings.unlink-github-account: 'GitHub-Account Verlinkung aufheben'
settings.unlink-gitlab-account: 'GitLab-Account Verlinkung aufheben'
settings.unlink-gitea-account: 'Gitea-Account Verlinkung aufheben'
settings.delete-account: 'Account löschen'
@@ -192,7 +192,7 @@ admin.actions.sync-db: 'Gists von der Datenbank synchronisieren'
admin.actions.git-gc: '„garbage collection“ bei allen git Repositories ausführen'
admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren'
admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories synchronisieren'
admin.actions.index-gists: 'Alle Gists Indexieren'
admin.actions.index-gists: 'Suchindex neu aufbauen'
admin.id: 'ID'
admin.user: 'Benutzer'
admin.delete: 'Löschen'
@@ -236,7 +236,7 @@ flash.admin.sync-db: 'Synchronisiere Repositories aus der Datenbank...'
flash.admin.git-gc: 'Sammle Repositories...'
flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...'
flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...'
flash.admin.index-gists: 'Indiziere alle Gists...'
flash.admin.index-gists: 'Suchindex wird neu aufgebaut...'
flash.auth.username-exists: 'Benutzername existiert bereits'
flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen'
@@ -303,3 +303,35 @@ auth.totp.scan-qr-code: Scanne den unten stehenden QR-Code mit deiner Authentifi
auth.totp.enter-code: Gib den Code aus deiner Authentifizierungs-App ein
auth.totp.save-recovery-codes: Speichere deine Wiederherstellungscodes an einem sicheren Ort. Du kannst diese Codes verwenden, um wieder Zugang zu deinem Account zu erlangen, wenn du den Zugriff auf deine Authentifizierungs-App verloren hast.
error.not-in-mfa-session: Nutzer ist nicht in einer Zwei-Faktor-Sitzung
gist.revision.binary-file-changes: Änderungen an Binärdateien werden nicht angezeigt
error.no-file-uploaded: Keine Datei hochgeladen
flash.admin.sync-gist-languages: Gist-Sprachen werden synchronisiert …
validation.invalid-gist-topics: Ungültige Gist-Themen. Sie müssen mit einem Buchstaben oder einer Zahl beginnen, dürfen maximal 50 Zeichen lang sein und dürfen Bindestriche enthalten
gist.new.topics: Themen (durch Leerzeichen getrennt)
gist.preview-non-available: Vorschau nicht verfügbar
gist.new.drop-files: Dateien hier ablegen oder zum Hochladen klicken
gist.new.any-file-type: Laden Sie einen beliebigen Dateityp hoch
gist.list.topic-results-topic: Alle Gists mit dem Thema %s
gist.file-raw: Diese Datei kann nicht gerendert werden.
gist.file-binary-edit: Diese Datei ist binär.
gist.search.placeholder.title: Titel
gist.search.placeholder.visibility: Sichtbarkeit
gist.search.placeholder.public: Öffentlich
gist.search.placeholder.unlisted: Nicht gelistet
gist.search.placeholder.private: Privat
gist.search.placeholder.language: Sprache
gist.search.placeholder.all: Alle
gist.search.placeholder.topics: Themen
gist.search.placeholder.search: Suche
gist.search.help.topic: gists zum gegebenen Thema
settings.header.account: Konto
settings.header.mfa: MFA
settings.header.ssh: SSH
settings.header.style: Stil
settings.style.removed-lines-color: Farbe entfernter Linien
settings.style.added-lines-color: Farbe hinzugefügter Linien
settings.style.git-lines-color: Git Linien Farbe
settings.style.save-style: Stil Speichern
auth.totp.enter-recovery-key: oder einen Wiederherstellungsschlüssel, wenn Sie Ihr Gerät verloren haben
error.cannot-open-file: Die hochgeladene Datei kann nicht geöffnet werden
admin.actions.sync-gist-languages: Synchronisieren Sie alle Gists-Sprachen

View File

@@ -23,9 +23,12 @@ gist.header.download-zip: Download ZIP
gist.raw: Raw
gist.file-truncated: This file has been truncated.
gist.file-raw: This file can't be rendered.
gist.file-binary-edit: This file is binary.
gist.watch-full-file: View the full file.
gist.file-not-valid: This file is not a valid CSV file.
gist.no-content: No files found
gist.preview-non-available: Preview not available
gist.new.new_gist: New gist
gist.new.title: Title
@@ -46,6 +49,8 @@ gist.new.create-private-button: Create private gist
gist.new.preview: Preview
gist.new.create-a-new-gist: Create a new gist
gist.new.topics: Topics (separate with spaces)
gist.new.drop-files: Drop files here or click to upload
gist.new.any-file-type: Upload any file type
gist.edit.editing: Editing
gist.edit.edit-gist: Edit %s
@@ -115,6 +120,7 @@ gist.revision.file-renamed: renamed to
gist.revision.diff-truncated: Diff is too large to be shown
gist.revision.file-renamed-no-changes: File renamed without changes
gist.revision.empty-file: Empty file
gist.revision.binary-file-changes: Binary file changes are not shown
gist.revision.no-changes: No changes
gist.revision.no-revisions: No revisions to show
gist.revision-of: Revision of %s
@@ -148,6 +154,42 @@ settings.create-password-help: Create your password to login to Opengist via HTT
settings.change-password: Change password
settings.change-password-help: Change your password to login to Opengist via HTTP
settings.password-label-title: Password
settings.header.account: Account
settings.header.mfa: MFA
settings.header.ssh: SSH
settings.header.tokens: Access tokens
settings.header.style: Style
settings.style.gist-code: Gist code
settings.style.no-soft-wrap: No Soft Wrap
settings.style.soft-wrap: Soft Wrap
settings.style.removed-lines-color: Removed lines color
settings.style.added-lines-color: Added lines color
settings.style.git-lines-color: Git lines color
settings.style.save-style: Save style
settings.style.theme: Theme
settings.style.theme-light: Light
settings.style.theme-dark: Dark
settings.style.theme-auto: Auto
settings.create-token: Create access token
settings.create-token-help: Access tokens can be used to access the API
settings.token-name: Name
settings.token-permissions: Permissions
settings.token-gist-permission: Gists
settings.token-permission-none: No access
settings.token-permission-read: Read
settings.token-permission-read-write: Read & Write
settings.delete-token: Delete
settings.delete-token-confirm: Confirm deletion of access token
settings.token-created-at: Created
settings.token-never-used: Never used
settings.token-last-used: Last used
settings.token-expiration: Expiration
settings.token-expiration-help: Leave empty for no expiration
settings.token-expires-at: Expires
settings.token-no-expiration: No expiration
settings.token-expired: expired
settings.token-created: Token created, make sure to copy it now, you won't be able to see it again!
settings.token-deleted: Access token deleted
auth.signup-disabled: Administrator has disabled signing up
auth.login: Login
@@ -158,6 +200,13 @@ auth.password: Password
auth.register-instead: Register instead
auth.login-instead: Login instead
auth.oauth: Continue with %s account
auth.oauth.no-provider: OAuth provider not found
auth.oauth.complete-registration: Complete your registration
auth.oauth.complete-registration-button: Create account
auth.oauth.signing-in-with: Signing in with %s
auth.oauth.cancel: Cancel
auth.oauth.existing-account: Existing account?
auth.oauth.already-have-account: If you already have an Opengist account, login first and link your %s account from your settings.
auth.mfa: Multi-factor authentication
auth.mfa.passkey: Passkey
auth.mfa.passkeys: Passkeys
@@ -199,11 +248,13 @@ error.signup-disabled: Signing up is disabled
error.signup-disabled-form: Signing up via registration form is disabled
error.login-disabled-form: Logging in via login form is disabled
error.complete-oauth-login: "Cannot complete user auth: %s"
error.oauth-unsupported: Unsupported provider
error.oauth-unsupported: Unsupported OAuth2 provider
error.cannot-bind-data: Cannot bind data
error.invalid-number: Invalid number
error.invalid-character-unescaped: Invalid character unescaped
error.not-in-mfa-session: User is not in a MFA session
error.no-file-uploaded: No file uploaded
error.cannot-open-file: Cannot open uploaded file
header.menu.all: All
header.menu.new: New
@@ -241,7 +292,7 @@ admin.actions.sync-db: Synchronize gists from database
admin.actions.git-gc: Garbage collect all git repositories
admin.actions.sync-previews: Synchronize all gists previews
admin.actions.reset-hooks: Reset Git server hooks for all repositories
admin.actions.index-gists: Index all gists
admin.actions.index-gists: Rebuild search index
admin.actions.sync-gist-languages: Synchronize all gists languages
admin.id: ID
admin.user: User
@@ -287,7 +338,7 @@ flash.admin.sync-db: Syncing repositories from database...
flash.admin.git-gc: Garbage collecting repositories...
flash.admin.sync-previews: Syncing Gist previews...
flash.admin.reset-hooks: Resetting Git server hooks for all repositories...
flash.admin.index-gists: Indexing all gists...
flash.admin.index-gists: Rebuilding search index...
flash.admin.sync-gist-languages: Syncing Gist languages...
flash.auth.username-exists: Username already exists
@@ -299,6 +350,8 @@ flash.auth.user-sshkeys-not-created: Could not create ssh key
flash.auth.must-be-logged-in: You must be logged in to access gists
flash.auth.passkey-registred: Passkey %s registered
flash.auth.passkey-deleted: Passkey deleted
flash.auth.oauth-session-expired: OAuth2 session expired, please try again
flash.auth.oauth-already-linked: This %s account is already linked to another user
flash.gist.visibility-changed: Gist visibility has been changed
flash.gist.deleted: Gist has been deleted
@@ -321,4 +374,4 @@ validation.not-enough: Not enough %s
validation.invalid: Invalid %s
validation.invalid-gist-topics: Invalid gist topics, they must start with a letter or number, consist of 50 characters or less, and can include hyphens
html.title.admin-panel: Admin panel
html.title.admin-panel: Admin panel

View File

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

View File

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

View File

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

View File

@@ -191,7 +191,7 @@ admin.actions.sync-db: 'Sincronizza gists dal database'
admin.actions.git-gc: 'Esegui la garbage collection da tutti i repositories'
admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists'
admin.actions.reset-hooks: 'Resetta tutti gli hook del server Git per tutti i repositories'
admin.actions.index-gists: 'Indicizza tutti i gists'
admin.actions.index-gists: 'Ricostruisci indice di ricerca'
admin.id: 'ID'
admin.user: 'Utente'
admin.delete: 'Elimina'
@@ -235,7 +235,7 @@ flash.admin.sync-db: 'Sincronizzando i repositories dal database...'
flash.admin.git-gc: 'Eseguendo il garbage collector dei repositories...'
flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...'
flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...'
flash.admin.index-gists: 'Indicizzando tutti i gists...'
flash.admin.index-gists: 'Ricostruzione indice di ricerca...'
flash.auth.username-exists: 'Il nome utente esiste già'
flash.auth.invalid-credentials: 'Credenziali errate'
@@ -267,3 +267,77 @@ validation.invalid: '%s non valido'
html.title.admin-panel: 'Pannello amministratore'
settings.ssh-key-exists: Questa chiave SSH esiste già
gist.new.drop-files: Rilascia i file qui oppure fai click per caricarli
gist.delete.confirm: Sei sicuro di voler eliminare questo gist?
gist.list.topic-results-topic: Tutti i gist corrispondenti all'argomento %s
gist.search.placeholder.language: Lingua
error.no-file-uploaded: Nessun file caricato
gist.revision.binary-file-changes: I cambiamenti al file binario non sono visualizzati
error.cannot-open-file: Impossibile aprire il file caricato
admin.invitations.delete_confirm: Vuoi davvero cancellare questo invito?
gist.new.topics: Argomenti (da separare con uno spazio)
gist.search.placeholder.visibility: Visibilità
settings.style.theme: Tema
settings.style.theme-light: Chiaro
settings.style.theme-dark: Scuro
settings.style.theme-auto: Automatico
auth.mfa: Autenticazione a due fattori
auth.mfa.passkey: Passkey
auth.mfa.passkeys: Passkeys
auth.mfa.passkeys-help: Aggiungi una passkey per accedere al tuo account e per usare l'autenticazione a due fattori.
auth.mfa.passkey-name: Nome
auth.mfa.delete-passkey: Elimina
auth.mfa.passkey-added-at: Aggiunta
auth.mfa.passkey-never-used: Mai usata
auth.mfa.passkey-last-used: Ultima usata
auth.mfa.delete-passkey-confirm: Conferma l'eliminazione della passkey
auth.totp: Time based one-time password (TOTP)
auth.totp.help: Il TOTP è un metodo di autenticazione a due fattori che usa una chiave segreta per generare una one-time password (OTP).
error.not-in-mfa-session: Non stai usando l'autenticazione a due fattori
admin.actions.sync-gist-languages: Sincronizza tutte le lingue dei gist
flash.admin.sync-gist-languages: Sincronizzazione delle lingue gist...
flash.auth.passkey-registred: Passkey %s registrata
flash.auth.passkey-deleted: Passkey eliminata
validation.invalid-gist-topics: 'Argomenti del gist non validi: devono iniziare con una lettera o un numero ed essere composti da al massimo 50 caratteri. Possono includere trattini'
gist.file-raw: Questo file non può essere visualizzato.
gist.file-binary-edit: Questo file è in formato binario.
gist.preview-non-available: Anteprima non disponibile
gist.new.any-file-type: Carica qualsiasi tipo di file
gist.list.topic-results: Tutti i gist corrispondenti all'argomento
gist.search.help.topic: Gist con l'argomento dato
gist.search.placeholder.title: Titolo
gist.search.placeholder.public: Pubblico
gist.search.placeholder.unlisted: Non in elenco
gist.search.placeholder.private: Privato
gist.search.placeholder.all: Tutti
gist.search.placeholder.topics: Argomenti
gist.search.placeholder.search: Ricerca
auth.mfa.use-passkey: Usa la passkey
auth.mfa.bind-passkey: Associa passkey
auth.mfa.login-with-passkey: Entra con passkey
auth.mfa.waiting-for-passkey-input: In attesa di input dal browser...
auth.mfa.use-passkey-to-finish: Usa una passkey per terminare l'autenticazione
settings.header.account: Account
settings.header.mfa: Autenticazione a due fattori
settings.header.ssh: SSH
settings.header.style: Stile
settings.style.gist-code: Codice gist
settings.style.removed-lines-color: Colore delle linee rimosse
settings.style.added-lines-color: Colore delle linee aggiunte
settings.style.git-lines-color: Colore delle linee di git
settings.style.save-style: Salva stile
auth.totp.scan-qr-code: Scannerizza il QR qua sotto con la tua app di autenticazione per abilitare l'autenticazione a due fattori, oppure inserisci la seguente stringa, conferma poi con il codice generato.
auth.totp.use: Usa TOTP
auth.totp.regenerate-recovery-codes: Rigenera i codici di recupero
auth.totp.already-enabled: Il TOTP è gia attivo
auth.totp.invalid-secret: Chiave TOTP non valida
auth.totp.invalid-code: Codice TOTP non valido
auth.totp.code-used: Il codice di recupero %s è già stato usato e ora non è più valido. Potresti voler disabilitare l'autenticazione a due fattori per ora o generare nuovi codici di sicurezza.
auth.totp.disabled: Il TOTP è stato disabilitato con successo
auth.totp.disable: Disabilita TOTP
auth.totp.enter-code: Inserisci il codice dall'app Authenticator
auth.totp.enter-recovery-key: oppure una chiave di recupero se hai perso il tuo dispositivo
auth.totp.code: Codice
auth.totp.submit: Invia
auth.totp.proceed: Procedi
auth.totp.save-recovery-codes: Salva i tuoi codici di recupero in un posto sicuro. Puoi usare questi codici per recuperare l'accesso al tuo account se non hai accesso alla tua app di autenticazione.

View File

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

View File

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

View File

@@ -191,7 +191,7 @@ admin.actions.sync-db: Gistleri veri tabanından senkronize et
admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et
admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla
admin.actions.index-gists: Tüm gistleri indeksle
admin.actions.index-gists: Arama dizinini yeniden oluştur
admin.id: ID
admin.user: Kullanıcı
admin.delete: Sil
@@ -234,7 +234,7 @@ flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor...
flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor...
flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor...
flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor...
flash.admin.index-gists: Tüm gistler indeksleniyor...
flash.admin.index-gists: Arama dizini yeniden oluşturuluyor...
flash.auth.username-exists: Kullanıcı adı zaten mevcut
flash.auth.invalid-credentials: Geçersiz kimlik bilgileri
@@ -266,3 +266,67 @@ validation.invalid: Geçersiz %s
html.title.admin-panel: Yönetici paneli
settings.ssh-key-exists: SSH anahtarı zaten mevcut
gist.search.help.topic: Verilen konuyla ilgili gist'ler
gist.search.placeholder.unlisted: Listelenmemiş
settings.header.style: Stil
auth.mfa.passkey: Parola Anahtarı
auth.mfa.waiting-for-passkey-input: Tarayıcı etkileşiminden gelecek girdi bekleniyor...
settings.header.account: Hesap
settings.style.no-soft-wrap: Yumuşak Satır Kaydırma Yok
auth.totp: Zamana Dayalı Tek Kullanımlık Parola (TOTP)
flash.admin.sync-gist-languages: Gist dilleri senkronize ediliyor...
auth.mfa.passkeys-help: Hesabınıza giriş yapmak ve çok faktörlü kimlik doğrulama yöntemi olarak kullanmak için bir geçiş anahtarı ekleyin.
validation.invalid-gist-topics: Geçersiz gist konuları, harf veya rakamla başlamalı, 50 karakterden uzun olmamalı ve tire içerebilir.
auth.totp.enter-recovery-key: veya cihazınızı kaybettiyseniz kurtarma anahtarını kullanın
auth.totp.save-recovery-codes: Kurtarma kodlarınızı güvenli bir yerde saklayın. Bu kodları, kimlik doğrulayıcı uygulamanıza erişimi kaybetmeniz durumunda hesabınıza yeniden erişmek için kullanabilirsiniz.
error.not-in-mfa-session: Kullanıcı çok faktörlü kimlik doğrulama oturumunda değil
admin.invitations.delete_confirm: Bu daveti silmek istiyor musunuz?
auth.totp.help: TOTP, paylaşılan bir gizli anahtarı kullanarak tek kullanımlık bir parola üreten, iki faktörlü kimlik doğrulama yöntemidir.
auth.totp.use: TOTP kullan
auth.totp.regenerate-recovery-codes: Kurtarma kodlarını yeniden oluştur
auth.totp.already-enabled: TOTP zaten etkinleştirilmiş
auth.totp.invalid-secret: Geçersiz TOTP gizli anahtarı
auth.totp.invalid-code: Geçersiz TOTP kodu
auth.totp.code-used: '%s kurtarma kodu kullanıldı, artık geçersiz. Şu anda çok faktörlü kimlik doğrulamayı devre dışı bırakmak veya kodlarınızı yeniden oluşturmak isteyebilirsiniz.'
flash.auth.passkey-registred: '%s geçiş anahtarı kaydedildi'
gist.new.topics: Konular (boşluklarla ayır)
gist.list.topic-results-topic: Tüm %s konusuyla eşleşen gist'ler
gist.list.topic-results: Konuyla eşleşen tüm gist'ler
gist.search.placeholder.title: Başlık
gist.search.placeholder.visibility: Görünürlük
gist.search.placeholder.public: Halka açık
gist.search.placeholder.private: Özel
gist.search.placeholder.language: Lisan
gist.search.placeholder.all: Tümü
gist.search.placeholder.topics: Başlıklar
gist.search.placeholder.search: Ara
gist.delete.confirm: Bu Gist'i silmek istediğinizden emin misiniz?
flash.auth.passkey-deleted: Geçiş anahtarı silindi
settings.header.mfa: ÇFKD
settings.header.ssh: SSH
settings.style.gist-code: Gist kodu
settings.style.soft-wrap: Yumuşak Satır Kaydırma
settings.style.removed-lines-color: Silinen satırların rengi
settings.style.added-lines-color: Eklenen satırların rengi
settings.style.git-lines-color: Git satırların rengi
settings.style.save-style: Stili kaydet
auth.mfa: Çok Faktörlü Kimlik Doğrulama
auth.mfa.passkeys: Parola Anahtarları
auth.mfa.use-passkey: Parola Anahtarı kullan
auth.mfa.bind-passkey: Parola Anahtarı bağla
auth.mfa.login-with-passkey: Parola Anahtarı ile Giriş yap
auth.mfa.use-passkey-to-finish: Kimlik doğrulamayı tamamlamak için bir geçiş anahtarı kullanın
auth.mfa.passkey-name: İsim
auth.mfa.delete-passkey: Sil
auth.mfa.passkey-added-at: Eklendi
auth.mfa.passkey-never-used: Hiç kullanılmadı
auth.mfa.passkey-last-used: Son kullanım
auth.mfa.delete-passkey-confirm: Geçiş Anahtarının silinmesini onaylayın
auth.totp.disabled: TOTP başarıyla devre dışı bırakıldı
auth.totp.disable: TOTP devre dışı bırak
auth.totp.enter-code: Kimlik Doğrulayıcı uygulamasındaki kodu girin
auth.totp.code: Kod
auth.totp.submit: Kaydet
auth.totp.proceed: Onayla
auth.totp.scan-qr-code: İki faktörlü kimlik doğrulamayı etkinleştirmek için aşağıdaki QR kodunu kimlik doğrulayıcı uygulamanızla tarayın veya aşağıdaki metni girin, ardından oluşturulan kodla onaylayın.
admin.actions.sync-gist-languages: Tüm gist dillerini senkronize et

View File

@@ -77,7 +77,7 @@ gist.list.all-from: Всі gists від %s
gist.search.found: gists знайдено
gist.search.no-results: Не знайдено gists
gist.search.help.user: gists створені користувачем
gist.search.help.title: gists з наданим ім'ям
gist.search.help.title: gists з наданим ім'ям
gist.search.help.filename: gists мають файли з наданим ім'ям
gist.search.help.extension: gists мають файли з наданим розширенням
gist.search.help.language: gists мають файли з наданою мовою
@@ -192,7 +192,7 @@ admin.actions.sync-db: Синхронізувати gists з базою дани
admin.actions.git-gc: Збір сміття з репозиторіїв Git
admin.actions.sync-previews: Синхронізувати всі gists перегляди
admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв
admin.actions.index-gists: Проіндексувати всі gists
admin.actions.index-gists: Перебудувати пошуковий індекс
admin.id: ID
admin.user: Користувач
admin.delete: Видалити
@@ -236,7 +236,7 @@ flash.admin.sync-db: Синхронізація репозиторіїв за б
flash.admin.git-gc: Збір сміття з репозиторіїв...
flash.admin.sync-previews: Синхронізація Gist переглядів...
flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв...
flash.admin.index-gists: Індексація всіх gists...
flash.admin.index-gists: Перебудова пошукового індексу...
flash.auth.username-exists: Це ім'я користувача вже існує
flash.auth.invalid-credentials: Недійсні облікові дані
@@ -266,4 +266,4 @@ validation.should-only-contain-alphanumeric-characters-and-dashes: Поле %s
validation.not-enough: Недостатньо %s
validation.invalid: Недійсний %s
html.title.admin-panel: Панель адміністратора
html.title.admin-panel: Панель адміністратора

View File

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

View File

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

View File

@@ -2,6 +2,10 @@ package index
import (
"errors"
"fmt"
"os"
"strconv"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
@@ -10,7 +14,6 @@ import (
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/search/query"
"github.com/rs/zerolog/log"
"strconv"
)
type BleveIndexer struct {
@@ -53,6 +56,8 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
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()
@@ -74,10 +79,20 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
}
docMapping.DefaultAnalyzer = "gistAnalyser"
mapping.DefaultMapping = docMapping
return bleve.New(i.path, mapping)
}
func (i *BleveIndexer) Reset() error {
i.Close()
if err := os.RemoveAll(i.path); err != nil {
return fmt.Errorf("failed to remove Bleve index directory: %w", err)
}
log.Info().Msg("Bleve index directory removed, re-creating index")
return i.Init()
}
func (i *BleveIndexer) Close() {
if i == nil || i.index == nil {
return
@@ -105,39 +120,72 @@ func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
var err error
var indexerQuery query.Query
if queryStr != "" {
contentQuery := bleve.NewMatchPhraseQuery(queryStr)
contentQuery.FieldVal = "Content"
// Use match query with fuzzy matching for more flexible content search
contentQuery := bleve.NewMatchQuery(queryStr)
contentQuery.SetField("Content")
contentQuery.SetFuzziness(2)
indexerQuery = contentQuery
} else {
contentQuery := bleve.NewMatchAllQuery()
indexerQuery = contentQuery
}
privateQuery := bleve.NewBoolFieldQuery(false)
privateQuery.SetField("Private")
// Visibility filtering: show public gists (Visibility=0) OR user's own gists
visibilityZero := float64(0)
truee := true
publicQuery := bleve.NewNumericRangeInclusiveQuery(&visibilityZero, &visibilityZero, &truee, &truee)
publicQuery.SetField("Visibility")
userIdMatch := float64(userId)
truee := true
userIdQuery := bleve.NewNumericRangeInclusiveQuery(&userIdMatch, &userIdMatch, &truee, &truee)
userIdQuery.SetField("UserID")
accessQuery := bleve.NewDisjunctionQuery(privateQuery, userIdQuery)
accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery)
indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery)
addQuery := func(field, value string) {
if value != "" && value != "." {
q := bleve.NewMatchPhraseQuery(value)
q.FieldVal = field
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q)
}
}
// Handle "All" field - search across all metadata fields with OR logic
if queryMetadata.All != "" {
allQueries := make([]query.Query, 0)
addQuery("Username", queryMetadata.Username)
addQuery("Title", queryMetadata.Title)
addQuery("Extensions", "."+queryMetadata.Extension)
addQuery("Filenames", queryMetadata.Filename)
addQuery("Languages", queryMetadata.Language)
addQuery("Topics", queryMetadata.Topic)
// Create match phrase queries for each field
fields := []struct {
field string
value string
}{
{"Username", queryMetadata.All},
{"Title", queryMetadata.All},
{"Extensions", "." + queryMetadata.All},
{"Filenames", queryMetadata.All},
{"Languages", queryMetadata.All},
{"Topics", queryMetadata.All},
}
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 {
// Original behavior: add each metadata field with AND logic
addQuery := func(field, value string) {
if value != "" && value != "." {
q := bleve.NewMatchPhraseQuery(value)
q.FieldVal = field
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q)
}
}
addQuery("Username", queryMetadata.Username)
addQuery("Title", queryMetadata.Title)
addQuery("Extensions", "."+queryMetadata.Extension)
addQuery("Filenames", queryMetadata.Filename)
addQuery("Languages", queryMetadata.Language)
addQuery("Topics", queryMetadata.Topic)
}
languageFacet := bleve.NewFacetRequest("Languages", 10)

View File

@@ -0,0 +1,162 @@
package index
import (
"os"
"path/filepath"
"testing"
)
// setupBleveIndexer creates a new BleveIndexer for testing
func setupBleveIndexer(t *testing.T) (*BleveIndexer, func()) {
t.Helper()
// Create a temporary directory for the test index
tmpDir, err := os.MkdirTemp("", "bleve-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
indexPath := filepath.Join(tmpDir, "test.index")
indexer := NewBleveIndexer(indexPath)
// Initialize the indexer
err = indexer.Init()
if err != nil {
os.RemoveAll(tmpDir)
t.Fatalf("Failed to initialize BleveIndexer: %v", err)
}
// Store in the global atomicIndexer since Add/Remove use it
var idx Indexer = indexer
atomicIndexer.Store(&idx)
// Return cleanup function
cleanup := func() {
atomicIndexer.Store(nil)
indexer.Close()
os.RemoveAll(tmpDir)
}
return indexer, cleanup
}
func TestBleveIndexerAddGist(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerAddGist(t, indexer)
}
func TestBleveIndexerAllFieldSearch(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerAllFieldSearch(t, indexer)
}
func TestBleveIndexerFuzzySearch(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerFuzzySearch(t, indexer)
}
func TestBleveIndexerSearchBasic(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerSearchBasic(t, indexer)
}
func TestBleveIndexerPagination(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
testIndexerPagination(t, indexer)
}
// TestBleveIndexerInitAndClose tests Bleve-specific initialization and closing
func TestBleveIndexerInitAndClose(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bleve-init-test-*")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tmpDir)
indexPath := filepath.Join(tmpDir, "test.index")
indexer := NewBleveIndexer(indexPath)
// Test initialization
err = indexer.Init()
if err != nil {
t.Fatalf("Failed to initialize BleveIndexer: %v", err)
}
if indexer.index == nil {
t.Fatal("Expected index to be initialized, got nil")
}
// Test closing
indexer.Close()
// Test reopening the same index
indexer2 := NewBleveIndexer(indexPath)
err = indexer2.Init()
if err != nil {
t.Fatalf("Failed to reopen BleveIndexer: %v", err)
}
defer indexer2.Close()
if indexer2.index == nil {
t.Fatal("Expected reopened index to be initialized, got nil")
}
}
// TestBleveIndexerUnicodeSearch tests that Unicode content can be indexed and searched
func TestBleveIndexerUnicodeSearch(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
// Add a gist with Unicode content
gist := &Gist{
GistID: 100,
UserID: 100,
Visibility: 0,
Username: "testuser",
Title: "Unicode Test",
Content: "Hello world with unicode characters: café résumé naïve",
Filenames: []string{"test.txt"},
Extensions: []string{".txt"},
Languages: []string{"Text"},
Topics: []string{"unicode"},
CreatedAt: 1234567890,
UpdatedAt: 1234567890,
}
err := indexer.Add(gist)
if err != nil {
t.Fatalf("Failed to add gist: %v", err)
}
// Search for unicode content
gistIDs, total, _, err := indexer.Search("café", SearchGistMetadata{}, 100, 1)
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if total == 0 {
t.Skip("Unicode search may require specific index configuration")
return
}
found := false
for _, id := range gistIDs {
if id == 100 {
found = true
break
}
}
if !found {
t.Log("Unicode gist not found in search results, but other results were returned")
}
}

View File

@@ -22,4 +22,5 @@ type SearchGistMetadata struct {
Extension string
Language string
Topic string
All string
}

View File

@@ -2,10 +2,11 @@ package index
import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"path/filepath"
"sync/atomic"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
)
var atomicIndexer atomic.Pointer[Indexer]
@@ -13,6 +14,7 @@ var atomicIndexer atomic.Pointer[Indexer]
type Indexer interface {
Init() error
Close()
Reset() error
Add(gist *Gist) error
Remove(gistID uint) error
Search(query string, metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error)
@@ -84,6 +86,19 @@ func Close() {
atomicIndexer.Store(nil)
}
func ResetIndex() error {
if !IndexEnabled() {
return nil
}
idx := atomicIndexer.Load()
if idx == nil {
return fmt.Errorf("indexer is not initialized")
}
return (*idx).Reset()
}
func AddInIndex(gist *Gist) error {
if !IndexEnabled() {
return nil

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
package index
import (
"encoding/json"
"errors"
"fmt"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log"
"strconv"
"strings"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log"
)
type MeiliIndexer struct {
@@ -70,6 +72,21 @@ func (i *MeiliIndexer) open() (meilisearch.IndexManager, error) {
return i.client.Index(i.indexName), nil
}
func (i *MeiliIndexer) Reset() error {
if i.client != nil {
taskInfo, err := i.client.DeleteIndex(i.indexName)
if err != nil {
return fmt.Errorf("failed to delete Meilisearch index: %w", err)
}
_, err = i.client.WaitForTask(taskInfo.TaskUID, 0)
if err != nil {
return fmt.Errorf("failed to wait for Meilisearch index deletion: %w", err)
}
log.Info().Msg("Meilisearch index deleted, re-creating index")
}
return i.Init()
}
func (i *MeiliIndexer) Close() {
if i.client != nil {
i.client.Close()
@@ -82,12 +99,13 @@ func (i *MeiliIndexer) Add(gist *Gist) error {
if gist == nil {
return errors.New("failed to add nil gist to index")
}
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, "GistID")
primaryKey := "GistID"
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
return err
}
func (i *MeiliIndexer) Remove(gistID uint) error {
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.DeleteDocument(strconv.Itoa(int(gistID)))
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.DeleteDocument(strconv.Itoa(int(gistID)), nil)
return err
}
@@ -127,16 +145,20 @@ func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
gistIds := make([]uint, 0, len(response.Hits))
for _, hit := range response.Hits {
if gistID, ok := hit.(map[string]interface{})["GistID"].(float64); ok {
gistIds = append(gistIds, uint(gistID))
if gistIDRaw, ok := hit["GistID"]; ok {
var gistID float64
if err := json.Unmarshal(gistIDRaw, &gistID); err == nil {
gistIds = append(gistIds, uint(gistID))
}
}
}
languageCounts := make(map[string]int)
if facets, ok := response.FacetDistribution.(map[string]interface{})["Languages"]; ok {
for language, count := range facets.(map[string]interface{}) {
if countValue, ok := count.(float64); ok {
languageCounts[language] = int(countValue)
if len(response.FacetDistribution) > 0 {
var facetDist map[string]map[string]int
if err := json.Unmarshal(response.FacetDistribution, &facetDist); err == nil {
if facets, ok := facetDist["Languages"]; ok {
languageCounts = facets
}
}
}

44
internal/render/csv.go Normal file
View File

@@ -0,0 +1,44 @@
package render
import (
"encoding/csv"
"fmt"
"strings"
"github.com/thomiceli/opengist/internal/git"
)
type CSVFile struct {
*git.File
Type string `json:"type"`
Header []string `json:"-"`
Rows [][]string `json:"-"`
}
func (r CSVFile) InternalType() string {
return "CSVFile"
}
func renderCsvFile(file *git.File) (*CSVFile, error) {
reader := csv.NewReader(strings.NewReader(file.Content))
records, err := reader.ReadAll()
if err != nil {
return nil, err
}
header := records[0]
numColumns := len(header)
for i := 1; i < len(records); i++ {
if len(records[i]) != numColumns {
return nil, fmt.Errorf("CSV file has invalid row at index %d", i)
}
}
return &CSVFile{
File: file,
Type: "CSV",
Header: header,
Rows: records[1:],
}, nil
}

View File

@@ -5,47 +5,45 @@ import (
"bytes"
"encoding/base64"
"fmt"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"path"
"sync"
)
type RenderedFile struct {
type HighlightedFile struct {
*git.File
Type string `json:"type"`
Lines []string `json:"-"`
HTML string `json:"-"`
}
type RenderedGist struct {
*db.Gist
Lines []string
HTML string
func (r HighlightedFile) InternalType() string {
return "HighlightedFile"
}
func HighlightFile(file *git.File) (RenderedFile, error) {
type RenderedGist struct {
*db.Gist
Lines []string
HTML string
PreviewMimeType *git.MimeType
}
func highlightFile(file *git.File) (HighlightedFile, error) {
rendered := HighlightedFile{
File: file,
}
if !file.MimeType.IsText() {
return rendered, nil
}
style := newStyle()
lexer := newLexer(file.Filename)
if lexer.Config().Name == "markdown" {
return MarkdownFile(file)
}
if lexer.Config().Name == "XML" && path.Ext(file.Filename) == ".svg" {
return RenderSvgFile(file), nil
}
formatter := html.New(html.WithClasses(true), html.PreventSurroundingPre(true))
rendered := RenderedFile{
File: file,
}
iterator, err := lexer.Tokenise(nil, file.Content+"\n")
if err != nil {
return rendered, err
@@ -74,43 +72,23 @@ func HighlightFile(file *git.File) (RenderedFile, error) {
return rendered, err
}
func HighlightFiles(files []*git.File) []RenderedFile {
const numWorkers = 10
jobs := make(chan int, numWorkers)
renderedFiles := make([]RenderedFile, len(files))
var wg sync.WaitGroup
worker := func() {
for idx := range jobs {
rendered, err := HighlightFile(files[idx])
if err != nil {
log.Error().Err(err).Msg("Error rendering gist preview for " + files[idx].Filename)
}
renderedFiles[idx] = rendered
}
wg.Done()
}
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker()
}
for i := range files {
jobs <- i
}
close(jobs)
wg.Wait()
return renderedFiles
}
func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
rendered := RenderedGist{
Gist: gist,
}
if gist.PreviewMimeType != "" {
mt := &git.MimeType{ContentType: gist.PreviewMimeType}
if mt.CanBeEmbedded() {
rendered.PreviewMimeType = mt
return rendered, nil
}
}
if gist.Preview == "" {
return rendered, nil
}
style := newStyle()
lexer := newLexer(gist.PreviewFilename)
if lexer.Config().Name == "markdown" {
@@ -146,18 +124,12 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
return rendered, err
}
func RenderSvgFile(file *git.File) RenderedFile {
rendered := RenderedFile{
func renderSvgFile(file *git.File) HighlightedFile {
return HighlightedFile{
File: file,
HTML: `<img src="data:image/svg+xml;base64,` + base64.StdEncoding.EncodeToString([]byte(file.Content)) + `" />`,
Type: "SVG",
}
encoded := base64.StdEncoding.EncodeToString([]byte(file.Content))
content := `<img src="data:image/svg+xml;base64,` + encoded + `" />`
rendered.HTML = content
rendered.Type = "SVG"
return rendered
}
func parseFileTypeName(config chroma.Config) string {

View File

@@ -2,6 +2,8 @@ package render
import (
"bytes"
"regexp"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
@@ -18,17 +20,19 @@ func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
var buf bytes.Buffer
err := newMarkdown().Convert([]byte(gist.Preview), &buf)
// remove links in Markdown Preview, quick fix for now
re := regexp.MustCompile(`<a\b[^>]*>(.*?)</a>`)
return RenderedGist{
Gist: gist,
HTML: buf.String(),
HTML: re.ReplaceAllString(buf.String(), `$1`),
}, err
}
func MarkdownFile(file *git.File) (RenderedFile, error) {
func renderMarkdownFile(file *git.File) (HighlightedFile, error) {
var buf bytes.Buffer
err := newMarkdownWithSvgExtension().Convert([]byte(file.Content), &buf)
return RenderedFile{
return HighlightedFile{
File: file,
HTML: buf.String(),
Type: "Markdown",

88
internal/render/render.go Normal file
View File

@@ -0,0 +1,88 @@
package render
import (
"path/filepath"
"sync"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/git"
)
type RenderedFile interface {
InternalType() string
}
type NonHighlightedFile struct {
*git.File
Type string `json:"type"`
}
func (r NonHighlightedFile) InternalType() string {
return "NonHighlightedFile"
}
func RenderFiles(files []*git.File) []RenderedFile {
const numWorkers = 10
jobs := make(chan int, numWorkers)
renderedFiles := make([]RenderedFile, len(files))
var wg sync.WaitGroup
worker := func() {
for idx := range jobs {
renderedFiles[idx] = processFile(files[idx])
}
wg.Done()
}
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker()
}
for i := range files {
jobs <- i
}
close(jobs)
wg.Wait()
return renderedFiles
}
func processFile(file *git.File) RenderedFile {
mt := file.MimeType
if mt.IsCSV() {
rendered, err := renderCsvFile(file)
if err != nil {
rendered, err := highlightFile(file)
if err != nil {
log.Error().Err(err).Msg("Error rendering gist preview for " + file.Filename)
}
return rendered
}
return rendered
} else if mt.IsText() && filepath.Ext(file.Filename) == ".md" {
rendered, err := renderMarkdownFile(file)
if err != nil {
log.Error().Err(err).Msg("Error rendering markdown file for " + file.Filename)
}
return rendered
} else if mt.IsSVG() {
rendered := renderSvgFile(file)
return rendered
} else if mt.CanBeEmbedded() {
rendered := NonHighlightedFile{File: file, Type: mt.RenderType()}
file.Content = ""
return rendered
} else if mt.CanBeRendered() {
rendered, err := highlightFile(file)
if err != nil {
log.Error().Err(err).Msg("Error rendering gist preview for " + file.Filename)
}
return rendered
} else {
rendered := NonHighlightedFile{File: file, Type: mt.RenderType()}
file.Content = ""
return rendered
}
}

View File

@@ -59,7 +59,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
name := fl.Field().String()
restrictedNames := map[string]struct{}{}
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn"} {
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} {
restrictedNames[restrictedName] = struct{}{}
}
@@ -92,7 +92,7 @@ func validateGistTopics(fl validator.FieldLevel) bool {
if len(tag) > 50 {
return false
}
if !regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(tag) {
if !regexp.MustCompile(`^[\p{L}\p{N}-]+$`).MatchString(tag) {
return false
}
}

View File

@@ -2,15 +2,16 @@ package context
import (
"context"
"html/template"
"net/http"
"sync"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"html/template"
"net/http"
"sync"
)
type dataKey string
@@ -57,7 +58,7 @@ func (ctx *Context) DataMap() echo.Map {
}
func (ctx *Context) ErrorRes(code int, message string, err error) error {
if code >= 500 {
if code >= 500 && err != nil {
var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
skipLogger.Error().Err(err).Msg(message)
}
@@ -68,7 +69,7 @@ func (ctx *Context) ErrorRes(code int, message string, err error) error {
}
func (ctx *Context) RedirectTo(location string) error {
return ctx.Context.Redirect(302, config.C.ExternalUrl+location)
return ctx.Redirect(302, config.C.ExternalUrl+location)
}
func (ctx *Context) Html(template string) error {
@@ -144,5 +145,6 @@ func (ctx *Context) Tr(key string, args ...any) string {
var ManifestEntries map[string]Asset
type Asset struct {
File string `json:"file"`
File string `json:"file"`
Css []string `json:"css"`
}

View File

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

View File

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

View File

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

View File

@@ -4,16 +4,15 @@ import (
"crypto/md5"
"errors"
"fmt"
"slices"
"strings"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/oauth"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gorm.io/gorm"
)
@@ -48,7 +47,8 @@ func Oauth(ctx *context.Context) error {
provider, err := oauth.DefineProvider(providerStr, opengistUrl)
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.oauth-unsupported"), nil)
ctx.AddFlash(ctx.Tr("error.oauth-unsupported"), "error")
return ctx.Redirect(302, "/login")
}
if err = provider.RegisterProvider(); err != nil {
@@ -62,28 +62,37 @@ func Oauth(ctx *context.Context) error {
func OauthCallback(ctx *context.Context) error {
provider, err := oauth.CompleteUserAuth(ctx)
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.complete-oauth-login", err.Error()), err)
ctx.AddFlash(ctx.Tr("auth.oauth.no-provider"), "error")
return ctx.Redirect(302, "/login")
}
currUser := ctx.User
user := provider.GetProviderUser()
// if user is logged in, link account to user and update its avatar URL
if currUser != nil {
// check if this OAuth account is already linked to another user
if existingUser, err := db.GetUserByProvider(user.UserID, provider.GetProvider()); err == nil && existingUser != nil {
ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error")
return ctx.RedirectTo("/settings")
}
provider.UpdateUserDB(currUser)
if err = currUser.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot update user "+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")
}
user := provider.GetProviderUser()
userDB, err := db.GetUserByProvider(user.UserID, provider.GetProvider())
// if user is not in database, create it
// if user is not in database, redirect to OAuth registration page
if err != nil {
if ctx.GetData("DisableSignup") == true {
return ctx.ErrorRes(403, ctx.Tr("error.signup-disabled"), nil)
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -94,74 +103,25 @@ func OauthCallback(ctx *context.Context) error {
user.NickName = strings.Split(user.Email, "@")[0]
}
userDB = &db.User{
Username: user.NickName,
Email: user.Email,
MD5Hash: fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(user.Email))))),
}
sess := ctx.GetSession()
sess.Values["oauthProvider"] = provider.GetProvider()
sess.Values["oauthUserID"] = user.UserID
sess.Values["oauthNickname"] = user.NickName
sess.Values["oauthEmail"] = user.Email
sess.Values["oauthAvatarURL"] = user.AvatarURL
sess.Values["oauthIsAdmin"] = provider.IsAdmin()
// set provider id and avatar URL
provider.UpdateUserDB(userDB)
sess.Options.MaxAge = 10 * 60 // 10 minutes
ctx.SaveSession(sess)
if err = userDB.Create(); err != nil {
if db.IsUniqueConstraintViolation(err) {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
return ctx.RedirectTo("/login")
}
return ctx.ErrorRes(500, "Cannot create user", err)
}
// if oidc admin group is not configured set first user as admin
if config.C.OIDCAdminGroup == "" && userDB.ID == 1 {
if err = userDB.SetAdmin(); err != nil {
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")
}
}
}
return ctx.RedirectTo("/oauth/register")
}
// update is admin status from oidc group
if config.C.OIDCAdminGroup != "" {
groupClaimName := config.C.OIDCGroupClaimName
if groupClaimName == "" {
log.Error().Msg("No OIDC group claim name configured")
} else if groups, ok := user.RawData[groupClaimName].([]interface{}); ok {
var groupNames []string
for _, group := range groups {
if groupName, ok := group.(string); ok {
groupNames = append(groupNames, groupName)
}
}
isOIDCAdmin := slices.Contains(groupNames, config.C.OIDCAdminGroup)
log.Debug().Bool("isOIDCAdmin", isOIDCAdmin).Str("user", user.Name).Msg("User is in admin group")
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")
// promote user to admin from oidc group
if !userDB.IsAdmin && provider.IsAdmin() {
userDB.IsAdmin = true
if err = userDB.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot set user admin", err)
}
}
@@ -173,6 +133,150 @@ func OauthCallback(ctx *context.Context) error {
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 {
providerStr := 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 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")
}

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