Compare commits

...

91 Commits

Author SHA1 Message Date
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
171 changed files with 11032 additions and 7628 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 runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: '20' node-version: '20'

View File

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

View File

@@ -8,10 +8,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up Helm - name: Set up Helm
uses: azure/setup-helm@v4.3.0 uses: azure/setup-helm@v4.3.1
with: with:
version: 'latest' version: 'latest'
@@ -26,6 +26,7 @@ jobs:
helm package ./opengist helm package ./opengist
# First time, create the index # First time, create the index
wget -q https://helm.opengist.io/index.yaml
if [ ! -f index.yaml ]; then if [ ! -f index.yaml ]; then
helm repo index --url https://helm.opengist.io . helm repo index --url https://helm.opengist.io .
else else

View File

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

5
.gitignore vendored
View File

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

View File

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

View File

@@ -19,7 +19,6 @@ install:
build_frontend: build_frontend:
@echo "Building frontend assets..." @echo "Building frontend assets..."
npx vite -c public/vite.config.js build 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: build_backend:
@echo "Building Opengist binary..." @echo "Building Opengist binary..."
@@ -39,11 +38,11 @@ build_dev_docker:
docker build -t $(BINARY_NAME)-dev:latest --target dev . docker build -t $(BINARY_NAME)-dev:latest --target dev .
run_dev_docker: 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: watch_frontend:
@echo "Building frontend assets..." @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: watch_backend:
@echo "Building Opengist binary..." @echo "Building Opengist binary..."
@@ -54,8 +53,8 @@ watch:
clean: clean:
@echo "Cleaning up build artifacts..." @echo "Cleaning up build artifacts..."
@rm -f $(BINARY_NAME) public/manifest.json @rm -f $(BINARY_NAME)
@rm -rf public/assets build @rm -rf public/assets public/.vite build
clean_docker: clean_docker:
@echo "Cleaning up Docker image..." @echo "Cleaning up Docker image..."

View File

@@ -1,6 +1,6 @@
# Opengist # 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 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. 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 * Download raw files or as a ZIP archive
* OAuth2 login with GitHub, GitLab, Gitea, and OpenID Connect * OAuth2 login with GitHub, GitLab, Gitea, and OpenID Connect
* Restrict or unrestrict snippets visibility to anonymous users * Restrict or unrestrict snippets visibility to anonymous users
* Docker support * Docker support / Helm Chart
* [More...](/docs/introduction.md#features) * [More...](/docs/introduction.md#features)
## Quick start ## 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 : Docker [images](https://github.com/thomiceli/opengist/pkgs/container/opengist) are available for each release :
```shell ```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 : 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 ```yml
services: services:
opengist: opengist:
image: ghcr.io/thomiceli/opengist:1.10 image: ghcr.io/thomiceli/opengist:1.12
container_name: opengist container_name: opengist
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -77,9 +77,9 @@ Download the archive for your system from the release page [here](https://github
```shell ```shell
# example for linux amd64 # example for linux amd64
wget https://github.com/thomiceli/opengist/releases/download/v1.10.0/opengist1.10.0-linux-amd64.tar.gz wget https://github.com/thomiceli/opengist/releases/download/v1.12.0/opengist1.12.0-linux-amd64.tar.gz
tar xzvf opengist1.10.0-linux-amd64.tar.gz tar xzvf opengist1.12.0-linux-amd64.tar.gz
cd opengist cd opengist
chmod +x opengist chmod +x opengist
./opengist # with or without `--config config.yml` ./opengist # with or without `--config config.yml`

View File

@@ -43,6 +43,7 @@ sqlite.journal-mode: WAL
# HTTP server configuration # HTTP server configuration
# Host to bind to. Default: 0.0.0.0 # 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 http.host: 0.0.0.0
# Port to bind to. Default: 6157 # 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 # Enable or disable git operations (clone, pull, push) via HTTP (either `true` or `false`). Default: true
http.git-enabled: 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 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 # SSH built-in server configuration
# Note: it is not using the SSH daemon from your machine (yet) # 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 # The name of the group that should receive admin rights
oidc.admin-group: 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 # Instance name
# Set your own custom name to be displayed instead of 'Opengist' # Set your own custom name to be displayed instead of 'Opengist'
custom.name: custom.name:

View File

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

View File

@@ -17,9 +17,9 @@ export default {
<header class="hero"> <header class="hero">
<div class="mx-auto max-w-7xl px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="mx-auto lg:text-center"> <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"> <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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
</svg> </svg>
@@ -98,4 +98,4 @@ export default {
} }
</style> </style>

View File

@@ -4,43 +4,51 @@ aside: false
# Configuration Cheat Sheet # Configuration Cheat Sheet
| YAML Config Key | Environment Variable | Default value | Description | | 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-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`. | | 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. | | 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. | | 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. | | 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. | | 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 | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. | | index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. | | index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) | | git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) | | sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. | | 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.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`) | | 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`) | | unix-socket-permissions | OG_UNIX_SOCKET_PERMISSIONS | `0666` | File permissions for Unix socket (octal format). |
| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) | | metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics server (`true` or `false`) |
| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. | | metrics.host | OG_METRICS_HOST | `0.0.0.0` | The host on which the metrics server should bind. |
| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. | | metrics.port | OG_METRICS_PORT | `6158` | The port on which the metrics 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.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) |
| ssh.keygen-executable | OG_SSH_KEYGEN_EXECUTABLE | `ssh-keygen` | Path to the SSH key generation executable. | | ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. |
| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. | | ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. |
| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. | | 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. |
| gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. | | ssh.keygen-executable | OG_SSH_KEYGEN_EXECUTABLE | `ssh-keygen` | Path to the SSH key generation executable. |
| gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. | | github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. |
| gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. | | github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. |
| gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. | | gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. |
| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. | | gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. |
| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. | | gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. |
| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. | | gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. |
| gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea 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. |
| oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider | | gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. |
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. | | gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. |
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. | | gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. |
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. | | oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider |
| custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title | | oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
| custom.logo | OG_CUSTOM_LOGO | none | Path to an image, relative to $opengist-home/custom. | | oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
| custom.favicon | OG_CUSTOM_FAVICON | none | Path to an image, relative to $opengist-home/custom. | | oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
| custom.static-links | OG_CUSTOM_STATIC_LINK_#_(PATH,NAME) | none | Path and name to custom links, more info [here](custom-links.md). | | 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 ## 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 ```yaml
metrics.enabled = true metrics.enabled: true
``` ```
Alternatively, you can use the environment variable: Alternatively, you can use the environment variable:
@@ -16,7 +16,25 @@ Alternatively, you can use the environment variable:
OG_METRICS_ENABLED=true 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 ## Available metrics
@@ -36,14 +54,6 @@ These standard metrics follow the Prometheus naming convention and include label
## Security Considerations ## 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: 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.
```shell
location /metrics {
auth_basic "Metrics";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://localhost:6157/metrics;
}
```

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 - [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 - [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: Requirements:
* [Git](https://git-scm.com/downloads) (2.28+) * [Git](https://git-scm.com/downloads) (2.28+)
* [Go](https://go.dev/doc/install) (1.23+) * [Go](https://go.dev/doc/install) (1.25+)
* [Node.js](https://nodejs.org/en/download/) (16+) * [Node.js](https://nodejs.org/en/download/) (20+)
* [Make](https://linux.die.net/man/1/make) (optional, but easier) * [Make](https://linux.die.net/man/1/make) (optional, but easier)
```shell ```shell
git clone git@github.com:thomiceli/opengist.git git clone git@github.com:thomiceli/opengist.git
cd opengist cd opengist
make install
make watch make watch
``` ```

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# Opengist # 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 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. 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 ```shell
# example for linux amd64 # 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.0/opengist1.12.0-linux-amd64.tar.gz
tar xzvf opengist1.10.0-linux-amd64.tar.gz tar xzvf opengist1.12.0-linux-amd64.tar.gz
cd opengist cd opengist
chmod +x opengist chmod +x opengist
./opengist # with or without `--config config.yml` ./opengist # with or without `--config config.yml`

View File

@@ -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 # 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: 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" 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 ```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 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 ```shell
Username for 'http://localhost:6157': thomas git remote add origin http://opengist.url/init
Password for 'http://thomas@localhost:6157':
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. Enumerating objects: 3, done.
Counting objects: 100% (3/3), done. Counting objects: 100% (3/3), done.
Delta compression using up to 8 threads 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. Writing objects: 100% (3/3), 416 bytes | 416.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
remote: 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:
remote: If you want to keep working with your gist, you could set the remote URL via: 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: remote:
To http://localhost:6157/init To http://opengist.url/init
* [new branch] master -> master * [new branch] master -> master
``` ```

141
go.mod
View File

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

361
go.sum
View File

@@ -1,74 +1,81 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0=
github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc= github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc=
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= 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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 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.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4= github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk= github.com/blevesearch/bleve_index_api v1.3.1 h1:LdH3CQgBbIZ5UI/5Pykz87e0jfeQtVnrdZ2WUBrHHwU=
github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY= github.com/blevesearch/bleve_index_api v1.3.1/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM= github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w= github.com/blevesearch/go-faiss v1.0.27 h1:7cBImYDDQ82WJd5RUZ1ie6zXztCsC73W94ZzwOjkatk=
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= github.com/blevesearch/go-faiss v1.0.27/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 h1:X6nJXnNHl7nasXW+U6y2Ns2Aw8F9STszkYkyBfQ+p0o= github.com/blevesearch/scorch_segment_api/v2 v2.4.1 h1:os52/JeCSLZ0YUkOuLk/Z7pu0SKUMofDPUg+VnbrRD0=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9/go.mod h1:IrzspZlVjhf4X29oJiEhBxEteTqOY9RlYlk1lCmYHr4= 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 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= 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 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y= github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
github.com/blevesearch/zapx/v11 v11.4.1 h1:qFCPlFbsEdwbbckJkysptSQOsHn4s6ZOHL5GMAIAVHA= github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.1/go.mod h1:qNOGxIqdPC1MXauJCD9HBG487PxviTUUbmChFOAosGs= github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.1 h1:K77bhypII60a4v8mwvav7r4IxWA8qxhNjgF9xGdb9eQ= github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.1/go.mod h1:QRPrlPOzAxBNMI0MkgdD+xsTqx65zbuPr3Ko4Re49II= github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.1 h1:EnkEMZFUK0lsW/jOJJF2xOcp+W8TjEsyeN5BeAZEYYE= github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.1/go.mod h1:e6duBMlCvgbH9rkzNMnUa9hRI9F7ri2BRcHfphcmGn8= github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.1 h1:G47kGCshknBZzZAtjcnIAMn3oNx8XBLxp8DMq18ogyE= github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.1/go.mod h1:O7sDxiaL2r2PnCXbhh1Bvm7b4sP+jp4unE9DDPWGoms= github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.1 h1:B5IoTMUCEzFdc9FSQbhVOxAY+BO17c05866fNruiI7g= github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.1/go.mod h1:b/MreHjYeQoLjyY2+UaM0hGZZUajEbE0xhnr1A2/Q6Y= github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc6r0Rzk= github.com/blevesearch/zapx/v16 v16.3.0 h1:hF6VlN15E9CB40RMPyqOIhlDw1OOo9RItumhKMQktxw=
github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk= 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.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA= github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0=
github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ= github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= 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/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.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -79,53 +86,53 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
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/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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY= github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw= github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0= github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= 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/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/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc= github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -141,22 +148,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/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 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= 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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -167,18 +188,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/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo-contrib v0.17.3 h1:hj+qXksKZG1scSe9ksUXMtv7fZYN+PtQT+bPcYA3/TY= github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
github.com/labstack/echo-contrib v0.17.3/go.mod h1:TcRBrzW8jcC4JD+5Dc/pvOyAps0rtgzj7oBqoR3nYsc= github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE=
github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -186,12 +205,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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meilisearch/meilisearch-go v0.31.0 h1:yZRhY1qJqdH8h6GFZALGtkDLyj8f9v5aJpsNMyrUmnY= github.com/meilisearch/meilisearch-go v0.36.0 h1:N1etykTektXt5KPcSbhBO0d5Xx5NaKj4pJWEM7WA5dI=
github.com/meilisearch/meilisearch-go v0.31.0/go.mod h1:aNtyuwurDg/ggxQIcKqWH6G9g2ptc8GyY7PLY4zMn/g= github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6Vk9IivcwKocWRuW7W78=
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -201,26 +218,23 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= 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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/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 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -229,101 +243,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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.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.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 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/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 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
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/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 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.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY=
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs= go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 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/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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,9 +1,9 @@
dependencies: dependencies:
- name: postgresql - name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts repository: oci://registry-1.docker.io/bitnamicharts
version: 16.5.6 version: 16.7.27
- name: meilisearch - name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.12.0 version: 0.17.1
digest: sha256:31084e570aa16e3a26317aeb6d0d5dec62540c314ee4f703374e6e7827399fa6 digest: sha256:ad702e35f258fed1f804d3e48b071767499f5730e099a8c461610950e5182368
generated: "2025-03-27T11:34:51.840778733+01:00" generated: "2025-09-21T04:49:08.679554149+02:00"

View File

@@ -2,18 +2,18 @@ apiVersion: v2
name: opengist name: opengist
description: Opengist Helm chart for Kubernetes description: Opengist Helm chart for Kubernetes
type: application type: application
version: 0.1.0 version: 0.5.0
appVersion: 1.10.0 appVersion: 1.12.0
home: https://opengist.io 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: sources:
- https://github.com/thomiceli/opengist - https://github.com/thomiceli/opengist
dependencies: dependencies:
- name: postgresql - name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts repository: oci://registry-1.docker.io/bitnamicharts
version: 16.5.6 version: 16.7.27
condition: postgresql.enabled condition: postgresql.enabled
- name: meilisearch - name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.12.0 version: 0.17.1
condition: meilisearch.enabled condition: meilisearch.enabled

View File

@@ -1,11 +1,12 @@
# Opengist Helm Chart # 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.5.0](https://img.shields.io/badge/Version-0.5.0-informational?style=flat-square) ![AppVersion: 1.12.0](https://img.shields.io/badge/AppVersion-1.12.0-informational?style=flat-square)
Opengist Helm chart for Kubernetes. Opengist Helm chart for Kubernetes. Check [CHANGELOG.md](CHANGELOG.md) for release notes.
* [Install](#install) * [Install](#install)
* [Configuration](#configuration) * [Configuration](#configuration)
* [Metrics & Monitoring](#metrics--monitoring)
* [Dependencies](#dependencies) * [Dependencies](#dependencies)
* [Meilisearch Indexer](#meilisearch-indexer) * [Meilisearch Indexer](#meilisearch-indexer)
* [PostgreSQL Database](#postgresql-database) * [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> 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 ## Dependencies
### Meilisearch Indexer ### 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`. 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 ### PostgreSQL Database
By default, Opengist uses the `sqlite` database. If needed, this chart also deploys a PostgreSQL instance. 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 db-uri: postgres://user:password@opengist-postgresql:5432/opengist
``` ```
Note: `opengist-postgresql` is the name of the K8S Service deployed by this chart. 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 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -32,6 +33,9 @@ spec:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
spec: spec:
{{- if .Values.deployment.terminationGracePeriodSeconds }}
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
{{- end }}
{{- with .Values.imagePullSecrets }} {{- with .Values.imagePullSecrets }}
imagePullSecrets: imagePullSecrets:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
@@ -49,6 +53,10 @@ spec:
mountPath: /init/config mountPath: /init/config
- name: config-volume - name: config-volume
mountPath: /config-volume mountPath: /config-volume
{{- if .Values.deployment.env }}
env:
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
containers: containers:
- name: {{ .Chart.Name }} - name: {{ .Chart.Name }}
securityContext: securityContext:
@@ -59,6 +67,11 @@ spec:
- name: http - name: http
containerPort: {{ .Values.service.http.port }} containerPort: {{ .Values.service.http.port }}
protocol: TCP protocol: TCP
{{- if index .Values.config "metrics.enabled" }}
- name: metrics
containerPort: {{ .Values.service.metrics.port }}
protocol: TCP
{{- end }}
{{- if .Values.livenessProbe.enabled }} {{- if .Values.livenessProbe.enabled }}
livenessProbe: livenessProbe:
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }} {{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}
@@ -88,7 +101,11 @@ spec:
- name: opengist-data - name: opengist-data
{{- if .Values.persistence.enabled }} {{- if .Values.persistence.enabled }}
persistentVolumeClaim: persistentVolumeClaim:
{{- if .Values.persistence.existingClaim }}
claimName: {{ .Values.persistence.existingClaim }}
{{- else }}
claimName: {{ include "opengist.fullname" . }}-data claimName: {{ include "opengist.fullname" . }}-data
{{- end }}
{{- else }} {{- else }}
emptyDir: {} emptyDir: {}
{{- end }} {{- end }}
@@ -113,3 +130,5 @@ spec:
tolerations: tolerations:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- 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 kind: PersistentVolumeClaim
apiVersion: v1 apiVersion: v1
metadata: metadata:
@@ -25,4 +25,4 @@ spec:
resources: resources:
requests: requests:
storage: {{ .Values.persistence.size }} storage: {{ .Values.persistence.size }}
{{- end }} {{- end }}

View File

@@ -1,4 +1,53 @@
{{- if (not .Values.configExistingSecret) }} {{- 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 := 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 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@@ -9,5 +58,5 @@ metadata:
type: Opaque type: Opaque
stringData: stringData:
config.yml: |- config.yml: |-
{{- .Values.config | toYaml | nindent 4 }} {{- $cfg | toYaml | nindent 4 }}
{{- end }} {{- 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 2 }}
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: config:
log-level: "warn" log-level: "warn"
log-output: "stdout" log-output: "stdout"
metrics.enabled: false
## If defined, the existing secret will be used instead of creating a new one. ## 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. ## The secret must contain a key named `config.yml` with the YAML configuration.
@@ -17,7 +18,7 @@ configExistingSecret: ""
image: image:
repository: ghcr.io/thomiceli/opengist repository: ghcr.io/thomiceli/opengist
pullPolicy: Always pullPolicy: Always
tag: "1.10.0" tag: "1.12.0"
digest: "" digest: ""
imagePullSecrets: [] imagePullSecrets: []
# - name: "image-pull-secret" # - name: "image-pull-secret"
@@ -32,6 +33,34 @@ strategy:
maxSurge: "100%" maxSurge: "100%"
maxUnavailable: 0 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 ## Security Context settings
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
podSecurityContext: podSecurityContext:
@@ -73,6 +102,26 @@ service:
loadBalancerSourceRanges: [] loadBalancerSourceRanges: []
externalTrafficPolicy: 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 ## HTTP Ingress for Opengist
## ref: https://kubernetes.io/docs/concepts/services-networking/ingress/ ## ref: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress: ingress:
@@ -99,20 +148,66 @@ serviceAccount:
annotations: {} annotations: {}
name: "" name: ""
## Set persistence using a Persistent Volume Claim ## Persistent storage for /opengist data directory
## If more than 2 replicas are set, the access mode must be ReadWriteMany
## ref: https://kubernetes.io/docs/concepts/storage/persistent-volumes/ ## ref: https://kubernetes.io/docs/concepts/storage/persistent-volumes/
persistence: persistence:
enabled: true 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: "" existingClaim: ""
storageClass: ""
## Common persistence parameters (apply to perReplica mode OR as defaults for create.*)
storageClass: "" # Empty = cluster default
labels: {} labels: {}
annotations: annotations:
helm.sh/resource-policy: keep helm.sh/resource-policy: keep # Prevents PVC deletion on helm uninstall
size: 5Gi size: 5Gi
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce # perReplica default; override to [ReadWriteMany] if using existingClaim
subPath: "" 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: [] extraVolumes: []
extraVolumeMounts: [] extraVolumeMounts: []

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

@@ -2,13 +2,17 @@ package oauth
import ( import (
gocontext "context" gocontext "context"
gojson "encoding/json"
"io"
"net/http"
"github.com/markbates/goth" "github.com/markbates/goth"
"github.com/markbates/goth/gothic" "github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/gitlab" "github.com/markbates/goth/providers/gitlab"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"net/http"
) )
type GitLabProvider struct { type GitLabProvider struct {
@@ -77,7 +81,34 @@ func (p *GitLabCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
func (p *GitLabCallbackProvider) UpdateUserDB(user *db.User) { func (p *GitLabCallbackProvider) UpdateUserDB(user *db.User) {
user.GitlabID = p.User.UserID 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 NewGitLabCallbackProvider(user *goth.User) CallbackProvider { func NewGitLabCallbackProvider(user *goth.User) CallbackProvider {

View File

@@ -25,6 +25,7 @@ func (p *OIDCProvider) RegisterProvider() error {
"openid", "openid",
"email", "email",
"profile", "profile",
config.C.OIDCGroupClaimName,
) )
if err != nil { if err != nil {

View File

@@ -1,4 +1,4 @@
package auth package password
import ( import (
"crypto/rand" "crypto/rand"
@@ -6,8 +6,9 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"golang.org/x/crypto/argon2"
"strings" "strings"
"golang.org/x/crypto/argon2"
) )
type argon2ID struct { 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 package password
import "github.com/thomiceli/opengist/internal/auth"
func HashPassword(code string) (string, error) { func HashPassword(code string) (string, error) {
return auth.Argon2id.Hash(code) return Argon2id.Hash(code)
} }
func VerifyPassword(code, hashedCode string) (bool, error) { 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 ( import (
"crypto/aes" "crypto/aes"
@@ -19,7 +19,8 @@ func AESEncrypt(key, text []byte) ([]byte, error) {
if _, err = io.ReadFull(rand.Reader, iv); err != nil { if _, err = io.ReadFull(rand.Reader, iv); err != nil {
return nil, err return nil, err
} }
// TODO: remove deprecated
//nolint:staticcheck
stream := cipher.NewCFBEncrypter(block, iv) stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(ciphertext[aes.BlockSize:], text) stream.XORKeyStream(ciphertext[aes.BlockSize:], text)
@@ -38,7 +39,8 @@ func AESDecrypt(key, ciphertext []byte) ([]byte, error) {
iv := ciphertext[:aes.BlockSize] iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:] ciphertext = ciphertext[aes.BlockSize:]
// TODO: remove deprecated
//nolint:staticcheck
stream := cipher.NewCFBDecrypter(block, iv) stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(ciphertext, ciphertext) 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" "bytes"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"github.com/pquerna/otp/totp"
"html/template" "html/template"
"image/png" "image/png"
"strings" "strings"
"github.com/pquerna/otp/totp"
) )
const secretSize = 16 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 var err error
if secret == nil { if secret == nil {
secret, err = generateSecret() secret, err = generateSecret()
if err != nil { 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, Secret: secret,
}) })
if err != nil { if err != nil {
return "", "", err, nil return "", "", nil, err
} }
qrcode, err := otpKey.Image(320, 240) qrcode, err := otpKey.Image(320, 240)
if err != nil { if err != nil {
return "", "", err, nil return "", "", nil, err
} }
var imgBytes bytes.Buffer var imgBytes bytes.Buffer
if err = png.Encode(&imgBytes, qrcode); err != nil { 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())) 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 { 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 ( import (
"encoding/json" "encoding/json"
"net/http"
"net/url"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"net/http"
"net/url"
) )
var webAuthn *webauthn.WebAuthn var webAuthn *webauthn.WebAuthn
@@ -101,7 +102,7 @@ func FinishDiscoverableLogin(jsonSession []byte, response *http.Request) (uint,
return 0, err 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) { func BeginLogin(dbUser *db.User) (credCreation *protocol.CredentialAssertion, jsonSession []byte, err error) {

View File

@@ -9,6 +9,7 @@ import (
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/index" "github.com/thomiceli/opengist/internal/index"
"github.com/thomiceli/opengist/internal/ssh" "github.com/thomiceli/opengist/internal/ssh"
"github.com/thomiceli/opengist/internal/web/handlers/metrics"
"github.com/thomiceli/opengist/internal/web/server" "github.com/thomiceli/opengist/internal/web/server"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"os" "os"
@@ -36,11 +37,18 @@ var CmdStart = cli.Command{
Initialize(ctx) 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", path.Join(config.GetHomeDir(), "sessions"), false)
go httpServer.Start()
go ssh.Start() go ssh.Start()
var metricsServer *metrics.Server
if config.C.MetricsEnabled {
metricsServer = metrics.NewServer()
go metricsServer.Start()
}
<-stopCtx.Done() <-stopCtx.Done()
shutdown() shutdown(httpServer, metricsServer)
return nil return nil
}, },
} }
@@ -130,7 +138,7 @@ func Initialize(ctx *cli.Context) {
} }
} }
func shutdown() { func shutdown(httpServer *server.Server, metricsServer *metrics.Server) {
log.Info().Msg("Shutting down database...") log.Info().Msg("Shutting down database...")
if err := db.Close(); err != nil { if err := db.Close(); err != nil {
log.Error().Err(err).Msg("Failed to close database") log.Error().Err(err).Msg("Failed to close database")
@@ -141,6 +149,12 @@ func shutdown() {
index.Close() index.Close()
} }
httpServer.Stop()
if metricsServer != nil {
metricsServer.Stop()
}
log.Info().Msg("Shutdown complete") log.Info().Msg("Shutdown complete")
} }

View File

@@ -51,6 +51,8 @@ type config struct {
HttpPort string `yaml:"http.port" env:"OG_HTTP_PORT"` HttpPort string `yaml:"http.port" env:"OG_HTTP_PORT"`
HttpGit bool `yaml:"http.git-enabled" env:"OG_HTTP_GIT_ENABLED"` 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"` SshGit bool `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"`
SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"` SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"`
SshPort string `yaml:"ssh.port" env:"OG_SSH_PORT"` SshPort string `yaml:"ssh.port" env:"OG_SSH_PORT"`
@@ -77,7 +79,15 @@ type config struct {
OIDCGroupClaimName string `yaml:"oidc.group-claim-name" env:"OG_OIDC_GROUP_CLAIM_NAME"` OIDCGroupClaimName string `yaml:"oidc.group-claim-name" env:"OG_OIDC_GROUP_CLAIM_NAME"`
OIDCAdminGroup string `yaml:"oidc.admin-group" env:"OG_OIDC_ADMIN_GROUP"` 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"` CustomName string `yaml:"custom.name" env:"OG_CUSTOM_NAME"`
CustomLogo string `yaml:"custom.logo" env:"OG_CUSTOM_LOGO"` CustomLogo string `yaml:"custom.logo" env:"OG_CUSTOM_LOGO"`
@@ -107,6 +117,8 @@ func configWithDefaults() (*config, error) {
c.HttpPort = "6157" c.HttpPort = "6157"
c.HttpGit = true c.HttpGit = true
c.UnixSocketPermissions = "0666"
c.SshGit = true c.SshGit = true
c.SshHost = "0.0.0.0" c.SshHost = "0.0.0.0"
c.SshPort = "2222" c.SshPort = "2222"
@@ -118,6 +130,8 @@ func configWithDefaults() (*config, error) {
c.GiteaName = "Gitea" c.GiteaName = "Gitea"
c.MetricsEnabled = false c.MetricsEnabled = false
c.MetricsHost = "0.0.0.0"
c.MetricsPort = "6158"
return c, nil 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) { func GetSetting(key string) (string, error) {
var setting AdminSetting var setting AdminSetting
var err error var err error
switch db.Dialector.Name() { switch db.Name() {
case "mysql", "sqlite": case "mysql", "sqlite":
err = db.Where("`key` = ?", key).First(&setting).Error err = db.Where("`key` = ?", key).First(&setting).Error
case "postgres": case "postgres":

View File

@@ -3,16 +3,17 @@ package db
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm/logger"
"net/url" "net/url"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"time" "time"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm/logger"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"gorm.io/gorm" "gorm.io/gorm"
@@ -39,6 +40,7 @@ type databaseInfo struct {
User string User string
Password string Password string
Database string Database string
SSLMode string
} }
var DatabaseInfo *databaseInfo var DatabaseInfo *databaseInfo
@@ -46,6 +48,8 @@ var DatabaseInfo *databaseInfo
func parseDBURI(uri string) (*databaseInfo, error) { func parseDBURI(uri string) (*databaseInfo, error) {
info := &databaseInfo{} info := &databaseInfo{}
info.SSLMode = "disable"
if uri == ":memory:" { if uri == ":memory:" {
info.Type = SQLite info.Type = SQLite
info.Database = uri info.Database = uri
@@ -85,6 +89,13 @@ func parseDBURI(uri string) (*databaseInfo, error) {
info.Password, _ = u.User.Password() 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 { switch info.Type {
case PostgreSQL, MySQL: case PostgreSQL, MySQL:
info.Database = strings.TrimPrefix(u.Path, "/") info.Database = strings.TrimPrefix(u.Path, "/")
@@ -144,7 +155,7 @@ func Setup(dbUri string) error {
return err 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 return err
} }
@@ -222,7 +233,7 @@ func setupSQLite(dbInfo databaseInfo) error {
func setupPostgres(dbInfo databaseInfo) error { func setupPostgres(dbInfo databaseInfo) error {
var err 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{ db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
@@ -258,5 +269,5 @@ func DeprecationDBFilename() {
} }
func TruncateDatabase() error { 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 package db
import ( import (
"bytes"
"encoding/gob"
"fmt" "fmt"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -397,7 +395,7 @@ func (gist *Gist) GetForks(currentUserId uint, offset int) ([]*Gist, error) {
} }
func (gist *Gist) CanWrite(user *User) bool { 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 { func (gist *Gist) InitRepository() error {
@@ -420,12 +418,20 @@ func (gist *Gist) Files(revision string, truncate bool) ([]*git.File, error) {
var files []*git.File var files []*git.File
for _, fileCat := range filesCat { 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{ files = append(files, &git.File{
Filename: fileCat.Name, Filename: fileCat.Name,
Size: fileCat.Size, Size: fileCat.Size,
HumanSize: humanize.IBytes(fileCat.Size), HumanSize: humanize.IBytes(fileCat.Size),
Content: fileCat.Content, Content: fileCat.Content,
Truncated: fileCat.Truncated, Truncated: fileCat.Truncated,
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(fileCat.Name)),
}) })
} }
return files, err return files, err
@@ -446,12 +452,20 @@ func (gist *Gist) File(revision string, filename string, truncate bool) (*git.Fi
return nil, err return nil, err
} }
var shortContent string
if len(content) > 512 {
shortContent = content[:512]
} else {
shortContent = content
}
return &git.File{ return &git.File{
Filename: filename, Filename: filename,
Size: size, Size: size,
HumanSize: humanize.IBytes(size), HumanSize: humanize.IBytes(size),
Content: content, Content: content,
Truncated: truncated, Truncated: truncated,
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(filename)),
}, err }, err
} }
@@ -473,8 +487,14 @@ func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error {
} }
for _, file := range *files { for _, file := range *files {
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil { if file.SourcePath != "" { // if it's an uploaded file
return err 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
}
} }
} }
@@ -532,19 +552,28 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
gist.Preview = "" gist.Preview = ""
gist.PreviewFilename = "" gist.PreviewFilename = ""
} else { } else {
file, err := gist.File("HEAD", filesStr[0], true) for _, fileStr := range filesStr {
if err != nil { file, err := gist.File("HEAD", fileStr, true)
return err if err != nil {
} return err
}
if file == nil {
continue
}
gist.Preview = ""
gist.PreviewFilename = file.Filename
split := strings.Split(file.Content, "\n") if !file.MimeType.CanBeEdited() {
if len(split) > 10 { continue
gist.Preview = strings.Join(split[:10], "\n") }
} else {
gist.Preview = file.Content
}
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 { if withTimestampUpdate {
@@ -613,30 +642,6 @@ func (gist *Gist) TopicsSlice() []string {
return topics 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() { func (gist *Gist) UpdateLanguages() {
languages, err := gist.GetLanguagesFromFiles() languages, err := gist.GetLanguagesFromFiles()
if err != nil { if err != nil {
@@ -686,10 +691,15 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
fileDTOs := make([]FileDTO, 0, len(files)) fileDTOs := make([]FileDTO, 0, len(files))
for _, file := range files { for _, file := range files {
fileDTOs = append(fileDTOs, FileDTO{ f := FileDTO{
Filename: file.Filename, Filename: file.Filename,
Content: file.Content, }
}) if file.MimeType.CanBeEdited() {
f.Content = file.Content
} else {
f.Binary = true
}
fileDTOs = append(fileDTOs, f)
} }
return &GistDTO{ return &GistDTO{
@@ -726,8 +736,10 @@ type VisibilityDTO struct {
} }
type FileDTO struct { type FileDTO struct {
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"` Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
Content string `validate:"required"` Content string
Binary bool
SourcePath string // Path to uploaded file, used instead of Content when present
} }
func (dto *GistDTO) ToGist() *Gist { 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) { func GetAllInvitations() ([]*Invitation, error) {
var invitations []*Invitation var invitations []*Invitation
dialect := db.Dialector.Name() dialect := db.Name()
query := db.Model(&Invitation{}) query := db.Model(&Invitation{})
switch dialect { switch dialect {

View File

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

View File

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

View File

@@ -1,28 +1,31 @@
package db package db
import ( import (
"encoding/json"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"gorm.io/gorm" "gorm.io/gorm"
) )
type User struct { type User struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"` Username string `gorm:"uniqueIndex,size:191"`
Password string Password string
IsAdmin bool IsAdmin bool
CreatedAt int64 CreatedAt int64
Email string Email string
MD5Hash string // for gravatar, if no Email is specified, the value is random MD5Hash string // for gravatar, if no Email is specified, the value is random
AvatarURL string AvatarURL string
GithubID string GithubID string
GitlabID string GitlabID string
GiteaID string GiteaID string
OIDCID string `gorm:"column:oidc_id"` OIDCID string `gorm:"column:oidc_id"`
StylePreferences string
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
AccessTokens []AccessToken `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
} }
func (user *User) BeforeDelete(tx *gorm.DB) error { func (user *User) BeforeDelete(tx *gorm.DB) error {
@@ -70,6 +73,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
return err 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 err = tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
if err != nil { if err != nil {
return err return err
@@ -234,6 +242,15 @@ func (user *User) HasMFA() (bool, bool, error) {
return webauthn, totp, err 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 -- // // -- DTO -- //
type UserDTO struct { type UserDTO struct {
@@ -251,3 +268,19 @@ func (dto *UserDTO) ToUser() *User {
type UserUsernameDTO struct { type UserUsernameDTO struct {
Username string `form:"username" validate:"required,max=24,alphanumdash,notreserved"` 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 ( import (
"encoding/hex" "encoding/hex"
"github.com/go-webauthn/webauthn/webauthn"
"time" "time"
"github.com/go-webauthn/webauthn/webauthn"
) )
type WebAuthnCredential struct { type WebAuthnCredential struct {
@@ -67,7 +68,7 @@ func GetUserByCredentialID(credID binaryData) (*User, error) {
var credential WebAuthnCredential var credential WebAuthnCredential
var err error var err error
switch db.Dialector.Name() { switch db.Name() {
case "postgres": case "postgres":
hexCredID := hex.EncodeToString(credID) hexCredID := hex.EncodeToString(credID)
if err = db.Preload("User").Where("credential_id = decode(?, 'hex')", hexCredID).First(&credential).Error; err != nil { 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 cred WebAuthnCredential
var err error var err error
switch db.Dialector.Name() { switch db.Name() {
case "postgres": case "postgres":
hexCredID := hex.EncodeToString(id) hexCredID := hex.EncodeToString(id)
if err = db.Where("credential_id = decode(?, 'hex')", hexCredID).First(&cred).Error; err != nil { if err = db.Where("credential_id = decode(?, 'hex')", hexCredID).First(&cred).Error; err != nil {

View File

@@ -4,7 +4,6 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"net/url" "net/url"
@@ -203,6 +202,11 @@ func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*
return nil, err return nil, err
} }
// Don't truncate Jupyter notebooks
if strings.HasSuffix(file.Name, ".ipynb") {
truncate = false
}
sizeToRead := size sizeToRead := size
if truncate && sizeToRead > truncateLimit { if truncate && sizeToRead > truncateLimit {
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) 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 { func AddAll(gistTmpId string) error {
tmpPath := TmpRepositoryPath(gistTmpId) tmpPath := TmpRepositoryPath(gistTmpId)
@@ -565,50 +580,6 @@ func DeleteUserDirectory(user string) error {
return os.RemoveAll(filepath.Join(config.GetHomeDir(), ReposDirectory, user)) 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 { 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) preReceiveDst, err := os.OpenFile(filepath.Join(repositoryPath, "hooks", hook), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0744)
if err != nil { if err != nil {
@@ -672,7 +643,7 @@ func convertUTF8ToOctal(name string) string {
} }
func convertURLToOctal(name string) string { func convertURLToOctal(name string) string {
decoded, err := url.QueryUnescape(name) decoded, err := url.PathUnescape(name)
if err != nil { if err != nil {
return name return name
} }

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

@@ -0,0 +1,91 @@
package git
import (
"fmt"
"strings"
"github.com/gabriel-vasile/mimetype"
)
type MimeType struct {
ContentType string
extension string
}
func (mt MimeType) IsText() bool {
return strings.Contains(mt.ContentType, "text/")
}
func (mt MimeType) IsCSV() bool {
return strings.Contains(mt.ContentType, "text/csv") &&
(strings.HasSuffix(mt.extension, ".csv"))
}
func (mt MimeType) IsImage() bool {
return strings.Contains(mt.ContentType, "image/")
}
func (mt MimeType) IsSVG() bool {
return strings.Contains(mt.ContentType, "image/svg+xml")
}
func (mt MimeType) IsPDF() bool {
return strings.Contains(mt.ContentType, "application/pdf")
}
func (mt MimeType) IsAudio() bool {
return strings.Contains(mt.ContentType, "audio/")
}
func (mt MimeType) IsVideo() bool {
return strings.Contains(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}
}

View File

@@ -3,27 +3,23 @@ package git
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/csv"
"fmt" "fmt"
"io" "io"
"regexp"
"strings" "strings"
) )
type File struct { type File struct {
Filename string `json:"filename"` Filename string `json:"filename"`
Size uint64 `json:"size"` Size uint64 `json:"size"`
HumanSize string `json:"human_size"` HumanSize string `json:"human_size"`
OldFilename string `json:"-"` OldFilename string `json:"-"`
Content string `json:"content"` Content string `json:"content"`
Truncated bool `json:"truncated"` Truncated bool `json:"truncated"`
IsCreated bool `json:"-"` IsCreated bool `json:"-"`
IsDeleted bool `json:"-"` IsDeleted bool `json:"-"`
} IsBinary bool `json:"-"`
MimeType MimeType `json:"-"`
type CsvFile struct {
File
Header []string
Rows [][]string
} }
type Commit struct { type Commit struct {
@@ -62,6 +58,8 @@ func truncateCommandOutput(out io.Reader, maxBytes int64) (string, bool, error)
return string(buf), truncated, nil 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 // 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) { func parseLog(out io.Reader, maxFiles int, maxBytes int) ([]*Commit, error) {
var commits []*Commit var commits []*Commit
@@ -206,6 +204,20 @@ loopLog:
currentFile.IsCreated = true currentFile.IsCreated = true
case strings.HasPrefix(line, "deleted file"): case strings.HasPrefix(line, "deleted file"):
currentFile.IsDeleted = true 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, "--- "): case strings.HasPrefix(line, "--- "):
name := convertOctalToUTF8(line[4 : len(line)-1]) name := convertOctalToUTF8(line[4 : len(line)-1])
if parseRename && currentFile.IsDeleted { if parseRename && currentFile.IsDeleted {
@@ -344,27 +356,3 @@ func skipToNextCommit(input *bufio.Reader) (line string, err error) {
} }
return line, err 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) name := display.Self.Name(tag)
if tag == language.AmericanEnglish { switch tag {
case language.AmericanEnglish:
name = "English" name = "English"
} else if tag == language.EuropeanSpanish { case language.EuropeanSpanish:
name = "Español" 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.embed-help: 'Bette diese Gist in deine Webseite ein.'
gist.header.download-zip: 'ZIP Herunterladen' gist.header.download-zip: 'ZIP Herunterladen'
gist.raw: 'Orginalformat' gist.raw: 'Originalformat'
gist.file-truncated: 'Diese Datei wurde abgeschnitten.' gist.file-truncated: 'Diese Datei wurde abgeschnitten.'
gist.watch-full-file: 'Die gesamte Datei anzeigen.' gist.watch-full-file: 'Die gesamte Datei anzeigen.'
gist.file-not-valid: 'Diese Datei ist keine korrekte CSV Datei.' 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-mode-tab: 'Tab'
gist.new.indent-size: 'Einrückungs Größe' gist.new.indent-size: 'Einrückungs Größe'
gist.new.wrap-mode: 'Textumbruch Modus' 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.wrap-mode-soft: 'weicher Zeilenumbruch'
gist.new.add-file: 'Datei hinzufügen' gist.new.add-file: 'Datei hinzufügen'
gist.new.create-public-button: 'Öffentliche Gist erstellen' gist.new.create-public-button: 'Öffentliche Gist erstellen'
@@ -53,7 +53,7 @@ gist.edit.delete: 'Löschen'
gist.edit.cancel: 'Abbrechen' gist.edit.cancel: 'Abbrechen'
gist.edit.save: 'Speichern' gist.edit.save: 'Speichern'
gist.list.joined: 'Gemeinsam' gist.list.joined: 'Beigetreten'
gist.list.all: 'Alle Gists' gist.list.all: 'Alle Gists'
gist.list.search-results: 'Suchergebnisse' gist.list.search-results: 'Suchergebnisse'
gist.list.sort: 'Sortieren' gist.list.sort: 'Sortieren'
@@ -61,17 +61,17 @@ gist.list.sort-by-created: 'erstellt'
gist.list.sort-by-updated: 'bearbeitet' gist.list.sort-by-updated: 'bearbeitet'
gist.list.order-by-asc: 'Älteste' gist.list.order-by-asc: 'Älteste'
gist.list.order-by-desc: 'Neueste' 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.liked: 'Favorisiert'
gist.list.likes: 'Favoriten' gist.list.likes: 'Favoriten'
gist.list.forked: 'Forked' gist.list.forked: 'Geforkt'
gist.list.forked-from: 'Forked von' gist.list.forked-from: 'Geforkt von'
gist.list.forks: 'Forks' gist.list.forks: 'Forks'
gist.list.files: 'Dateien' gist.list.files: 'Dateien'
gist.list.last-active: 'Zuletzt aktiv' gist.list.last-active: 'Zuletzt aktiv'
gist.list.no-gists: 'Keine Gists' gist.list.no-gists: 'Keine Gists'
gist.list.all-liked-by: 'Alle Gists favorisiert von %s' 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.list.all-from: 'Alle Gists von %s'
gist.search.found: 'Gists gefunden' gist.search.found: 'Gists gefunden'
@@ -89,7 +89,7 @@ gist.forks.for: 'Fork für %s'
gist.likes: 'Favoriten' gist.likes: 'Favoriten'
gist.likes.no: 'Keine Favorisierungen' gist.likes.no: 'Keine Favorisierungen'
gist.likes.for: 'Favortitisiert für %s' gist.likes.for: 'Favorisiert für %s'
gist.revisions: 'Revisionen' gist.revisions: 'Revisionen'
gist.revision.revised: 'hat die Gist bearbeitet' 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-github-account: 'GitHub-Account verlinken'
settings.link-gitlab-account: 'GitLab-Account verlinken' settings.link-gitlab-account: 'GitLab-Account verlinken'
settings.link-gitea-account: 'Gitea-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-gitlab-account: 'GitLab-Account Verlinkung aufheben'
settings.unlink-gitea-account: 'Gitea-Account Verlinkung aufheben' settings.unlink-gitea-account: 'Gitea-Account Verlinkung aufheben'
settings.delete-account: 'Account löschen' settings.delete-account: 'Account löschen'
@@ -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.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. 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 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.raw: Raw
gist.file-truncated: This file has been truncated. 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.watch-full-file: View the full file.
gist.file-not-valid: This file is not a valid CSV file. gist.file-not-valid: This file is not a valid CSV file.
gist.no-content: No files found gist.no-content: No files found
gist.preview-non-available: Preview not available
gist.new.new_gist: New gist gist.new.new_gist: New gist
gist.new.title: Title gist.new.title: Title
@@ -46,6 +49,8 @@ gist.new.create-private-button: Create private gist
gist.new.preview: Preview gist.new.preview: Preview
gist.new.create-a-new-gist: Create a new gist gist.new.create-a-new-gist: Create a new gist
gist.new.topics: Topics (separate with spaces) 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.editing: Editing
gist.edit.edit-gist: Edit %s 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.diff-truncated: Diff is too large to be shown
gist.revision.file-renamed-no-changes: File renamed without changes gist.revision.file-renamed-no-changes: File renamed without changes
gist.revision.empty-file: Empty file 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-changes: No changes
gist.revision.no-revisions: No revisions to show gist.revision.no-revisions: No revisions to show
gist.revision-of: Revision of %s 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: Change password
settings.change-password-help: Change your password to login to Opengist via HTTP settings.change-password-help: Change your password to login to Opengist via HTTP
settings.password-label-title: Password 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.signup-disabled: Administrator has disabled signing up
auth.login: Login auth.login: Login
@@ -204,6 +246,8 @@ error.cannot-bind-data: Cannot bind data
error.invalid-number: Invalid number error.invalid-number: Invalid number
error.invalid-character-unescaped: Invalid character unescaped error.invalid-character-unescaped: Invalid character unescaped
error.not-in-mfa-session: User is not in a MFA session 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.all: All
header.menu.new: New header.menu.new: New
@@ -321,4 +365,4 @@ validation.not-enough: Not enough %s
validation.invalid: Invalid %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 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

@@ -267,3 +267,77 @@ validation.invalid: '%s non valido'
html.title.admin-panel: 'Pannello amministratore' html.title.admin-panel: 'Pannello amministratore'
settings.ssh-key-exists: Questa chiave SSH esiste già 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

@@ -185,17 +185,17 @@ gist.list.all-from: 'Все фрагменты от %s'
gist.search.found: 'фрагментов найдено' gist.search.found: 'фрагментов найдено'
gist.search.no-results: 'Не найден ни один фрагмент' gist.search.no-results: 'Не найден ни один фрагмент'
gist.search.help.user: 'фрагментов создано пользователем' gist.search.help.user: 'фрагментов создано пользователем'
gist.search.help.title: '' gist.search.help.title: 'фрагментов с указанным заголовком'
gist.search.help.filename: '' gist.search.help.filename: 'фрагменты содержащие файлы с указанным именем'
gist.search.help.extension: '' gist.search.help.extension: 'фрагменты, содержащие файлы с указанным расширением'
gist.search.help.language: '' gist.search.help.language: 'фрагменты, содержащие файлы с указанным языком'
gist.forks.for: '' gist.forks.for: 'Форки фрагмента %s'
gist.likes.for: '' gist.likes.for: 'Лайки фрагмента %s'
gist.revision-of: '' gist.revision-of: 'Ревизия фрагмента %s'
settings.link-gitlab-account: '' settings.link-gitlab-account: 'Привязать учётную запись Gitlab'
settings.unlink-gitlab-account: '' settings.unlink-gitlab-account: 'Отвязать учётную запись GitHub'
settings.change-username: '' settings.change-username: 'Сменить имя пользователя'
settings.create-password: '' settings.create-password: 'Создать пароль'
settings.create-password-help: '' settings.create-password-help: ''
settings.change-password: '' settings.change-password: ''
settings.change-password-help: '' settings.change-password-help: ''
@@ -257,3 +257,24 @@ validation.should-only-contain-alphanumeric-characters-and-dashes: ''
validation.not-enough: '' validation.not-enough: ''
validation.invalid: '' validation.invalid: ''
html.title.admin-panel: '' 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: Изменения в бинарных файлах не отображаются

View File

@@ -266,3 +266,67 @@ validation.invalid: Geçersiz %s
html.title.admin-panel: Yönetici paneli html.title.admin-panel: Yönetici paneli
settings.ssh-key-exists: SSH anahtarı zaten mevcut 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

@@ -2,6 +2,8 @@ package index
import ( import (
"errors" "errors"
"strconv"
"github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom" "github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase" "github.com/blevesearch/bleve/v2/analysis/token/camelcase"
@@ -10,7 +12,6 @@ import (
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/search/query" "github.com/blevesearch/bleve/v2/search/query"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"strconv"
) )
type BleveIndexer struct { type BleveIndexer struct {
@@ -53,6 +54,8 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
docMapping := bleve.NewDocumentMapping() docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("GistID", bleve.NewNumericFieldMapping()) docMapping.AddFieldMappingsAt("GistID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("UserID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("Visibility", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("Content", bleve.NewTextFieldMapping()) docMapping.AddFieldMappingsAt("Content", bleve.NewTextFieldMapping())
mapping := bleve.NewIndexMapping() mapping := bleve.NewIndexMapping()
@@ -74,6 +77,7 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
} }
docMapping.DefaultAnalyzer = "gistAnalyser" docMapping.DefaultAnalyzer = "gistAnalyser"
mapping.DefaultMapping = docMapping
return bleve.New(i.path, mapping) return bleve.New(i.path, mapping)
} }
@@ -105,39 +109,72 @@ func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
var err error var err error
var indexerQuery query.Query var indexerQuery query.Query
if queryStr != "" { if queryStr != "" {
contentQuery := bleve.NewMatchPhraseQuery(queryStr) // Use match query with fuzzy matching for more flexible content search
contentQuery.FieldVal = "Content" contentQuery := bleve.NewMatchQuery(queryStr)
contentQuery.SetField("Content")
contentQuery.SetFuzziness(2)
indexerQuery = contentQuery indexerQuery = contentQuery
} else { } else {
contentQuery := bleve.NewMatchAllQuery() contentQuery := bleve.NewMatchAllQuery()
indexerQuery = contentQuery indexerQuery = contentQuery
} }
privateQuery := bleve.NewBoolFieldQuery(false) // Visibility filtering: show public gists (Visibility=0) OR user's own gists
privateQuery.SetField("Private") visibilityZero := float64(0)
truee := true
publicQuery := bleve.NewNumericRangeInclusiveQuery(&visibilityZero, &visibilityZero, &truee, &truee)
publicQuery.SetField("Visibility")
userIdMatch := float64(userId) userIdMatch := float64(userId)
truee := true
userIdQuery := bleve.NewNumericRangeInclusiveQuery(&userIdMatch, &userIdMatch, &truee, &truee) userIdQuery := bleve.NewNumericRangeInclusiveQuery(&userIdMatch, &userIdMatch, &truee, &truee)
userIdQuery.SetField("UserID") userIdQuery.SetField("UserID")
accessQuery := bleve.NewDisjunctionQuery(privateQuery, userIdQuery) accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery)
indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery) indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery)
addQuery := func(field, value string) { // Handle "All" field - search across all metadata fields with OR logic
if value != "" && value != "." { if queryMetadata.All != "" {
q := bleve.NewMatchPhraseQuery(value) allQueries := make([]query.Query, 0)
q.FieldVal = field
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q)
}
}
addQuery("Username", queryMetadata.Username) // Create match phrase queries for each field
addQuery("Title", queryMetadata.Title) fields := []struct {
addQuery("Extensions", "."+queryMetadata.Extension) field string
addQuery("Filenames", queryMetadata.Filename) value string
addQuery("Languages", queryMetadata.Language) }{
addQuery("Topics", queryMetadata.Topic) {"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) 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 Extension string
Language string Language string
Topic string Topic string
All string
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
package index package index
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log"
"strconv" "strconv"
"strings" "strings"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log"
) )
type MeiliIndexer struct { type MeiliIndexer struct {
@@ -82,12 +84,13 @@ func (i *MeiliIndexer) Add(gist *Gist) error {
if gist == nil { if gist == nil {
return errors.New("failed to add nil gist to index") return errors.New("failed to add nil gist to index")
} }
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, "GistID") primaryKey := "GistID"
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
return err return err
} }
func (i *MeiliIndexer) Remove(gistID uint) error { 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 return err
} }
@@ -127,16 +130,20 @@ func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
gistIds := make([]uint, 0, len(response.Hits)) gistIds := make([]uint, 0, len(response.Hits))
for _, hit := range response.Hits { for _, hit := range response.Hits {
if gistID, ok := hit.(map[string]interface{})["GistID"].(float64); ok { if gistIDRaw, ok := hit["GistID"]; ok {
gistIds = append(gistIds, uint(gistID)) var gistID float64
if err := json.Unmarshal(gistIDRaw, &gistID); err == nil {
gistIds = append(gistIds, uint(gistID))
}
} }
} }
languageCounts := make(map[string]int) languageCounts := make(map[string]int)
if facets, ok := response.FacetDistribution.(map[string]interface{})["Languages"]; ok { if len(response.FacetDistribution) > 0 {
for language, count := range facets.(map[string]interface{}) { var facetDist map[string]map[string]int
if countValue, ok := count.(float64); ok { if err := json.Unmarshal(response.FacetDistribution, &facetDist); err == nil {
languageCounts[language] = int(countValue) 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,44 @@ import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles" "github.com/alecthomas/chroma/v2/styles"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"path"
"sync"
) )
type RenderedFile struct { type HighlightedFile struct {
*git.File *git.File
Type string `json:"type"` Type string `json:"type"`
Lines []string `json:"-"` Lines []string `json:"-"`
HTML string `json:"-"` HTML string `json:"-"`
} }
func (r HighlightedFile) InternalType() string {
return "HighlightedFile"
}
type RenderedGist struct { type RenderedGist struct {
*db.Gist *db.Gist
Lines []string Lines []string
HTML string HTML string
} }
func HighlightFile(file *git.File) (RenderedFile, error) { func highlightFile(file *git.File) (HighlightedFile, error) {
rendered := HighlightedFile{
File: file,
}
if !file.MimeType.IsText() {
return rendered, nil
}
style := newStyle() style := newStyle()
lexer := newLexer(file.Filename) 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)) formatter := html.New(html.WithClasses(true), html.PreventSurroundingPre(true))
rendered := RenderedFile{
File: file,
}
iterator, err := lexer.Tokenise(nil, file.Content+"\n") iterator, err := lexer.Tokenise(nil, file.Content+"\n")
if err != nil { if err != nil {
return rendered, err return rendered, err
@@ -74,38 +71,6 @@ func HighlightFile(file *git.File) (RenderedFile, error) {
return rendered, err 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) { func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
rendered := RenderedGist{ rendered := RenderedGist{
Gist: gist, Gist: gist,
@@ -146,18 +111,12 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
return rendered, err return rendered, err
} }
func RenderSvgFile(file *git.File) RenderedFile { func renderSvgFile(file *git.File) HighlightedFile {
rendered := RenderedFile{ return HighlightedFile{
File: file, 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 { func parseFileTypeName(config chroma.Config) string {

View File

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

@@ -92,7 +92,7 @@ func validateGistTopics(fl validator.FieldLevel) bool {
if len(tag) > 50 { if len(tag) > 50 {
return false return false
} }
if !regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(tag) { if !regexp.MustCompile(`^[\p{L}\p{N}-]+$`).MatchString(tag) {
return false return false
} }
} }

View File

@@ -2,15 +2,16 @@ package context
import ( import (
"context" "context"
"html/template"
"net/http"
"sync"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n" "github.com/thomiceli/opengist/internal/i18n"
"html/template"
"net/http"
"sync"
) )
type dataKey string type dataKey string
@@ -57,7 +58,7 @@ func (ctx *Context) DataMap() echo.Map {
} }
func (ctx *Context) ErrorRes(code int, message string, err error) error { 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() var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
skipLogger.Error().Err(err).Msg(message) 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 { 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 { 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 var ManifestEntries map[string]Asset
type Asset struct { type Asset struct {
File string `json:"file"` File string `json:"file"`
Css []string `json:"css"`
} }

View File

@@ -2,7 +2,9 @@ package auth
import ( import (
"errors" "errors"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth"
passwordpkg "github.com/thomiceli/opengist/internal/auth/password" passwordpkg "github.com/thomiceli/opengist/internal/auth/password"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n" "github.com/thomiceli/opengist/internal/i18n"
@@ -114,6 +116,7 @@ func ProcessLogin(ctx *context.Context) error {
return ctx.ErrorRes(403, ctx.Tr("error.login-disabled-form"), nil) return ctx.ErrorRes(403, ctx.Tr("error.login-disabled-form"), nil)
} }
var user *db.User
var err error var err error
sess := ctx.GetSession() sess := ctx.GetSession()
@@ -121,26 +124,16 @@ func ProcessLogin(ctx *context.Context) error {
if err = ctx.Bind(dto); err != nil { if err = ctx.Bind(dto); err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err) return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
} }
password := dto.Password
var user *db.User user, err = auth.TryAuthentication(dto.Username, dto.Password)
if err != nil {
if user, err = db.GetUserByUsername(dto.Username); err != nil { var authErr auth.AuthError
if !errors.Is(err, gorm.ErrRecordNotFound) { if errors.As(err, &authErr) {
return ctx.ErrorRes(500, "Cannot get user", err) log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
return ctx.RedirectTo("/login")
} }
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) return ctx.ErrorRes(500, "Authentication system error", nil)
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
return ctx.RedirectTo("/login")
}
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
if err != nil {
return ctx.ErrorRes(500, "Cannot check for password", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
return ctx.RedirectTo("/login")
} }
// handle MFA // handle MFA

View File

@@ -1,10 +1,11 @@
package auth package auth
import ( import (
"net/url"
"github.com/thomiceli/opengist/internal/auth/totp" "github.com/thomiceli/opengist/internal/auth/totp"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"net/url"
) )
func BeginTotp(ctx *context.Context) error { func BeginTotp(ctx *context.Context) error {
@@ -14,7 +15,7 @@ func BeginTotp(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot check for user MFA", err) return ctx.ErrorRes(500, "Cannot check for user MFA", err)
} else if hasTotp { } else if hasTotp {
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error") ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/mfa")
} }
ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string)) ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string))
@@ -25,7 +26,7 @@ func BeginTotp(ctx *context.Context) error {
sess := ctx.GetSession() sess := ctx.GetSession()
generatedSecret, _ := sess.Values["generatedSecret"].([]byte) generatedSecret, _ := sess.Values["generatedSecret"].([]byte)
totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(ctx.User.Username, ogUrl.Hostname(), generatedSecret) totpSecret, qrcode, generatedSecret, err := totp.GenerateQRCode(ctx.User.Username, ogUrl.Hostname(), generatedSecret)
if err != nil { if err != nil {
return ctx.ErrorRes(500, "Cannot generate TOTP QR code", err) return ctx.ErrorRes(500, "Cannot generate TOTP QR code", err)
} }
@@ -47,7 +48,7 @@ func FinishTotp(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot check for user MFA", err) return ctx.ErrorRes(500, "Cannot check for user MFA", err)
} else if hasTotp { } else if hasTotp {
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error") ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/mfa")
} }
dto := &db.TOTPDTO{} dto := &db.TOTPDTO{}
@@ -134,7 +135,7 @@ func AssertTotp(ctx *context.Context) error {
} }
ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning") ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning")
redirectUrl = "/settings" redirectUrl = "/settings/mfa"
} }
sess.Values["user"] = userId sess.Values["user"] = userId
@@ -157,7 +158,7 @@ func DisableTotp(ctx *context.Context) error {
} }
ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success") ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/mfa")
} }
func RegenerateTotpRecoveryCodes(ctx *context.Context) error { func RegenerateTotpRecoveryCodes(ctx *context.Context) error {

View File

@@ -2,6 +2,9 @@ package gist
import ( import (
"errors" "errors"
"slices"
"strings"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/index" "github.com/thomiceli/opengist/internal/index"
@@ -9,8 +12,6 @@ import (
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers" "github.com/thomiceli/opengist/internal/web/handlers"
"gorm.io/gorm" "gorm.io/gorm"
"slices"
"strings"
) )
func AllGists(ctx *context.Context) error { func AllGists(ctx *context.Context) error {
@@ -54,18 +55,19 @@ func AllGists(ctx *context.Context) error {
mode := ctx.GetData("mode") mode := ctx.GetData("mode")
if fromUserStr == "" { if fromUserStr == "" {
if mode == "search" { switch mode {
case "search":
ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results")) ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
ctx.SetData("searchQuery", ctx.QueryParam("q")) ctx.SetData("searchQuery", ctx.QueryParam("q"))
pagination.Query = ctx.QueryParam("q") pagination.Query = ctx.QueryParam("q")
urlPage = "search" urlPage = "search"
gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "") gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "")
} else if mode == "topics" { case "topics":
ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic"))) ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic")))
ctx.SetData("topic", ctx.Param("topic")) ctx.SetData("topic", ctx.Param("topic"))
urlPage = "topics/" + ctx.Param("topic") urlPage = "topics/" + ctx.Param("topic")
gists, err = db.GetAllGistsFromSearch(currentUserId, "", pageInt-1, sort, order, ctx.Param("topic")) gists, err = db.GetAllGistsFromSearch(currentUserId, "", pageInt-1, sort, order, ctx.Param("topic"))
} else if mode == "all" { case "all":
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all")) ctx.SetData("htmlTitle", ctx.TrH("gist.list.all"))
urlPage = "all" urlPage = "all"
gists, err = db.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order) gists, err = db.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order)
@@ -101,15 +103,16 @@ func AllGists(ctx *context.Context) error {
ctx.SetData("countForked", countForked) ctx.SetData("countForked", countForked)
} }
if mode == "liked" { switch mode {
case "liked":
urlPage = fromUserStr + "/liked" urlPage = fromUserStr + "/liked"
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-liked-by", fromUserStr)) ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-liked-by", fromUserStr))
gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order) gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
} else if mode == "forked" { case "forked":
urlPage = fromUserStr + "/forked" urlPage = fromUserStr + "/forked"
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-forked-by", fromUserStr)) ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-forked-by", fromUserStr))
gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order) gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
} else if mode == "fromUser" { case "fromUser":
urlPage = fromUserStr urlPage = fromUserStr
if languages, err := db.GetGistLanguagesForUser(fromUser.ID, currentUserId); err != nil { if languages, err := db.GetGistLanguagesForUser(fromUser.ID, currentUserId); err != nil {
@@ -186,6 +189,7 @@ func Search(ctx *context.Context) error {
Extension: meta["extension"], Extension: meta["extension"],
Language: meta["language"], Language: meta["language"],
Topic: meta["topic"], Topic: meta["topic"],
All: meta["all"],
}, currentUserId, pageInt) }, currentUserId, pageInt)
if err != nil { if err != nil {
return ctx.ErrorRes(500, "Error searching gists", err) return ctx.ErrorRes(500, "Error searching gists", err)

View File

@@ -1,14 +1,19 @@
package gist package gist
import ( import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/i18n" "github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/validator" "github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"net/url"
"strconv"
"strings"
) )
func Create(ctx *context.Context) error { func Create(ctx *context.Context) error {
@@ -17,10 +22,7 @@ func Create(ctx *context.Context) error {
} }
func ProcessCreate(ctx *context.Context) error { func ProcessCreate(ctx *context.Context) error {
isCreate := false isCreate := ctx.Request().URL.Path == "/"
if ctx.Request().URL.Path == "/" {
isCreate = true
}
err := ctx.Request().ParseForm() err := ctx.Request().ParseForm()
if err != nil { if err != nil {
@@ -43,25 +45,78 @@ func ProcessCreate(ctx *context.Context) error {
dto.Files = make([]db.FileDTO, 0) dto.Files = make([]db.FileDTO, 0)
fileCounter := 0 fileCounter := 0
for i := 0; i < len(ctx.Request().PostForm["content"]); i++ {
name := ctx.Request().PostForm["name"][i]
content := ctx.Request().PostForm["content"][i]
names := ctx.Request().PostForm["name"]
contents := ctx.Request().PostForm["content"]
// Process files from text editors
for i, content := range contents {
if content == "" {
continue
}
name := names[i]
if name == "" { if name == "" {
fileCounter += 1 fileCounter += 1
name = "gistfile" + strconv.Itoa(fileCounter) + ".txt" name = "gistfile" + strconv.Itoa(fileCounter) + ".txt"
} }
escapedValue, err := url.QueryUnescape(content) escapedValue, err := url.PathUnescape(content)
if err != nil { if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.invalid-character-unescaped"), err) return ctx.ErrorRes(400, ctx.Tr("error.invalid-character-unescaped"), err)
} }
dto.Files = append(dto.Files, db.FileDTO{ dto.Files = append(dto.Files, db.FileDTO{
Filename: strings.Trim(name, " "), Filename: strings.TrimSpace(name),
Content: escapedValue, Content: escapedValue,
}) })
} }
// Process uploaded files from UUID arrays
fileUUIDs := ctx.Request().PostForm["uploadedfile_uuid"]
fileFilenames := ctx.Request().PostForm["uploadedfile_filename"]
if len(fileUUIDs) == len(fileFilenames) {
for i, fileUUID := range fileUUIDs {
filePath := filepath.Join(filepath.Join(config.GetHomeDir(), "uploads"), fileUUID)
if _, err := os.Stat(filePath); err != nil {
continue
}
dto.Files = append(dto.Files, db.FileDTO{
Filename: fileFilenames[i],
SourcePath: filePath,
Content: "", // Empty since we're using SourcePath
})
}
}
// Process binary file operations (edit mode)
binaryOldNames := ctx.Request().PostForm["binary_old_name"]
binaryNewNames := ctx.Request().PostForm["binary_new_name"]
if len(binaryOldNames) == len(binaryNewNames) {
for i, oldName := range binaryOldNames {
newName := binaryNewNames[i]
if newName == "" { // deletion
continue
}
if !isCreate {
gistOld := ctx.GetData("gist").(*db.Gist)
fileContent, _, err := git.GetFileContent(gistOld.User.Username, gistOld.Uuid, "HEAD", oldName, false)
if err != nil {
continue
}
dto.Files = append(dto.Files, db.FileDTO{
Filename: newName,
Content: fileContent,
Binary: true,
})
}
}
}
ctx.SetData("dto", dto) ctx.SetData("dto", dto)
err = ctx.Validate(dto) err = ctx.Validate(dto)
@@ -93,31 +148,20 @@ func ProcessCreate(ctx *context.Context) error {
if err != nil { if err != nil {
return ctx.ErrorRes(500, "Error creating an UUID", err) return ctx.ErrorRes(500, "Error creating an UUID", err)
} }
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1) gist.Uuid = strings.ReplaceAll(uuidGist.String(), "-", "")
gist.UserID = user.ID gist.UserID = user.ID
gist.User = *user gist.User = *user
} }
if gist.Title == "" { if gist.Title == "" {
if ctx.Request().PostForm["name"][0] == "" { if dto.Files[0].Filename == "" {
gist.Title = "gist:" + gist.Uuid gist.Title = "gist:" + gist.Uuid
} else { } else {
gist.Title = ctx.Request().PostForm["name"][0] gist.Title = dto.Files[0].Filename
} }
} }
if len(dto.Files) > 0 {
split := strings.Split(dto.Files[0].Content, "\n")
if len(split) > 10 {
gist.Preview = strings.Join(split[:10], "\n")
} else {
gist.Preview = dto.Files[0].Content
}
gist.PreviewFilename = dto.Files[0].Filename
}
if err = gist.InitRepository(); err != nil { if err = gist.InitRepository(); err != nil {
return ctx.ErrorRes(500, "Error creating the repository", err) return ctx.ErrorRes(500, "Error creating the repository", err)
} }
@@ -138,6 +182,9 @@ func ProcessCreate(ctx *context.Context) error {
gist.AddInIndex() gist.AddInIndex()
gist.UpdateLanguages() gist.UpdateLanguages()
if err = gist.UpdatePreviewAndCount(true); err != nil {
return ctx.ErrorRes(500, "Error updating preview and count", err)
}
return ctx.RedirectTo("/" + user.Username + "/" + gist.Identifier()) return ctx.RedirectTo("/" + user.Username + "/" + gist.Identifier())
} }

View File

@@ -7,7 +7,6 @@ import (
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
) )
func RawFile(ctx *context.Context) error { func RawFile(ctx *context.Context) error {
@@ -20,10 +19,8 @@ func RawFile(ctx *context.Context) error {
if file == nil { if file == nil {
return ctx.NotFound("File not found") return ctx.NotFound("File not found")
} }
contentType := handlers.GetContentTypeFromFilename(file.Filename) ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
ContentDisposition := handlers.GetContentDisposition(file.Filename) ctx.Response().Header().Set("Content-Disposition", "inline; filename=\""+file.Filename+"\"")
ctx.Response().Header().Set("Content-Type", contentType)
ctx.Response().Header().Set("Content-Disposition", ContentDisposition)
return ctx.PlainText(200, file.Content) return ctx.PlainText(200, file.Content)
} }
@@ -38,7 +35,7 @@ func DownloadFile(ctx *context.Context) error {
return ctx.NotFound("File not found") return ctx.NotFound("File not found")
} }
ctx.Response().Header().Set("Content-Type", "text/plain") ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+file.Filename) ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+file.Filename)
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content))) ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
_, err = ctx.Response().Write([]byte(file.Content)) _, err = ctx.Response().Write([]byte(file.Content))

View File

@@ -2,12 +2,13 @@ package gist
import ( import (
"errors" "errors"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers" "github.com/thomiceli/opengist/internal/web/handlers"
"gorm.io/gorm" "gorm.io/gorm"
"strings"
) )
func Fork(ctx *context.Context) error { func Fork(ctx *context.Context) error {
@@ -34,7 +35,7 @@ func Fork(ctx *context.Context) error {
} }
newGist := &db.Gist{ newGist := &db.Gist{
Uuid: strings.Replace(uuidGist.String(), "-", "", -1), Uuid: strings.ReplaceAll(uuidGist.String(), "-", ""),
Title: gist.Title, Title: gist.Title,
Preview: gist.Preview, Preview: gist.Preview,
PreviewFilename: gist.PreviewFilename, PreviewFilename: gist.PreviewFilename,

View File

@@ -5,12 +5,13 @@ import (
"bytes" "bytes"
gojson "encoding/json" gojson "encoding/json"
"fmt" "fmt"
"net/url"
"time"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/render" "github.com/thomiceli/opengist/internal/render"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"net/url"
"time"
) )
func GistIndex(ctx *context.Context) error { func GistIndex(ctx *context.Context) error {
@@ -34,7 +35,7 @@ func GistIndex(ctx *context.Context) error {
return ctx.ErrorRes(500, "Error fetching files", err) return ctx.ErrorRes(500, "Error fetching files", err)
} }
renderedFiles := render.HighlightFiles(files) renderedFiles := render.RenderFiles(files)
ctx.SetData("page", "code") ctx.SetData("page", "code")
ctx.SetData("commit", revision) ctx.SetData("commit", revision)
@@ -51,7 +52,7 @@ func GistJson(ctx *context.Context) error {
return ctx.ErrorRes(500, "Error fetching files", err) return ctx.ErrorRes(500, "Error fetching files", err)
} }
renderedFiles := render.HighlightFiles(files) renderedFiles := render.RenderFiles(files)
ctx.SetData("files", renderedFiles) ctx.SetData("files", renderedFiles)
topics, err := gist.GetTopics() topics, err := gist.GetTopics()
@@ -96,8 +97,10 @@ func GistJson(ctx *context.Context) error {
} }
func GistJs(ctx *context.Context) error { func GistJs(ctx *context.Context) error {
theme := "light"
if _, exists := ctx.QueryParams()["dark"]; exists { if _, exists := ctx.QueryParams()["dark"]; exists {
ctx.SetData("dark", "dark") ctx.SetData("dark", "dark")
theme = "dark"
} }
gist := ctx.GetData("gist").(*db.Gist) gist := ctx.GetData("gist").(*db.Gist)
@@ -106,7 +109,7 @@ func GistJs(ctx *context.Context) error {
return ctx.ErrorRes(500, "Error fetching files", err) return ctx.ErrorRes(500, "Error fetching files", err)
} }
renderedFiles := render.HighlightFiles(files) renderedFiles := render.RenderFiles(files)
ctx.SetData("files", renderedFiles) ctx.SetData("files", renderedFiles)
htmlbuf := bytes.Buffer{} htmlbuf := bytes.Buffer{}
@@ -116,16 +119,21 @@ func GistJs(ctx *context.Context) error {
} }
_ = w.Flush() _ = w.Flush()
cssUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), context.ManifestEntries["embed.css"].File) cssUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), context.ManifestEntries["ts/embed.ts"].Css[0])
if err != nil { if err != nil {
return ctx.ErrorRes(500, "Error joining css url", err) return ctx.ErrorRes(500, "Error joining css url", err)
} }
js, err := escapeJavaScriptContent(htmlbuf.String(), cssUrl) themeUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), context.ManifestEntries["ts/"+theme+".ts"].Css[0])
if err != nil {
return ctx.ErrorRes(500, "Error joining theme url", err)
}
js, err := escapeJavaScriptContent(htmlbuf.String(), cssUrl, themeUrl)
if err != nil { if err != nil {
return ctx.ErrorRes(500, "Error escaping JavaScript content", err) return ctx.ErrorRes(500, "Error escaping JavaScript content", err)
} }
ctx.Response().Header().Set("Content-Type", "application/javascript") ctx.Response().Header().Set("Content-Type", "text/javascript")
return ctx.PlainText(200, js) return ctx.PlainText(200, js)
} }
@@ -140,7 +148,7 @@ func Preview(ctx *context.Context) error {
return ctx.PlainText(200, previewStr) return ctx.PlainText(200, previewStr)
} }
func escapeJavaScriptContent(htmlContent, cssUrl string) (string, error) { func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string) (string, error) {
jsonContent, err := gojson.Marshal(htmlContent) jsonContent, err := gojson.Marshal(htmlContent)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to encode content: %w", err) return "", fmt.Errorf("failed to encode content: %w", err)
@@ -151,11 +159,18 @@ func escapeJavaScriptContent(htmlContent, cssUrl string) (string, error) {
return "", fmt.Errorf("failed to encode CSS URL: %w", err) return "", fmt.Errorf("failed to encode CSS URL: %w", err)
} }
jsonThemeUrl, err := gojson.Marshal(themeUrl)
if err != nil {
return "", fmt.Errorf("failed to encode Theme URL: %w", err)
}
js := fmt.Sprintf(` js := fmt.Sprintf(`
document.write('<link rel="stylesheet" href=%s>'); document.write('<link rel="stylesheet" href=%s>');
document.write('<link rel="stylesheet" href=%s>');
document.write(%s); document.write(%s);
`, `,
string(jsonCssUrl), string(jsonCssUrl),
string(jsonThemeUrl),
string(jsonContent), string(jsonContent),
) )

View File

@@ -0,0 +1,77 @@
package gist
import (
"io"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/web/context"
)
func Upload(ctx *context.Context) error {
err := ctx.Request().ParseMultipartForm(32 << 20) // 32 MB max
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), err)
}
fileHeader, err := ctx.FormFile("file")
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.no-file-uploaded"), err)
}
file, err := fileHeader.Open()
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-open-file"), err)
}
defer file.Close()
fileUUID, err := uuid.NewRandom()
if err != nil {
return ctx.ErrorRes(500, "Error generating UUID", err)
}
uploadsDir := filepath.Join(config.GetHomeDir(), "uploads")
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
return ctx.ErrorRes(500, "Error creating uploads directory", err)
}
filename := fileUUID.String()
filePath := filepath.Join(uploadsDir, filename)
destFile, err := os.Create(filePath)
if err != nil {
return ctx.ErrorRes(500, "Error creating file", err)
}
defer destFile.Close()
if _, err := io.Copy(destFile, file); err != nil {
return ctx.ErrorRes(500, "Error saving file", err)
}
return ctx.JSON(200, map[string]string{
"uuid": filename,
"filename": fileHeader.Filename,
})
}
func DeleteUpload(ctx *context.Context) error {
uuid := ctx.Param("uuid")
if uuid == "" {
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), nil)
}
uploadsDir := filepath.Join(config.GetHomeDir(), "uploads")
filePath := filepath.Join(uploadsDir, uuid)
if _, err := os.Stat(filePath); err == nil {
if err := os.Remove(filePath); err != nil {
return ctx.ErrorRes(500, "Error deleting file", err)
}
}
return ctx.JSON(200, map[string]string{
"status": "deleted",
})
}

View File

@@ -6,9 +6,6 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"github.com/thomiceli/opengist/internal/auth/password"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@@ -23,7 +20,8 @@ import (
"github.com/thomiceli/opengist/internal/auth" "github.com/thomiceli/opengist/internal/auth"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"gorm.io/gorm" "github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
) )
var routes = []struct { var routes = []struct {
@@ -45,138 +43,211 @@ var routes = []struct {
} }
func GitHttp(ctx *context.Context) error { func GitHttp(ctx *context.Context) error {
route := findMatchingRoute(ctx)
if route == nil {
return ctx.NotFound("Gist not found") // regular 404 for non-git routes
}
gist := ctx.GetData("gist").(*db.Gist)
gistExists := gist.ID != 0
isInitRoute := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs")
isInitRouteReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack")
isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$")
isPull := ctx.QueryParam("service") == "git-upload-pack" ||
strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") && !isInfoRefs
isPush := ctx.QueryParam("service") == "git-receive-pack" ||
strings.HasSuffix(ctx.Request().URL.Path, "git-receive-pack") && !isInfoRefs
repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid)
ctx.SetData("repositoryPath", repositoryPath)
allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, true)
if err != nil {
log.Fatal().Err(err).Msg("Cannot check if unauthenticated access is allowed")
}
// No need to authenticate if the user wants
// to clone/pull ; a non-private gist ; that exists ; where unauthenticated access is allowed in the instance
if isPull && gist.Private != db.PrivateVisibility && gistExists && allow {
return route.handler(ctx)
}
// Else we need to authenticate the user, that include other cases:
// - user wants to push the gist
// - user wants to clone/pull a private gist
// - user wants to clone/pull a non-private gist but unauthenticated access is not allowed
// - gist is not found ; has no right to clone/pull (obfuscation)
// - admin setting to require login is set to true
authUsername, authPassword, err := parseAuthHeader(ctx)
if err != nil {
return basicAuth(ctx)
}
// if the user wants to create a gist via the /init route
if isInitRoute || isInitRouteReceive {
var user *db.User
// check if the user has a valid account on opengist to push a gist
user, err = auth.TryAuthentication(authUsername, authPassword)
if err != nil {
var authErr auth.AuthError
if errors.As(err, &authErr) {
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return ctx.PlainText(401, "Invalid credentials")
}
return ctx.ErrorRes(500, "Authentication system error", nil)
}
if isInitRoute {
gist, err = createGist(user, "")
if err != nil {
return ctx.ErrorRes(500, "Cannot create gist", err)
}
err = db.AddInitGistToQueue(gist.ID, user.ID)
if err != nil {
return ctx.ErrorRes(500, "Cannot add inited gist to the queue", err)
}
ctx.SetData("gist", gist)
return route.handler(ctx)
} else {
gist, err = db.GetInitGistInQueueForUser(user.ID)
if err != nil {
return ctx.ErrorRes(500, "Cannot retrieve inited gist from the queue", err)
}
ctx.SetData("gist", gist)
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
return route.handler(ctx)
}
}
// if clone/pull
// check if the gist exists and if the credentials are valid
if isPull {
log.Debug().Msg("Detected git pull operation")
if !gistExists {
log.Debug().Str("authUsername", authUsername).Msg("Pulling unknown gist")
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
}
var userToCheckPermissions string
// if the user is trying to clone/pull a non-private gist while unauthenticated access is not allowed,
// check if the user has a valid account
if gist.Private != db.PrivateVisibility {
log.Debug().Str("authUsername", authUsername).Msg("Pulling non-private gist with authenticated access")
userToCheckPermissions = authUsername
} else { // else just check the password against the gist owner
log.Debug().Str("authUsername", authUsername).Str("gistOwner", gist.User.Username).Msg("Pulling private gist")
userToCheckPermissions = gist.User.Username
}
if _, err = auth.TryAuthentication(userToCheckPermissions, authPassword); err != nil {
var authErr auth.AuthError
if errors.As(err, &authErr) {
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
}
return ctx.ErrorRes(500, "Authentication system error", nil)
}
log.Debug().Str("authUsername", authUsername).Msg("Pulling gist")
return route.handler(ctx)
}
if isPush {
log.Debug().Msg("Detected git push operation")
// if gist exists, check if the credentials are valid and if the user is the gist owner
if gistExists {
log.Debug().Str("authUsername", authUsername).Str("gistOwner", gist.User.Username).Msg("Pushing to existing gist")
if _, err = auth.TryAuthentication(gist.User.Username, authPassword); err != nil {
var authErr auth.AuthError
if errors.As(err, &authErr) {
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
}
return ctx.ErrorRes(500, "Authentication system error", nil)
}
log.Debug().Str("authUsername", authUsername).Msg("Pushing gist")
return route.handler(ctx)
} else { // if the gist does not exist, check if the user has a valid account on opengist to push a gist and create it
log.Debug().Str("authUsername", authUsername).Msg("Creating new gist by pushing")
var user *db.User
if user, err = auth.TryAuthentication(authUsername, authPassword); err != nil {
var authErr auth.AuthError
if errors.As(err, &authErr) {
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
}
return ctx.ErrorRes(500, "Authentication system error", nil)
}
urlPath := ctx.Request().URL.Path
pathParts := strings.Split(strings.Trim(urlPath, "/"), "/")
if pathParts[0] == authUsername && len(pathParts) == 4 {
log.Debug().Str("authUsername", authUsername).Msg("Valid URL format for push operation")
gist, err = createGist(user, pathParts[1])
if err != nil {
return ctx.ErrorRes(500, "Cannot create gist", err)
}
log.Debug().Str("authUsername", authUsername).Str("url", urlPath).Msg("Gist created")
ctx.SetData("gist", gist)
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
} else {
log.Debug().Str("authUsername", authUsername).Any("path", pathParts).Msg("Invalid URL format for push operation")
return ctx.PlainText(401, "Invalid URL format for push operation")
}
return route.handler(ctx)
}
}
return route.handler(ctx)
}
func findMatchingRoute(ctx *context.Context) *struct {
gitUrl string
method string
handler func(ctx *context.Context) error
} {
for _, route := range routes { for _, route := range routes {
matched, _ := regexp.MatchString(route.gitUrl, ctx.Request().URL.Path) matched, _ := regexp.MatchString(route.gitUrl, ctx.Request().URL.Path)
if ctx.Request().Method == route.method && matched { if ctx.Request().Method == route.method && matched {
if !strings.HasPrefix(ctx.Request().Header.Get("User-Agent"), "git/") { if !strings.HasPrefix(ctx.Request().Header.Get("User-Agent"), "git/") {
continue continue
} }
return &route
gist := ctx.GetData("gist").(*db.Gist)
isInit := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs")
isInitReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack")
isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$")
isPull := ctx.QueryParam("service") == "git-upload-pack" ||
strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") ||
ctx.Request().Method == "GET" && !isInfoRefs
repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid)
if _, err := os.Stat(repositoryPath); os.IsNotExist(err) {
if err != nil {
log.Info().Err(err).Msg("Repository directory does not exist")
return ctx.ErrorRes(404, "Repository directory does not exist", err)
}
}
ctx.SetData("repositoryPath", repositoryPath)
allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, true)
if err != nil {
log.Fatal().Err(err).Msg("Cannot check if unauthenticated access is allowed")
}
// Shows basic auth if :
// - user wants to push the gist
// - user wants to clone/pull a private gist
// - gist is not found (obfuscation)
// - admin setting to require login is set to true
if isPull && gist.Private != db.PrivateVisibility && gist.ID != 0 && allow {
return route.handler(ctx)
}
authHeader := ctx.Request().Header.Get("Authorization")
if authHeader == "" {
return basicAuth(ctx)
}
authFields := strings.Fields(authHeader)
if len(authFields) != 2 || authFields[0] != "Basic" {
return basicAuth(ctx)
}
authUsername, authPassword, err := basicAuthDecode(authFields[1])
if err != nil {
return basicAuth(ctx)
}
if !isInit && !isInitReceive {
if gist.ID == 0 {
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
}
var userToCheckPermissions *db.User
if gist.Private != db.PrivateVisibility && isPull {
userToCheckPermissions, _ = db.GetUserByUsername(authUsername)
} else {
userToCheckPermissions = &gist.User
}
if ok, err := password.VerifyPassword(authPassword, userToCheckPermissions.Password); !ok {
if err != nil {
return ctx.ErrorRes(500, "Cannot verify password", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
}
} else {
var user *db.User
if user, err = db.GetUserByUsername(authUsername); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorRes(500, "Cannot get user", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return ctx.ErrorRes(401, "Invalid credentials", nil)
}
if ok, err := password.VerifyPassword(authPassword, user.Password); !ok {
if err != nil {
return ctx.ErrorRes(500, "Cannot check for password", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
return ctx.ErrorRes(401, "Invalid credentials", nil)
}
if isInit {
gist = new(db.Gist)
gist.UserID = user.ID
gist.User = *user
uuidGist, err := uuid.NewRandom()
if err != nil {
return ctx.ErrorRes(500, "Error creating an UUID", err)
}
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
gist.Title = "gist:" + gist.Uuid
if err = gist.InitRepository(); err != nil {
return ctx.ErrorRes(500, "Cannot init repository in the file system", err)
}
if err = gist.Create(); err != nil {
return ctx.ErrorRes(500, "Cannot init repository in database", err)
}
err = gist.SerialiseInitRepository()
if err != nil {
return ctx.ErrorRes(500, "Cannot serialise the repository", err)
}
ctx.SetData("gist", gist)
} else {
gist, err = db.DeserialiseInitRepository(user.Username)
if err != nil {
return ctx.ErrorRes(500, "Cannot deserialise the repository", err)
}
ctx.SetData("gist", gist)
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
}
}
return route.handler(ctx)
} }
} }
return ctx.NotFound("Gist not found") return nil
}
func createGist(user *db.User, url string) (*db.Gist, error) {
gist := new(db.Gist)
gist.UserID = user.ID
gist.User = *user
uuidGist, err := uuid.NewRandom()
if err != nil {
return nil, err
}
gist.Uuid = strings.ReplaceAll(uuidGist.String(), "-", "")
gist.Title = "gist:" + gist.Uuid
if url != "" {
gist.URL = strings.TrimSuffix(url, ".git")
gist.Title = strings.TrimSuffix(url, ".git")
}
if err := gist.InitRepository(); err != nil {
return nil, err
}
if err := gist.Create(); err != nil {
return nil, err
}
return gist, nil
} }
func uploadPack(ctx *context.Context) error { func uploadPack(ctx *context.Context) error {
@@ -302,6 +373,26 @@ func basicAuth(ctx *context.Context) error {
return ctx.PlainText(401, "Requires authentication") return ctx.PlainText(401, "Requires authentication")
} }
func parseAuthHeader(ctx *context.Context) (string, string, error) {
authHeader := ctx.Request().Header.Get("Authorization")
if authHeader == "" {
return "", "", errors.New("no auth header")
}
authFields := strings.Fields(authHeader)
if len(authFields) != 2 || authFields[0] != "Basic" {
return "", "", errors.New("invalid auth header")
}
authUsername, authPassword, err := basicAuthDecode(authFields[1])
if err != nil {
log.Error().Err(err).Msg("Cannot decode basic auth header")
return "", "", err
}
return authUsername, authPassword, nil
}
func basicAuthDecode(encoded string) (string, string, error) { func basicAuthDecode(encoded string) (string, string, error) {
s, err := base64.StdEncoding.DecodeString(encoded) s, err := base64.StdEncoding.DecodeString(encoded)
if err != nil { if err != nil {

View File

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

View File

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

View File

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

View File

@@ -5,13 +5,19 @@ import (
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
) )
func UserSettings(ctx *context.Context) error { func UserAccount(ctx *context.Context) error {
user := ctx.User user := ctx.User
keys, err := db.GetSSHKeysByUserID(user.ID) ctx.SetData("email", user.Email)
if err != nil { ctx.SetData("hasPassword", user.Password != "")
return ctx.ErrorRes(500, "Cannot get SSH keys", err) ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
} ctx.SetData("settingsHeaderPage", "account")
ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_account.html")
}
func UserMFA(ctx *context.Context) error {
user := ctx.User
passkeys, err := db.GetAllCredentialsForUser(user.ID) passkeys, err := db.GetAllCredentialsForUser(user.ID)
if err != nil { if err != nil {
@@ -23,12 +29,48 @@ func UserSettings(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot get MFA status", err) return ctx.ErrorRes(500, "Cannot get MFA status", err)
} }
ctx.SetData("email", user.Email)
ctx.SetData("sshKeys", keys)
ctx.SetData("passkeys", passkeys) ctx.SetData("passkeys", passkeys)
ctx.SetData("hasTotp", hasTotp) ctx.SetData("hasTotp", hasTotp)
ctx.SetData("hasPassword", user.Password != "") ctx.SetData("settingsHeaderPage", "mfa")
ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
ctx.SetData("htmlTitle", ctx.TrH("settings")) ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings.html") return ctx.Html("settings_mfa.html")
}
func UserSSHKeys(ctx *context.Context) error {
user := ctx.User
keys, err := db.GetSSHKeysByUserID(user.ID)
if err != nil {
return ctx.ErrorRes(500, "Cannot get SSH keys", err)
}
ctx.SetData("sshKeys", keys)
ctx.SetData("settingsHeaderPage", "ssh")
ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_ssh.html")
}
func UserStyle(ctx *context.Context) error {
ctx.SetData("settingsHeaderPage", "style")
ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_style.html")
}
func ProcessUserStyle(ctx *context.Context) error {
styleDto := new(db.UserStyleDTO)
if err := ctx.Bind(styleDto); err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
}
if err := ctx.Validate(styleDto); err != nil {
return ctx.ErrorRes(400, "Invalid data", err)
}
user := ctx.User
user.StylePreferences = styleDto.ToJson()
if err := user.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot update user styles", err)
}
ctx.AddFlash("Updated style", "success")
return ctx.RedirectTo("/settings/style")
} }

View File

@@ -20,7 +20,7 @@ func SshKeysProcess(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil { if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/ssh")
} }
key := dto.ToSSHKey() key := dto.ToSSHKey()
@@ -29,7 +29,7 @@ func SshKeysProcess(ctx *context.Context) error {
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content)) pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
if err != nil { if err != nil {
ctx.AddFlash(ctx.Tr("flash.user.invalid-ssh-key"), "error") ctx.AddFlash(ctx.Tr("flash.user.invalid-ssh-key"), "error")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/ssh")
} }
key.Content = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))) key.Content = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey)))
@@ -38,7 +38,7 @@ func SshKeysProcess(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot check if SSH key exists", err) return ctx.ErrorRes(500, "Cannot check if SSH key exists", err)
} }
ctx.AddFlash(ctx.Tr("settings.ssh-key-exists"), "error") ctx.AddFlash(ctx.Tr("settings.ssh-key-exists"), "error")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/ssh")
} }
if err := key.Create(); err != nil { if err := key.Create(); err != nil {
@@ -46,20 +46,20 @@ func SshKeysProcess(ctx *context.Context) error {
} }
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success") ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/ssh")
} }
func SshKeysDelete(ctx *context.Context) error { func SshKeysDelete(ctx *context.Context) error {
user := ctx.User user := ctx.User
keyId, err := strconv.Atoi(ctx.Param("id")) keyId, err := strconv.Atoi(ctx.Param("id"))
if err != nil { if err != nil {
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/ssh")
} }
key, err := db.GetSSHKeyByID(uint(keyId)) key, err := db.GetSSHKeyByID(uint(keyId))
if err != nil || key.UserID != user.ID { if err != nil || key.UserID != user.ID {
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/ssh")
} }
if err := key.Delete(); err != nil { if err := key.Delete(); err != nil {
@@ -67,5 +67,5 @@ func SshKeysDelete(ctx *context.Context) error {
} }
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success") ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success")
return ctx.RedirectTo("/settings") return ctx.RedirectTo("/settings/ssh")
} }

View File

@@ -4,7 +4,6 @@ import (
"errors" "errors"
"html/template" "html/template"
"net/url" "net/url"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -141,22 +140,3 @@ func ParseSearchQueryStr(query string) (string, map[string]string) {
content := strings.TrimSpace(contentBuilder.String()) content := strings.TrimSpace(contentBuilder.String())
return content, metadata return content, metadata
} }
func GetContentTypeFromFilename(filename string) (ret string) {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".css":
ret = "text/css"
default:
ret = "text/plain"
}
// add charset=utf-8, if not, unicode charset will be broken
ret += "; charset=utf-8"
return
}
func GetContentDisposition(filename string) string {
return "inline; filename=\"" + filename + "\""
}

View File

@@ -3,6 +3,14 @@ package server
import ( import (
"errors" "errors"
"fmt" "fmt"
"html/template"
"net/http"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"github.com/labstack/echo-contrib/echoprometheus" "github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
@@ -15,12 +23,6 @@ import (
"github.com/thomiceli/opengist/internal/web/handlers" "github.com/thomiceli/opengist/internal/web/handlers"
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
"html/template"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
) )
func (s *Server) useCustomContext() { func (s *Server) useCustomContext() {
@@ -36,8 +38,7 @@ func (s *Server) registerMiddlewares() {
s.echo.Use(Middleware(dataInit).toEcho()) s.echo.Use(Middleware(dataInit).toEcho())
s.echo.Use(Middleware(locale).toEcho()) s.echo.Use(Middleware(locale).toEcho())
if config.C.MetricsEnabled { if config.C.MetricsEnabled {
p := echoprometheus.NewMiddleware("opengist") s.echo.Use(echoprometheus.NewMiddleware("opengist"))
s.echo.Use(p)
} }
s.echo.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{ s.echo.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{
@@ -54,7 +55,7 @@ func (s *Server) registerMiddlewares() {
return nil return nil
}, },
})) }))
//s.echo.Use(middleware.Recover()) s.echo.Use(middleware.Recover())
s.echo.Use(middleware.Secure()) s.echo.Use(middleware.Secure())
s.echo.Use(Middleware(sessionInit).toEcho()) s.echo.Use(Middleware(sessionInit).toEcho())
@@ -90,21 +91,33 @@ func (s *Server) errorHandler(err error, ctx echo.Context) {
data["error"] = err data["error"] = err
if acceptJson { if acceptJson {
if err := ctx.JSON(httpErr.Code, httpErr); err != nil { if err := ctx.JSON(httpErr.Code, httpErr); err != nil {
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
return
}
log.Fatal().Err(err).Send() log.Fatal().Err(err).Send()
} }
return return
} }
if err := ctx.Render(httpErr.Code, "error", data); err != nil { if err := ctx.Render(httpErr.Code, "error", data); err != nil {
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
return
}
log.Fatal().Err(err).Send() log.Fatal().Err(err).Send()
} }
return return
} }
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
return
}
log.Error().Err(err).Send() log.Error().Err(err).Send()
httpErr = echo.NewHTTPError(http.StatusInternalServerError, err.Error()) httpErr = echo.NewHTTPError(http.StatusInternalServerError, err.Error())
data["error"] = httpErr data["error"] = httpErr
if err := ctx.Render(500, "error", data); err != nil { if err := ctx.Render(500, "error", data); err != nil {
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
return
}
log.Fatal().Err(err).Send() log.Fatal().Err(err).Send()
} }
} }
@@ -275,6 +288,7 @@ func sessionInit(next Handler) Handler {
if user != nil { if user != nil {
ctx.User = user ctx.User = user
ctx.SetData("userLogged", user) ctx.SetData("userLogged", user)
ctx.SetData("currentStyle", user.GetStyle())
} }
return next(ctx) return next(ctx)
} }
@@ -312,6 +326,40 @@ func loadSettings(ctx *context.Context) error {
return nil return nil
} }
// getUserByToken checks the Authorization header for token-based auth.
// Expects format: Authorization: Token <token>
// Returns the user if the token is valid and has gist read permission, nil otherwise.
func getUserByToken(ctx *context.Context) *db.User {
authHeader := ctx.Request().Header.Get("Authorization")
if authHeader == "" {
return nil
}
if !strings.HasPrefix(authHeader, "Token ") {
return nil
}
plainToken := strings.TrimPrefix(authHeader, "Token ")
accessToken, err := db.GetAccessTokenByToken(plainToken)
if err != nil {
return nil
}
if accessToken.IsExpired() {
return nil
}
if !accessToken.HasGistReadPermission() {
return nil
}
// Update last used timestamp
_ = accessToken.UpdateLastUsed()
return &accessToken.User
}
func gistInit(next Handler) Handler { func gistInit(next Handler) Handler {
return func(ctx *context.Context) error { return func(ctx *context.Context) error {
currUser := ctx.User currUser := ctx.User
@@ -338,7 +386,12 @@ func gistInit(next Handler) Handler {
if gist.Private == db.PrivateVisibility { if gist.Private == db.PrivateVisibility {
if currUser == nil || currUser.ID != gist.UserID { if currUser == nil || currUser.ID != gist.UserID {
return ctx.NotFound("Gist not found") // Check for token-based auth via Authorization header
if tokenUser := getUserByToken(ctx); tokenUser != nil && tokenUser.ID == gist.UserID {
// Token is valid and belongs to gist owner, allow access
} else {
return ctx.NotFound("Gist not found")
}
} }
} }

View File

@@ -4,16 +4,6 @@ import (
gojson "encoding/json" gojson "encoding/json"
"errors" "errors"
"fmt" "fmt"
"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/git"
"github.com/thomiceli/opengist/internal/index"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
"github.com/thomiceli/opengist/public"
"github.com/thomiceli/opengist/templates"
htmlpkg "html" htmlpkg "html"
"html/template" "html/template"
"io" "io"
@@ -24,6 +14,17 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/dustin/go-humanize"
"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/index"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
"github.com/thomiceli/opengist/public"
"github.com/thomiceli/opengist/templates"
) )
type Template struct { type Template struct {
@@ -58,23 +59,8 @@ func (s *Server) setFuncMap() {
"isMarkdown": func(i string) bool { "isMarkdown": func(i string) bool {
return strings.ToLower(filepath.Ext(i)) == ".md" return strings.ToLower(filepath.Ext(i)) == ".md"
}, },
"isCsv": func(i string) bool { "isJupyter": func(i string) bool {
return strings.ToLower(filepath.Ext(i)) == ".csv" return strings.ToLower(filepath.Ext(i)) == ".ipynb"
},
"isSvg": func(i string) bool {
return strings.ToLower(filepath.Ext(i)) == ".svg"
},
"csvFile": func(file *git.File) *git.CsvFile {
if strings.ToLower(filepath.Ext(file.Filename)) != ".csv" {
return nil
}
csvFile, err := git.ParseCsv(file)
if err != nil {
return nil
}
return csvFile
}, },
"httpStatusText": http.StatusText, "httpStatusText": http.StatusText,
"loadedTime": func(startTime time.Time) string { "loadedTime": func(startTime time.Time) string {
@@ -106,6 +92,12 @@ func (s *Server) setFuncMap() {
} }
return config.C.ExternalUrl + "/" + context.ManifestEntries[file].File return config.C.ExternalUrl + "/" + context.ManifestEntries[file].File
}, },
"assetCss": func(file string) string {
if s.dev {
return "http://localhost:16157/" + file
}
return config.C.ExternalUrl + "/" + context.ManifestEntries[file].Css[0]
},
"custom": func(file string) string { "custom": func(file string) string {
assetpath, err := url.JoinPath("/", "assets", file) assetpath, err := url.JoinPath("/", "assets", file)
if err != nil { if err != nil {
@@ -186,6 +178,34 @@ func (s *Server) setFuncMap() {
} }
return str return str
}, },
"hexToRgb": func(hex string) string {
h, _ := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 32)
return fmt.Sprintf("%d, %d, %d,", (h>>16)&0xFF, (h>>8)&0xFF, h&0xFF)
},
"humanTimeDiff": func(t int64) string {
return humanize.Time(time.Unix(t, 0))
},
"humanTimeDiffStr": func(timestamp string) string {
t, _ := strconv.ParseInt(timestamp, 10, 64)
return humanize.Time(time.Unix(t, 0))
},
"humanDate": func(t int64) string {
return time.Unix(t, 0).Format("02/01/2006 15:04")
},
"humanDateOnly": func(t int64) string {
return time.Unix(t, 0).Format("02/01/2006")
},
"mainTheme": func(theme *db.UserStyleDTO) string {
if theme == nil {
return "auto"
}
if theme.Theme == "" {
return "auto"
}
return theme.Theme
},
} }
t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html")) t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html"))
@@ -206,7 +226,7 @@ func (s *Server) setFuncMap() {
} }
func (s *Server) parseManifestEntries() { func (s *Server) parseManifestEntries() {
file, err := public.Files.Open("manifest.json") file, err := public.Files.Open(".vite/manifest.json")
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to open manifest.json") log.Fatal().Err(err).Msg("Failed to open manifest.json")
} }

View File

@@ -17,7 +17,6 @@ import (
"github.com/thomiceli/opengist/internal/web/handlers/gist" "github.com/thomiceli/opengist/internal/web/handlers/gist"
"github.com/thomiceli/opengist/internal/web/handlers/git" "github.com/thomiceli/opengist/internal/web/handlers/git"
"github.com/thomiceli/opengist/internal/web/handlers/health" "github.com/thomiceli/opengist/internal/web/handlers/health"
"github.com/thomiceli/opengist/internal/web/handlers/metrics"
"github.com/thomiceli/opengist/internal/web/handlers/settings" "github.com/thomiceli/opengist/internal/web/handlers/settings"
"github.com/thomiceli/opengist/public" "github.com/thomiceli/opengist/public"
) )
@@ -29,13 +28,11 @@ func (s *Server) registerRoutes() {
r.GET("/", gist.Create, logged) r.GET("/", gist.Create, logged)
r.POST("/", gist.ProcessCreate, logged) r.POST("/", gist.ProcessCreate, logged)
r.POST("/preview", gist.Preview, logged) r.POST("/preview", gist.Preview, logged)
r.POST("/upload", gist.Upload, logged)
r.DELETE("/upload/:uuid", gist.DeleteUpload, logged)
r.GET("/healthcheck", health.Healthcheck) r.GET("/healthcheck", health.Healthcheck)
if config.C.MetricsEnabled {
r.GET("/metrics", metrics.Metrics)
}
r.GET("/register", auth.Register) r.GET("/register", auth.Register)
r.POST("/register", auth.ProcessRegister) r.POST("/register", auth.ProcessRegister)
r.GET("/login", auth.Login) r.GET("/login", auth.Login)
@@ -56,11 +53,18 @@ func (s *Server) registerRoutes() {
sA := r.SubGroup("/settings") sA := r.SubGroup("/settings")
{ {
sA.Use(logged) sA.Use(logged)
sA.GET("", settings.UserSettings) sA.GET("", settings.UserAccount)
sA.GET("/mfa", settings.UserMFA)
sA.GET("/ssh", settings.UserSSHKeys)
sA.GET("/style", settings.UserStyle)
sA.POST("/style", settings.ProcessUserStyle)
sA.POST("/email", settings.EmailProcess) sA.POST("/email", settings.EmailProcess)
sA.DELETE("/account", settings.AccountDeleteProcess) sA.DELETE("/account", settings.AccountDeleteProcess)
sA.POST("/ssh-keys", settings.SshKeysProcess) sA.POST("/ssh-keys", settings.SshKeysProcess)
sA.DELETE("/ssh-keys/:id", settings.SshKeysDelete) sA.DELETE("/ssh-keys/:id", settings.SshKeysDelete)
sA.GET("/access-tokens", settings.AccessTokens)
sA.POST("/access-tokens", settings.AccessTokensProcess)
sA.DELETE("/access-tokens/:id", settings.AccessTokensDelete)
sA.DELETE("/passkeys/:id", settings.PasskeyDelete) sA.DELETE("/passkeys/:id", settings.PasskeyDelete)
sA.PUT("/password", settings.PasswordProcess) sA.PUT("/password", settings.PasswordProcess)
sA.PUT("/username", settings.UsernameProcess) sA.PUT("/username", settings.UsernameProcess)

View File

@@ -1,8 +1,14 @@
package server package server
import ( import (
"fmt"
"github.com/thomiceli/opengist/internal/validator" "github.com/thomiceli/opengist/internal/validator"
"net"
"net/http" "net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -45,7 +51,19 @@ func NewServer(isDev bool, sessionsPath string, ignoreCsrf bool) *Server {
return s return s
} }
func isSocketPath(host string) bool {
return strings.Contains(host, "/") || strings.Contains(host, "\\")
}
func (s *Server) Start() { func (s *Server) Start() {
if isSocketPath(config.C.HttpHost) {
s.startUnixSocket()
} else {
s.startHTTP()
}
}
func (s *Server) startHTTP() {
addr := config.C.HttpHost + ":" + config.C.HttpPort addr := config.C.HttpHost + ":" + config.C.HttpPort
log.Info().Msg("Starting HTTP server on http://" + addr) log.Info().Msg("Starting HTTP server on http://" + addr)
@@ -54,12 +72,106 @@ func (s *Server) Start() {
} }
} }
func (s *Server) startUnixSocket() {
socketPath := config.C.HttpHost
if socketPath == "" {
socketPath = "/tmp/opengist.sock"
}
if dir := filepath.Dir(socketPath); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Warn().Err(err).Str("dir", dir).Msg("Failed to create socket directory")
}
}
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
log.Warn().Err(err).Str("socket", socketPath).Msg("Failed to remove existing socket file")
}
pidPath := strings.TrimSuffix(socketPath, filepath.Ext(socketPath)) + ".pid"
if err := s.createPidFile(pidPath); err != nil {
log.Warn().Err(err).Str("pid-file", pidPath).Msg("Failed to create PID file")
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatal().Err(err).Msg("Failed to start Unix socket server")
}
s.echo.Listener = listener
if config.C.UnixSocketPermissions != "" {
if perm, err := strconv.ParseUint(config.C.UnixSocketPermissions, 8, 32); err == nil {
if err := os.Chmod(socketPath, os.FileMode(perm)); err != nil {
log.Warn().Err(err).Str("socket", socketPath).Str("permissions", config.C.UnixSocketPermissions).Msg("Failed to set socket permissions")
}
} else {
log.Warn().Err(err).Str("permissions", config.C.UnixSocketPermissions).Msg("Invalid socket permissions format")
}
}
log.Info().Str("socket", socketPath).Msg("Starting Unix socket server")
log.Info().Str("pid-file", pidPath).Msg("PID file created")
server := new(http.Server)
if err := s.echo.StartServer(server); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("Failed to start Unix socket server")
}
}
func (s *Server) Stop() { func (s *Server) Stop() {
if isSocketPath(config.C.HttpHost) {
s.stopUnixSocket()
} else {
s.stopHTTP()
}
}
func (s *Server) stopHTTP() {
log.Info().Msg("Stopping HTTP server...")
if err := s.echo.Close(); err != nil { if err := s.echo.Close(); err != nil {
log.Fatal().Err(err).Msg("Failed to stop HTTP server") log.Fatal().Err(err).Msg("Failed to stop HTTP server")
} }
} }
func (s *Server) stopUnixSocket() {
log.Info().Msg("Stopping Unix socket server...")
var socketPath string
if s.echo.Listener != nil {
if unixListener, ok := s.echo.Listener.(*net.UnixListener); ok {
socketPath = unixListener.Addr().String()
}
}
if err := s.echo.Close(); err != nil {
log.Error().Err(err).Msg("Failed to stop Unix socket server")
}
if socketPath != "" {
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
log.Error().Err(err).Str("socket", socketPath).Msg("Failed to remove socket file")
} else {
log.Info().Str("socket", socketPath).Msg("Socket file removed")
}
pidPath := strings.TrimSuffix(socketPath, filepath.Ext(socketPath)) + ".pid"
if err := os.Remove(pidPath); err != nil && !os.IsNotExist(err) {
log.Error().Err(err).Str("pid-file", pidPath).Msg("Failed to remove PID file")
} else {
log.Info().Str("pid-file", pidPath).Msg("PID file removed")
}
}
}
func (s *Server) createPidFile(pidPath string) error {
pid := os.Getpid()
pidContent := fmt.Sprintf("%d\n", pid)
if err := os.WriteFile(pidPath, []byte(pidContent), 0644); err != nil {
return err
}
return nil
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.echo.ServeHTTP(w, r) s.echo.ServeHTTP(w, r)
} }

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