Compare commits

...

147 Commits

Author SHA1 Message Date
Thomas cac21689cf Rootless docker (#716)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-20 01:54:30 +08:00
Thomas 28736d6b66 Gist expiration + scheduled actions (#726)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-20 01:28:48 +08:00
Marcel Herrguth 499f9c67b9 Fixes #723 - Add no-store header for embedded js (#724) 2026-06-20 01:28:32 +08:00
Marcel Herrguth 9d9b54a5e1 Embedding light dark and auto (#718) 2026-06-20 01:07:22 +08:00
Marcel Herrguth 1ba588c90e Closes #479 - Support Syntax highlighting for Salesforce Apex language (#725)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
* Closes #479 - Support Syntax highlighting for Salesforce Apex language

* Linter
2026-06-19 04:27:34 +08:00
Thomas a2e4734e36 GitHub alerts in Markdown (#721)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-19 00:07:06 +08:00
Thomas 66f2793f8b Add PKCE for OAuth providers (#720)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
* PKCE

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-11 02:18:33 +08:00
Thomas Miceli 2bba402787 v1.13.1
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-10 02:25:21 +07:00
Thomas daeeed3dc5 Fix CSS url for json embed url (#715)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-10 02:14:42 +07:00
Marcel Herrguth 06708eb351 Embedding fix vertical scrolling and improve padding (#714)
* Maintenance: Only scroll code vertically

* Improve code padding
2026-06-10 01:02:54 +08:00
Thomas Miceli b41a80a335 v1.13.0
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-09 04:19:30 +07:00
Thomas b43789943a Upgrade deps (#713)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-09 04:57:30 +08:00
Marcel Herrguth bf3257faa8 Feature: Allow embedding Gists for a certain file only (#709)
* Feature: Allow embedding Gists for a certain file only

* Move from URL to param approach

* Switch gist.Files to gist.File

* Satisfy linting
2026-06-09 03:44:39 +08:00
Peter Barr 2946de2505 fix: apply topics push option in post-receive hook (#698)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
* Apply `topics` push option in post-receive hook

The `topics` value was parsed into the push-options map but never read
by the hook, silently discarding any topics passed via `git push -o
topics=...`. Mirror the existing `title`/`description` handling and
reuse the `gisttopics` validator.

* Docs

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

---------

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
Co-authored-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-08 04:20:50 +08:00
dependabot[bot] c1ca19aec9 Bump azure/setup-helm from 4.3.1 to 5.0.0 (#688)
Bumps [azure/setup-helm](https://github.com/azure/setup-helm) from 4.3.1 to 5.0.0.
- [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.1...v5.0.0)

---
updated-dependencies:
- dependency-name: azure/setup-helm
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 04:05:34 +08:00
Thomas 34e5a16a26 Require login + api (#711)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-08 03:28:49 +08:00
Thomas 31bc25e569 Beautiful docs website (#710)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-08 03:15:24 +08:00
Thomas 3b8d947ad8 Fix SSH key generation (#708)
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-04 02:09:26 +08:00
Thomas 8e462397f4 API for gists and users (#707)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-06-03 02:26:17 +08:00
KhaledMahfouz5 5c23d7feed Adding Arabic Translation (#706) 2026-06-03 02:21:29 +08:00
awkj 690f151592 feat: add REST API v1 with admin toggle and OpenAPI spec (#702)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
Adds an HTTP REST API under /api/v1 with bearer-token auth, served
alongside the existing web UI. The API exposes seven endpoints:

  GET    /api/v1/user
  GET    /api/v1/gists
  POST   /api/v1/gists
  GET    /api/v1/gists/:uuid
  PATCH  /api/v1/gists/:uuid
  DELETE /api/v1/gists/:uuid
  GET    /api/v1/gists/:uuid/files/:filename/raw

Auth reuses the existing AccessToken model (extended with a new
ScopeUser permission for /user) and accepts both 'Authorization: Bearer
og_...' and the legacy 'Token og_...' header.

The API is gated by a new 'api-enabled' admin setting (default off) so
deployments must explicitly opt in from Admin Panel → Configuration.
When disabled, requests get a 503 with an actionable hint, and the
access-token settings page shows a banner pointing admins straight to
the toggle.

An embedded OpenAPI 3.1 spec is served at GET /api/v1/openapi.yaml;
import it into Postman / Insomnia / Bruno / openapi-generator for
interactive testing or client generation.

Other UX polish bundled in:
- Settings access-token form now exposes the new User scope and uses
  the existing locale.Tr lookups (translations backfilled for zh-CN).
- Language switcher distinguishes 简体中文 vs 繁體中文 instead of both
  appearing as '中文'.
- Settings page header tabs translated for zh-CN.
- POST /gists cleans up the on-disk repo when commit/create fails
  (previously orphaned).
- visibility=public list excludes private/unlisted gists (regression
  guard added).
- Content-Disposition filename in raw responses is RFC-escaped.

Docs: docs/usage/api.md walks through enabling the API, creating a
token, the seven endpoints with curl examples, error codes and v1
limitations.
2026-05-22 04:12:33 +08:00
Thomas Miceli 8da72b9545 Limit display if there is too much files in one gist (#701)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-05-10 03:28:02 +08:00
Lukas Gierth f3c38ddbbb chore: bump helm chart version to 0.7.0 (#696)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
Co-authored-by: Lukas Gierth <lukas.gierth@ta-systeme.com>
2026-04-30 22:47:18 +08:00
Thomas Miceli 8a6f2d82ff v1.12.2
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
2026-04-28 16:37:03 +07:00
John71 bfd75a9d58 fix(Gitea): Field avatar_url not found in Gitea JSON response by using gothic provided user data and removing our API call (#674)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
Co-authored-by: John71 <john71@noreply.git.john71.fr>
2026-04-26 19:45:26 +08:00
dependabot[bot] eef6029c95 Bump softprops/action-gh-release from 2 to 3 (#691)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

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

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

* feat: add env and envFromSecrets to deplyoment container
2026-04-26 18:52:21 +08:00
dependabot[bot] ec26888487 Bump github.com/labstack/echo-contrib from 0.17.4 to 0.18.0 (#667)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
Bumps [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib) from 0.17.4 to 0.18.0.
- [Release notes](https://github.com/labstack/echo-contrib/releases)
- [Commits](https://github.com/labstack/echo-contrib/compare/v0.17.4...v0.18.0)

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Anonymous <noreply@weblate.org>
2026-03-13 11:17:23 +08:00
Thomas Miceli e91139d3ec Improve code search + tests (#663)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
Co-authored-by: Qiang Zhou <zhouqiang.loaded@bytedance.com>
Co-authored-by: theodoruszq <theodoruszq@gmail.com>
2026-03-13 11:16:10 +08:00
Webysther Sperandio 279da52899 feat: search all fields (#622)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
*  feat(search): search all feature

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

* Fix test

* Set content by default

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

* Config to define default searchable fields

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Currently translated at 98.1% (310 of 316 strings)

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

* Translated using Weblate (Italian)

Currently translated at 99.3% (318 of 320 strings)

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

---------

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 09:24:33 +08:00
dependabot[bot] b2161d8859 Bump github.com/meilisearch/meilisearch-go from 0.35.0 to 0.35.1 (#591)
Bumps [github.com/meilisearch/meilisearch-go](https://github.com/meilisearch/meilisearch-go) from 0.35.0 to 0.35.1.
- [Release notes](https://github.com/meilisearch/meilisearch-go/releases)
- [Commits](https://github.com/meilisearch/meilisearch-go/compare/v0.35.0...v0.35.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 09:24:23 +08:00
dependabot[bot] 61bb22ebe9 Bump github.com/yuin/goldmark from 1.7.13 to 1.7.15 (#592)
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.13 to 1.7.15.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.13...v1.7.15)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-version: 1.7.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 09:24:14 +08:00
dependabot[bot] 6813c14e3a Bump github.com/labstack/echo/v4 from 4.14.0 to 4.15.0 (#590)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.14.0 to 4.15.0.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.14.0...v4.15.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 09:24:05 +08:00
Guillem Riera Galmés 4ae25144a0 Adds StatefulSet support (#549)
* Adds StatefulSet support

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

* Adds statefulset support for replicaCount gt 1

* Improves the setup of multiple replicas in a stateful set

* Adds config wrangling logic to the secret template

* Adds shared PV functionality

* Adds missing pvc-shared template

* Adds stateful set and documentation

---------

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

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

* fix base image deps

---------

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 13:34:08 +08:00
dependabot[bot] 5fe6238da1 Bump github.com/labstack/echo/v4 from 4.13.4 to 4.14.0 (#584)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.13.4 to 4.14.0.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.13.4...v4.14.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 13:33:43 +08:00
dependabot[bot] f4e472a77b Bump @tailwindcss/forms from 0.5.10 to 0.5.11 (#583)
Bumps [@tailwindcss/forms](https://github.com/tailwindlabs/tailwindcss-forms) from 0.5.10 to 0.5.11.
- [Release notes](https://github.com/tailwindlabs/tailwindcss-forms/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss-forms/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss-forms/compare/v0.5.10...v0.5.11)

---
updated-dependencies:
- dependency-name: "@tailwindcss/forms"
  dependency-version: 0.5.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 13:31:52 +08:00
dependabot[bot] 4350a66afd Bump github.com/alecthomas/chroma/v2 from 2.20.0 to 2.21.1 (#582)
Bumps [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) from 2.20.0 to 2.21.1.
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.20.0...v2.21.1)

---
updated-dependencies:
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-version: 2.21.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 13:31:36 +08:00
dependabot[bot] 8a958de3d7 Bump github.com/go-webauthn/webauthn from 0.14.0 to 0.15.0 (#585)
Bumps [github.com/go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) from 0.14.0 to 0.15.0.
- [Release notes](https://github.com/go-webauthn/webauthn/releases)
- [Commits](https://github.com/go-webauthn/webauthn/compare/v0.14.0...v0.15.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 13:31:09 +08:00
dependabot[bot] 871cb356b7 Bump @tailwindcss/vite from 4.1.14 to 4.1.18 (#586)
Bumps [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) from 4.1.14 to 4.1.18.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/packages/@tailwindcss-vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 13:30:34 +08:00
dependabot[bot] 0958e80d8e Bump marked from 16.4.1 to 17.0.1 (#581)
Bumps [marked](https://github.com/markedjs/marked) from 16.4.1 to 17.0.1.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v16.4.1...v17.0.1)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 17.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 13:30:01 +08:00
dependabot[bot] cc27899b6c Bump gorm.io/gorm from 1.31.0 to 1.31.1 (#580)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.31.0 to 1.31.1.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.31.0...v1.31.1)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-version: 1.31.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 13:28:34 +08:00
dependabot[bot] 256da0077a Bump @codemirror/language from 6.11.3 to 6.12.1 (#579)
Bumps [@codemirror/language](https://github.com/codemirror/language) from 6.11.3 to 6.12.1.
- [Changelog](https://github.com/codemirror/language/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/language/compare/6.11.3...6.12.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 13:27:59 +08:00
dependabot[bot] 0e5007dbad Bump nodemon from 3.1.10 to 3.1.11 (#578)
Bumps [nodemon](https://github.com/remy/nodemon) from 3.1.10 to 3.1.11.
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.10...v3.1.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 03:34:50 +08:00
dependabot[bot] 91de091874 Bump actions/checkout from 5 to 6 (#560)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 03:30:08 +08:00
dependabot[bot] 07bdf983af Bump golangci/golangci-lint-action from 8 to 9 (#557)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8 to 9.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 03:29:45 +08:00
dependabot[bot] a5907c313c Bump @codemirror/state from 6.5.2 to 6.5.3 (#566)
Bumps [@codemirror/state](https://github.com/codemirror/state) from 6.5.2 to 6.5.3.
- [Changelog](https://github.com/codemirror/state/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/state/compare/6.5.2...6.5.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-28 03:28:40 +08:00
dependabot[bot] dc0b429121 Bump github.com/go-playground/validator/v10 from 10.28.0 to 10.30.1 (#568)
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.28.0 to 10.30.1.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.28.0...v10.30.1)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 21:55:44 +08:00
dependabot[bot] b2373109b8 Bump tailwindcss from 4.1.14 to 4.1.18 (#569)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.1.14 to 4.1.18.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 21:55:26 +08:00
dependabot[bot] 0a106b27db Bump github.com/gabriel-vasile/mimetype from 1.4.10 to 1.4.12 (#570)
Bumps [github.com/gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype) from 1.4.10 to 1.4.12.
- [Release notes](https://github.com/gabriel-vasile/mimetype/releases)
- [Commits](https://github.com/gabriel-vasile/mimetype/compare/v1.4.10...v1.4.12)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 21:55:07 +08:00
dependabot[bot] f10d656355 Bump katex from 0.16.23 to 0.16.27 (#571)
Bumps [katex](https://github.com/KaTeX/KaTeX) from 0.16.23 to 0.16.27.
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.23...v0.16.27)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 21:54:44 +08:00
dependabot[bot] fe211b949b Bump @codemirror/view from 6.38.5 to 6.39.7 (#572)
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.38.5 to 6.39.7.
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.38.5...6.39.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 21:54:11 +08:00
dependabot[bot] a5778e77eb Bump github.com/blevesearch/bleve/v2 from 2.5.3 to 2.5.7 (#573)
Bumps [github.com/blevesearch/bleve/v2](https://github.com/blevesearch/bleve) from 2.5.3 to 2.5.7.
- [Release notes](https://github.com/blevesearch/bleve/releases)
- [Commits](https://github.com/blevesearch/bleve/compare/v2.5.3...v2.5.7)

---
updated-dependencies:
- dependency-name: github.com/blevesearch/bleve/v2
  dependency-version: 2.5.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 21:53:53 +08:00
dependabot[bot] f24c78d0a2 Bump golang.org/x/crypto from 0.42.0 to 0.46.0 (#574)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.46.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.46.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 21:53:28 +08:00
dependabot[bot] 34bd7bec20 Bump vite from 7.1.9 to 7.3.0 (#575)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.9 to 7.3.0.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.0/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.0/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 21:41:33 +08:00
Thomas Miceli 4d6809bc2d Feat/fix test (#577) 2025-12-27 21:29:52 +08:00
Thomas Miceli a493de4325 quick fix test (#576) 2025-12-27 20:50:15 +08:00
dependabot[bot] a67c80d148 Bump marked from 16.4.0 to 16.4.1 (#544)
Bumps [marked](https://github.com/markedjs/marked) from 16.4.0 to 16.4.1.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Changelog](https://github.com/markedjs/marked/blob/master/.releaserc.json)
- [Commits](https://github.com/markedjs/marked/compare/v16.4.0...v16.4.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 20:26:40 +08:00
dependabot[bot] feac9dcb66 Bump actions/setup-node from 5 to 6 (#545)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 20:26:20 +08:00
dependabot[bot] 38024310df Bump golang.org/x/text from 0.29.0 to 0.30.0 (#533)
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.29.0 to 0.30.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.29.0...v0.30.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 20:25:51 +08:00
Sebastian Ertz 9512ba84b0 Fix indentation and newline at eof (#564) 2025-12-27 20:24:30 +08:00
Thomas Miceli b11306851b Fuzzy search + tests (#555) 2025-12-26 22:36:28 +08:00
Thomas Miceli 3957dfb3ea Add some tests (#553)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, windows-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled
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
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.23, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.23, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.23, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.23, ubuntu-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.23, windows-latest) (push) Has been cancelled
Go CI / Build (1.23, macOS-latest) (push) Has been cancelled
Go CI / Build (1.23, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.23, windows-latest) (push) Has been cancelled
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
268 changed files with 24468 additions and 9069 deletions
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
+21 -27
View File
@@ -11,37 +11,31 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '26'
- name: Install JS dependencies
run: |
npm install vitepress@1.3.4 tailwindcss@3.4.10
- name: Install dependencies
run: npm install
working-directory: docs
- name: Build docs
run: npm run build
working-directory: docs
- name: Push to docs repository
run: |
cd docs
npx tailwindcss -i .vitepress/theme/style.css -o .vitepress/theme/theme.css -c .vitepress/tailwind.config.js
npm run docs:build
- name: Deploy to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "docs/.vitepress/dist/*"
target: ${{ secrets.SERVER_PATH }}
- name: Update remote docs
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
${{ secrets.UPDATE_DOCS }}
git clone https://${{ secrets.STATIC_REPO_TOKEN }}@github.com/${{ secrets.STATIC_REPO }}.git target-repo
rm -rf target-repo/srv/opengist
mkdir -p target-repo/srv/opengist
cp -r docs/.vitepress/dist/* target-repo/srv/opengist/
cd target-repo
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Deploy docs from ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit"
git pull --rebase
git push
+42 -22
View File
@@ -9,6 +9,7 @@ on:
pull_request:
paths-ignore:
- '**.yml'
- '**.yaml'
- '**.md'
jobs:
@@ -17,18 +18,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go 1.23
uses: actions/setup-go@v4
- name: Set up Go 1.26
uses: actions/setup-go@v6
with:
go-version: "1.23"
go-version: "1.26"
- name: Lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v9
with:
version: v1.60
args: --out-format=colored-line-number --timeout=20m
version: v2.12
args: --timeout=20m --disable=errcheck
- name: Format
run: make fmt check_changes
@@ -38,12 +39,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go 1.23
uses: actions/setup-go@v4
- name: Set up Go 1.26
uses: actions/setup-go@v6
with:
go-version: "1.23"
go-version: "1.26"
- name: Check Go modules
run: make go_mod check_changes
@@ -57,7 +58,7 @@ jobs:
fail-fast: false
matrix:
os: ["ubuntu-latest"]
go: ["1.23"]
go: ["1.26"]
database: [postgres, mysql]
include:
- database: postgres
@@ -83,33 +84,47 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
meilisearch:
image: getmeili/meilisearch:latest
ports:
- 47700:7700
env:
MEILI_NO_ANALYTICS: true
MEILI_ENV: development
options: >-
--health-cmd "curl -sf http://localhost:7700/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v4
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
- name: Run tests
run: make test TEST_DB_TYPE=${{ matrix.database }}
env:
OG_TEST_MEILI_HOST: http://localhost:47700
test:
name: Test
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
go: ["1.23"]
os: ["ubuntu-latest", "macOS-latest"]
go: ["1.26"]
database: ["sqlite"]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v4
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
@@ -122,17 +137,22 @@ jobs:
fail-fast: false
matrix:
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
go: ["1.23"]
go: ["1.26"]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go 1.23
uses: actions/setup-go@v4
- name: Set up Go 1.26
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
- name: Set up Node.js 26
uses: actions/setup-node@v6
with:
node-version: '26'
- name: Build
shell: bash
run: make
+16 -19
View File
@@ -2,16 +2,17 @@ name: Build / Deploy Helm Chart
on:
workflow_dispatch:
workflow_call:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Helm
uses: azure/setup-helm@v4.3.0
uses: azure/setup-helm@v5.0.0
with:
version: 'latest'
@@ -34,20 +35,16 @@ jobs:
helm repo index --url https://helm.opengist.io --merge index.yaml .
fi
- name: Deploy to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "./helm/*.tgz,./helm/index.yaml"
target: ${{ secrets.HELM_SERVER_PATH }}
- name: Update remote helm repository
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
${{ secrets.UPDATE_HELM_REPO }}
- name: Push to docs repository
run: |
git clone https://${{ secrets.STATIC_REPO_TOKEN }}@github.com/${{ secrets.STATIC_REPO }}.git target-repo
mkdir -p target-repo/helm
cp helm/*.tgz target-repo/srv/helm/
cp helm/index.yaml target-repo/srv/helm/
cd target-repo
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Deploy helm chart from ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit"
git pull --rebase
git push
+23 -13
View File
@@ -11,18 +11,23 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up Go 1.23
uses: actions/setup-go@v4
- name: Set up Go 1.26
uses: actions/setup-go@v6
with:
go-version: "1.23"
go-version: "1.26"
- name: Set up Node.js 26
uses: actions/setup-node@v6
with:
node-version: '26'
- name: Cross compile build
run: make all_crosscompile
- name: Upload Release Assets
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v3
with:
files: |
build/*.tar.gz
@@ -38,11 +43,11 @@ jobs:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/thomiceli/opengist
@@ -54,26 +59,26 @@ jobs:
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -81,4 +86,9 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max
helm-build-release:
needs: docker-build-release
uses: ./.github/workflows/helm.yml
secrets: inherit
+1
View File
@@ -6,6 +6,7 @@ gist.db
/**/.DS_Store
public/assets/*
public/manifest.json
public/.vite/*
./opengist
opengist
build/
+95
View File
@@ -1,5 +1,100 @@
# Changelog
## [1.13.1](https://github.com/thomiceli/opengist/compare/v1.13.0...v1.13.1) - 2026-06-10
See here how to [update](https://opengist.io/docs/update) Opengist.
### Fixed
- Embedding fix vertical scrolling and improve padding (#714)
- Fix CSS url for json embed url (#715)
## [1.13.0](https://github.com/thomiceli/opengist/compare/v1.12.2...v1.13.0) - 2026-06-09
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- REST API (#707) (#711) (#702)
- Limit display if there is too much files in one gist (#701)
- Topics git push option in post-receive hook (#698)
- Allow embedding Gists for a certain file only (#709)
- Arabic Translation (#706)
### Fixed
- Server SSH key generation (#708)
### Other
- Update deps Golang, JS, Docker deps (#713)
- New docs website (#710)
## [1.12.2](https://github.com/thomiceli/opengist/compare/v1.12.1...v1.12.2) - 2026-03-14
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- Search all fields (#622)
- Display a form to create an Opengist account coming from a OAuth provider (#623)
- Rebuild search index in admin options (#647)
### Fixed
- Clean file path names on file creation (#624)
- Support UTF-8 on gist download (#625)
- CSRF skipper only for GET *.js request (#627)
- Async-loaded gist embed scripts (#630)
- Make gists username/urls case insensitive in URLS (#641)
- Improve code search and index tests (#663)
- Translation strings (#659)
- Gitea avatar URL on OAuth (#674)
### [Helm Chart](helm/opengist)
- Add environment variables and secrets to statefulset (#644)
> Admins of Opengist instances may want to run "Rebuild search index" in the admin panel.
## [1.12.1](https://github.com/thomiceli/opengist/compare/v1.12.0...v1.12.1) - 2026-02-03
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- More translation strings (#605)
### Fixed
- Allow Access Tokens with Required Login (#611)
- Make text files renderable with mimetypes different than text/plain (#612)
- Improve security on raw files endpoint (#613)
> Admins of Opengist instances may want to run "Synchronize all gists previews" in the admin panel.
## [1.12.0](https://github.com/thomiceli/opengist/compare/v1.11.1...v1.12.0) - 2026-01-27
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- Access tokens (#602)
- Fuzzy search for gist search (#555)
- Allow Unicode letters/numbers in topics (#597)
- Resize editor height (#600)
- More translation strings (#516) (#604)
### Fixed
- Don't panic on Go TCP errors (#601)
### Other
- Reduce footprint of Docker image (#515)
- Update Go + JS deps (#603)
- Configure Dependabot for updates on Go and NPM (#449)
### [Helm Chart](helm/opengist)
- Use existing pvc claim of provided (#547)
- Adds StatefulSet support (#549)
- Move Prom metrics to a dedicated port + support ServiceMonitor (#599)
## [1.11.1](https://github.com/thomiceli/opengist/compare/v1.11.0...v1.11.1) - 2025-09-30
See here how to [update](https://opengist.io/docs/update) Opengist.
### 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.
+21 -25
View File
@@ -1,25 +1,18 @@
FROM alpine:3.19 AS base
FROM alpine:3.23 AS base
RUN apk update && \
apk add --no-cache \
make \
shadow \
openssl \
openssh \
curl \
wget \
git \
gnupg \
xz \
gcc \
git \
musl-dev \
libstdc++
COPY --from=golang:1.23-alpine /usr/local/go/ /usr/local/go/
COPY --from=golang:1.26.4-alpine3.23 /usr/local/go/ /usr/local/go/
ENV PATH="/usr/local/go/bin:${PATH}"
ENV CGO_ENABLED=0
COPY --from=node:20-alpine /usr/local/ /usr/local/
COPY --from=node:26.3.0-alpine3.23 /usr/local/ /usr/local/
ENV NODE_PATH="/usr/local/lib/node_modules"
ENV PATH="/usr/local/bin:${PATH}"
@@ -29,8 +22,20 @@ COPY . .
FROM base AS dev
RUN apk add --no-cache \
openssl \
openssh-server \
curl \
wget \
git \
gnupg \
xz
EXPOSE 6157 6158 2222 16157
RUN git config --global --add safe.directory /opengist
RUN make install
EXPOSE 6157 2222 16157
VOLUME /opengist
CMD ["make", "watch"]
@@ -41,33 +46,24 @@ FROM base AS build
RUN make
FROM alpine:3.19 as prod
FROM alpine:3.23 AS prod
RUN apk update && \
apk add --no-cache \
shadow \
openssl \
openssh \
curl \
wget \
git \
gnupg \
xz \
gcc \
musl-dev \
libstdc++
git
RUN addgroup -S opengist && \
adduser -S -G opengist -s /bin/ash -g 'Opengist User' opengist
COPY --from=build --chown=opengist:opengist /opengist/config.yml config.yml
WORKDIR /app/opengist
COPY --from=build --chown=opengist:opengist /opengist/config.yml /config.yml
COPY --from=build --chown=opengist:opengist /opengist/opengist .
COPY --from=build --chown=opengist:opengist /opengist/docker ./docker
EXPOSE 6157 2222
EXPOSE 6157 6158 2222
VOLUME /opengist
HEALTHCHECK --interval=60s --timeout=30s --start-period=15s --retries=3 CMD curl -f http://localhost:6157/healthcheck || exit 1
ENTRYPOINT ["./docker/entrypoint.sh"]
+15 -8
View File
@@ -1,4 +1,4 @@
.PHONY: all all_crosscompile install build_frontend build_backend build build_crosscompile build_docker build_dev_docker run_dev_docker watch_frontend watch_backend watch clean clean_docker check_changes go_mod fmt test check-tr
.PHONY: all all_crosscompile install build_frontend build_backend build build_crosscompile build_docker build_dev_docker run_dev_docker watch_frontend watch_backend watch clean clean_docker check_changes go_mod fmt test check-tr update_js_deps update_go_deps
# Specify the name of your Go binary output
BINARY_NAME := opengist
@@ -19,7 +19,6 @@ install:
build_frontend:
@echo "Building frontend assets..."
npx vite -c public/vite.config.js build
@EMBED=1 npx postcss 'public/assets/embed-*.css' -c public/postcss.config.js --replace # until we can .nest { @tailwind } in Sass
build_backend:
@echo "Building Opengist binary..."
@@ -39,23 +38,23 @@ build_dev_docker:
docker build -t $(BINARY_NAME)-dev:latest --target dev .
run_dev_docker:
docker run -v .:/opengist -p 6157:6157 -p 16157:16157 -p 2222:2222 -v $(HOME)/.opengist-dev:/root/.opengist --rm $(BINARY_NAME)-dev:latest
docker run -v .:/opengist -v /opengist/node_modules -p 6157:6157 -p 16157:16157 -p 2222:2222 -v $(HOME)/.opengist-dev:/root/.opengist --rm $(BINARY_NAME)-dev:latest
watch_frontend:
@echo "Building frontend assets..."
npx vite -c public/vite.config.js dev --port 16157 --host
npx vite -c public/vite.config.js --port 16157 --host
watch_backend:
@echo "Building Opengist binary..."
OG_DEV=1 npx nodemon --watch '**/*' -e html,yml,go,js --signal SIGTERM --exec 'go run -ldflags "-X $(VERSION_PKG)=$(GIT_TAG)" . --config config.yml'
watch:
@bash ./scripts/watch.sh
@sh ./scripts/watch.sh
clean:
@echo "Cleaning up build artifacts..."
@rm -f $(BINARY_NAME) public/manifest.json
@rm -rf public/assets build
@rm -f $(BINARY_NAME)
@rm -rf public/assets public/.vite build
clean_docker:
@echo "Cleaning up Docker image..."
@@ -76,4 +75,12 @@ test:
@OPENGIST_TEST_DB=$(TEST_DB_TYPE) go test ./... -p 1
check-tr:
@bash ./scripts/check-translations.sh
@bash ./scripts/check-translations.sh
update_js_deps:
@echo "Updating NPM dependencies..."
@npx npm-check-updates -u && npm install
update_go_deps:
@echo "Updating Go dependencies..."
@go get -u ./... && go mod tidy
+5 -5
View File
@@ -1,6 +1,6 @@
# Opengist
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg" alt="Opengist" align="right" />
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg" alt="Opengist" align="right" />
Opengist is a **self-hosted** Pastebin **powered by Git**. All snippets are stored in a Git repository and can be
read and/or modified using standard Git commands, or with the web interface.
@@ -38,7 +38,7 @@ It is similar to [GitHub Gist](https://gist.github.com/), but open-source and co
Docker [images](https://github.com/thomiceli/opengist/pkgs/container/opengist) are available for each release :
```shell
docker pull ghcr.io/thomiceli/opengist:1.11
docker pull ghcr.io/thomiceli/opengist:1.13
```
It can be used in a `docker-compose.yml` file :
@@ -50,7 +50,7 @@ It can be used in a `docker-compose.yml` file :
```yml
services:
opengist:
image: ghcr.io/thomiceli/opengist:1.11
image: ghcr.io/thomiceli/opengist:1.13
container_name: opengist
restart: unless-stopped
ports:
@@ -77,9 +77,9 @@ Download the archive for your system from the release page [here](https://github
```shell
# example for linux amd64
wget https://github.com/thomiceli/opengist/releases/download/v1.11.0/opengist1.11.0-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.13.1/opengist1.13.1-linux-amd64.tar.gz
tar xzvf opengist1.11.0-linux-amd64.tar.gz
tar xzvf opengist1.13.1-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`
+15 -5
View File
@@ -3,7 +3,7 @@
# https://github.com/thomiceli/opengist/blob/master/docs/configuration/cheat-sheet.md
# Set the log level to one of the following: debug, info, warn, error, fatal. Default: warn
log-level: debug
log-level: warn
# Set the log output to one or more of the following: `stdout`, `file`. Default: stdout,file
log-output: stdout,file
@@ -32,6 +32,10 @@ index.meili.host:
# Set the API key for the Meiliseach server
index.meili.api-key:
# Set the default search fields. Can contain multiple fields (e.g., `content,username`).
# Fields: content,user,title,description,filename,extension,language,topic. Default: content
search.default: content
# Default branch name used by Opengist when initializing Git repositories.
# If not set, uses the Git default branch name. See https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch
git.default-branch:
@@ -52,12 +56,21 @@ http.port: 6157
# Enable or disable git operations (clone, pull, push) via HTTP (either `true` or `false`). Default: true
http.git-enabled: true
# Enable or disable the REST API (either `true` or `false`). Default: true
api.enabled: true
# File permissions for Unix socket (octal format). Default: 0666
unix-socket-permissions: 0666
# Enable or disable the metrics endpoint (either `true` or `false`). Default: false
# Enable or disable the Prometheus metrics server (either `true` or `false`). Default: false
metrics.enabled: false
# The host on which the metrics server should bind. Default: 0.0.0.0
metrics.host: 0.0.0.0
# The port on which the metrics server should listen. Default: 6158
metrics.port: 6158
# SSH built-in server configuration
# Note: it is not using the SSH daemon from your machine (yet)
@@ -78,9 +91,6 @@ ssh.port: 2222
# If not set, uses the URL from the request
ssh.external-domain:
# Path or alias to ssh-keygen executable. Default: ssh-keygen
ssh.keygen-executable: ssh-keygen
# OAuth2 configuration
# The callback/redirect URL must be http://opengist.url/oauth/<github|gitlab|gitea|openid-connect>/callback
+14 -5
View File
@@ -1,5 +1,18 @@
#!/bin/sh
load_secrets() {
if [ -f "/run/secrets/opengist_secrets" ]; then
set -a
. /run/secrets/opengist_secrets
set +a
fi
}
if [ "$(id -u)" -ne 0 ]; then
load_secrets
exec env HOME=/opengist OG_OPENGIST_HOME=/opengist /app/opengist/opengist --config /config.yml
fi
export USER=opengist
UID=${UID:-1000}
GID=${GID:-1000}
@@ -9,10 +22,6 @@ usermod -o -u "$UID" $USER
chown -R "$USER:$USER" /opengist
chown -R "$USER:$USER" /config.yml
if [ -f "/run/secrets/opengist_secrets" ]; then
set -a
. /run/secrets/opengist_secrets
set +a
fi
load_secrets
exec su $USER -c "OG_OPENGIST_HOME=/opengist /app/opengist/opengist --config /config.yml"
+3
View File
@@ -0,0 +1,3 @@
node_modules
.vitepress/dist
.vitepress/cache
+229 -66
View File
@@ -1,90 +1,216 @@
import {defineConfig} from 'vitepress'
import tailwindcss from '@tailwindcss/vite'
import {listOperations, listSchemas, readSpecRaw} from './openapi'
// Serve the raw OpenAPI spec verbatim at /docs/api/openapi.yaml — read from the
// Go source tree so it never drifts. Dev via middleware, build via asset emit.
let isSsrBuild = false
const openapiRawPlugin = {
name: 'opengist:openapi-raw',
configResolved(c: any) {
isSsrBuild = !!c.build?.ssr
},
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
if (req.url === '/docs/api/openapi.yaml') {
res.setHeader('Content-Type', 'text/yaml; charset=utf-8')
res.end(readSpecRaw())
} else next()
})
},
generateBundle(this: any) {
if (isSsrBuild) return
this.emitFile({type: 'asset', fileName: 'docs/api/openapi.yaml', source: readSpecRaw()})
}
}
// Build the API Reference sidebar from the OpenAPI spec: one entry per
// operation (grouped by tag), then one entry per schema, plus the Overview.
const apiOps = listOperations()
const apiTags = [...new Set(apiOps.map(op => op.tag))]
const apiSidebar = [
{text: 'Overview', link: '/docs/api'},
...apiTags.map(tag => ({
text: tag,
collapsed: false,
items: apiOps
.filter(op => op.tag === tag)
.map(op => ({text: op.summary, link: `/docs/api/${op.id}`})),
})),
{
text: 'Schemas',
collapsed: true,
items: listSchemas().map(name => ({text: name, link: `/docs/api/schemas/${name}`})),
},
]
// Main docs sidebar, shared by the /docs/ pages and the standalone /changelog page.
const docsSidebar = [
{
text: '', items: [
{text: 'Introduction', link: '/docs'},
{text: 'Installation', link: '/docs/installation', items: [
{text: 'Docker', link: '/docs/installation/docker'},
{text: 'Kubernetes', link: '/docs/installation/kubernetes'},
{text: 'Binary', link: '/docs/installation/binary'},
{text: 'Source', link: '/docs/installation/source'},
],
collapsed: true
},
{text: 'Update', link: '/docs/update'},
], collapsed: false
},
{
text: 'Configuration', base: '/docs/configuration', items: [
{text: 'Configure Opengist', link: '/configure'},
{text: 'Databases', items: [
{text: 'SQLite', link: '/databases/sqlite'},
{text: 'PostgreSQL', link: '/databases/postgresql'},
{text: 'MySQL', link: '/databases/mysql'},
], collapsed: true
},
{text: 'OAuth Providers', link: '/oauth-providers'},
{text: 'Custom assets', link: '/custom-assets'},
{text: 'Custom links', link: '/custom-links'},
{text: 'Cheat Sheet', link: '/cheat-sheet'},
{text: 'Metrics', link: '/metrics'},
{text: 'Admin panel', link: '/admin-panel'},
], collapsed: false
},
{
text: 'Usage', base: '/docs/usage', items: [
{text: 'Init via Git', link: '/init-via-git'},
{text: 'Embed Gist', link: '/embed'},
{text: 'Access Tokens', link: '/access-tokens'},
{text: 'Gist as JSON', link: '/gist-json'},
{text: 'Import Gists from Github', link: '/import-from-github-gist'},
{text: 'Git push options', link: '/git-push-options'},
], collapsed: false
},
{
text: 'Administration', base: '/docs/administration', items: [
{text: 'Run with systemd', link: '/run-with-systemd'},
{text: 'Reverse proxy', items: [
{text: 'Nginx', link: '/nginx-reverse-proxy'},
{text: 'Traefik', link: '/traefik-reverse-proxy'},
], collapsed: true},
{text: 'Fail2ban', link: '/fail2ban-setup'},
{text: 'Healthcheck', link: '/healthcheck'},
], collapsed: false
},
{
text: 'Contributing', base: '/docs/contributing', items: [
{text: 'Community', link: '/community'},
{text: 'Development', link: '/development'},
], collapsed: false
},
]
// https://vitepress.dev/reference/site-config
const hostname = 'https://opengist.io'
const ogImage = `${hostname}/opengist-demo.png`
export default defineConfig({
title: "Opengist",
description: "Documention for Opengist",
description: "Documentation for Opengist — a self-hosted pastebin powered by Git.",
lang: 'en-US',
sitemap: {
hostname,
},
markdown: {
config(md) {
// Strip the "See here how to update Opengist." note that appears
// under each version in the embedded CHANGELOG (source stays intact).
md.core.ruler.push('strip_changelog_update_note', (state) => {
const t = state.tokens
for (let i = t.length - 1; i >= 0; i--) {
if (
t[i].type === 'inline' &&
/See here how to .*update.* Opengist\./i.test(t[i].content) &&
t[i - 1]?.type === 'paragraph_open'
) {
t.splice(i - 1, 3)
}
}
})
// Turn "#123" references into links to the matching GitHub PR/issue.
md.inline.ruler.before('text', 'github_ref', (state, silent) => {
const start = state.pos
if (state.src.charCodeAt(start) !== 0x23 /* # */) return false
// Require a boundary before '#' (avoid URL fragments like page#1).
const prev = start > 0 ? state.src[start - 1] : ''
if (prev && /[0-9A-Za-z]/.test(prev)) return false
let pos = start + 1
while (pos < state.posMax && /[0-9]/.test(state.src[pos])) pos++
if (pos === start + 1) return false // no digits
// Reject things like a hex color "#1a2" (digit run followed by a letter).
if (pos < state.posMax && /[A-Za-z]/.test(state.src[pos])) return false
const num = state.src.slice(start + 1, pos)
if (!silent) {
const open = state.push('link_open', 'a', 1)
open.attrs = [
['href', `https://github.com/thomiceli/opengist/pull/${num}`],
['target', '_blank'],
['rel', 'noreferrer'],
]
state.push('text', '', 0).content = `#${num}`
state.push('link_close', 'a', -1)
}
state.pos = pos
return true
})
},
},
vite: {
plugins: [tailwindcss(), openapiRawPlugin]
},
rewrites: {
'index.md': 'index.md',
'introduction.md': 'docs/index.md',
'changelog.md': 'changelog.md',
':path(.*)': 'docs/:path'
},
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
logo: 'https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg',
logo: 'https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg',
logoLink: '/',
nav: [
{ text: 'Demo', link: 'https://demo.opengist.io' },
{ text: 'Translate', link: 'https://tr.opengist.io' }
{ text: 'Docs', link: '/docs', activeMatch: '^/docs' },
{
text: 'Resources',
items: [
{ text: 'Demo', link: 'https://demo.opengist.io' },
{ text: 'Translate', link: 'https://tr.opengist.io' },
]
},
{
text: 'v1.13.1',
items: [
{ text: 'Changelog', link: '/changelog' },
{ text: 'Releases', link: 'https://github.com/thomiceli/opengist/releases' },
]
}
],
sidebar: {
'/docs/': [
{
text: '', items: [
{text: 'Introduction', link: '/docs'},
{text: 'Installation', link: '/docs/installation', items: [
{text: 'Docker', link: '/docs/installation/docker'},
{text: 'Kubernetes', link: '/docs/installation/kubernetes'},
{text: 'Binary', link: '/docs/installation/binary'},
{text: 'Source', link: '/docs/installation/source'},
],
collapsed: true
},
{text: 'Update', link: '/docs/update'},
], collapsed: false
},
{
text: 'Configuration', base: '/docs/configuration', items: [
{text: 'Configure Opengist', link: '/configure'},
{text: 'Databases', items: [
{text: 'SQLite', link: '/databases/sqlite'},
{text: 'PostgreSQL', link: '/databases/postgresql'},
{text: 'MySQL', link: '/databases/mysql'},
], collapsed: true
},
{text: 'OAuth Providers', link: '/oauth-providers'},
{text: 'Custom assets', link: '/custom-assets'},
{text: 'Custom links', link: '/custom-links'},
{text: 'Cheat Sheet', link: '/cheat-sheet'},
{text: 'Metrics', link: '/metrics'},
{text: 'Admin panel', link: '/admin-panel'},
], collapsed: false
},
{
text: 'Usage', base: '/docs/usage', items: [
{text: 'Init via Git', link: '/init-via-git'},
{text: 'Embed Gist', link: '/embed'},
{text: 'Gist as JSON', link: '/gist-json'},
{text: 'Import Gists from Github', link: '/import-from-github-gist'},
{text: 'Git push options', link: '/git-push-options'},
], collapsed: false
},
{
text: 'Administration', base: '/docs/administration', items: [
{text: 'Run with systemd', link: '/run-with-systemd'},
{text: 'Reverse proxy', items: [
{text: 'Nginx', link: '/nginx-reverse-proxy'},
{text: 'Traefik', link: '/traefik-reverse-proxy'},
], collapsed: true},
{text: 'Fail2ban', link: '/fail2ban-setup'},
{text: 'Healthcheck', link: '/healthcheck'},
], collapsed: false
},
{
text: 'Contributing', base: '/docs/contributing', items: [
{text: 'Community', link: '/community'},
{text: 'Development', link: '/development'},
], collapsed: false
},
]},
// Standalone API Reference section: its own sidebar, separate from
// the main docs navigation. Longest-prefix match means /docs/api
// uses this instead of the '/docs/' sidebar below.
'/docs/api': apiSidebar,
'/docs/': docsSidebar,
// Standalone /changelog page reuses the main docs sidebar.
'/changelog': docsSidebar,
},
socialLinks: [
{icon: 'github', link: 'https://github.com/thomiceli/opengist'}
{icon: 'github', link: 'https://github.com/thomiceli/opengist'},
{icon: 'discord', link: 'https://discord.gg/9Pm3X5scZT'}
],
editLink: {
pattern: 'https://github.com/thomiceli/opengist/edit/stable/docs/:path'
pattern: 'https://github.com/thomiceli/opengist/edit/master/docs/:path'
},
// @ts-ignore
lastUpdated: true,
@@ -92,6 +218,43 @@ export default defineConfig({
},
head: [
['link', {rel: 'icon', href: '/favicon.svg'}],
['meta', {name: 'theme-color', content: '#3c79e2'}],
// Site-wide Open Graph / Twitter Card defaults (per-page values are
// refined in transformPageData below).
['meta', {property: 'og:type', content: 'website'}],
['meta', {property: 'og:site_name', content: 'Opengist'}],
['meta', {property: 'og:image', content: ogImage}],
['meta', {name: 'twitter:card', content: 'summary_large_image'}],
['meta', {name: 'twitter:image', content: ogImage}],
],
// Per-page meta: canonical URL, description, and Open Graph / Twitter tags
// built from each page's title + description.
transformPageData(pageData) {
// Mirror the `rewrites` above to compute the deployed path.
let out
if (pageData.relativePath === 'index.md') out = 'index.md'
else if (pageData.relativePath === 'introduction.md') out = 'docs/index.md'
else if (pageData.relativePath === 'changelog.md') out = 'changelog.md'
else out = `docs/${pageData.relativePath}`
const path = out.replace(/(^|\/)index\.md$/, '$1').replace(/\.md$/, '.html')
const url = `${hostname}/${path}`
const base = pageData.title || 'Opengist'
const title = base.includes('Opengist') ? base : `${base} | Opengist`
const description =
pageData.description ||
pageData.frontmatter.description ||
'Documentation for Opengist — a self-hosted pastebin powered by Git.'
pageData.frontmatter.head ??= []
pageData.frontmatter.head.push(
['link', {rel: 'canonical', href: url}],
['meta', {property: 'og:title', content: title}],
['meta', {property: 'og:description', content: description}],
['meta', {property: 'og:url', content: url}],
['meta', {name: 'twitter:title', content: title}],
['meta', {name: 'twitter:description', content: description}],
)
},
ignoreDeadLinks: true
})
+13
View File
@@ -0,0 +1,13 @@
import { defineLoader } from 'vitepress'
import { buildApiData, SPEC_FILE, type ApiData } from './openapi'
// `data` is populated at build time and importable from any component.
declare const data: ApiData
export { data }
export type { ApiData }
export default defineLoader({
// Rebuild whenever the spec changes (path is relative to this file).
watch: [SPEC_FILE],
load: (): Promise<ApiData> => buildApiData()
})
+441
View File
@@ -0,0 +1,441 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { parse } from 'yaml'
// The authoritative spec lives in the Go source tree.
export const SPEC_FILE = '../../internal/web/handlers/api/openapi.yaml'
const SPEC_URL = new URL(SPEC_FILE, import.meta.url)
const METHOD_ORDER = ['get', 'post', 'put', 'patch', 'delete']
// ---- shapes consumed by the renderer components (all JSON-serializable) ----
export interface ApiData {
info: { title: string; version: string; descriptionHtml: string }
authHtml: string
servers: { url: string; description: string }[]
groups: Group[]
schemas: SchemaDef[]
}
export interface Group {
name: string
descriptionHtml: string
operations: Operation[]
}
export interface Operation {
id: string
method: string
path: string
summary: string
descriptionHtml: string
auth: { anonymous: boolean; anyToken: boolean; scopes: string[] }
parameters: Param[]
requestBody: (BodyTable & { required: boolean }) | null
responses: ResponseDef[]
// Synthesized samples for the right-hand code panel (pre-highlighted HTML).
sample: { curlHtml: string; responseStatus: string; responseHtml: string | null }
}
interface Param {
name: string
in: string
required: boolean
typeHtml: string
descriptionHtml: string
}
interface Field {
name: string
typeHtml: string
required: boolean
descriptionHtml: string
depth: number
}
// A body schema flattened into a field table (the inlined replacement for a
// schema link): its type label, whether it's an array, and its fields.
interface BodyTable {
label: string
arrayOf: boolean
note: string
rows: Field[]
}
interface ResponseDef {
status: string
descriptionHtml: string
headers: { name: string; typeHtml: string; descriptionHtml: string }[]
typeLabel: string | null
}
// A named component schema, rendered one-per-page.
export interface SchemaDef {
name: string
descriptionHtml: string
note: string
rows: Field[]
}
// Lightweight index used by config.mts (sidebar) and the dynamic-route
// generator. Synchronous and markdown-free so it's cheap to call.
export interface OpIndex {
id: string
method: string
path: string
summary: string
tag: string
}
function readSpec(): any {
return parse(readSpecRaw())
}
// Raw spec contents, for serving the file verbatim at /docs/api/openapi.yaml.
export function readSpecRaw(): string {
return readFileSync(fileURLToPath(SPEC_URL), 'utf-8')
}
// Walk paths × methods in a stable order, yielding each operation.
function eachOperation(spec: any, cb: (op: any, method: string, path: string, item: any) => void) {
for (const [path, item] of Object.entries<any>(spec.paths)) {
for (const method of METHOD_ORDER) {
const op = item[method]
if (op) cb(op, method, path, item)
}
}
}
export function listOperations(): OpIndex[] {
const spec = readSpec()
const ops: OpIndex[] = []
eachOperation(spec, (op, method, path) => {
ops.push({
id: op.operationId,
method: method.toUpperCase(),
path,
summary: op.summary || op.operationId,
tag: op.tags?.[0] || (spec.tags?.[0]?.name ?? '')
})
})
return ops
}
// Names of the component schemas, for the sidebar and per-schema routes.
export function listSchemas(): string[] {
return Object.keys(readSpec().components?.schemas || {})
}
export async function buildApiData(): Promise<ApiData> {
// Lazily pulled in so this module can be imported from config.mts without
// dragging the markdown renderer into the config load path.
const { createMarkdownRenderer } = await import('vitepress')
const spec = readSpec()
const md = await createMarkdownRenderer(fileURLToPath(new URL('..', import.meta.url)))
// Block rendering is async because Shiki highlighting in VitePress 2 is
// resolved via markdown-it-async; renderInline carries no code fences.
const html = (s?: string) => (s ? md.renderAsync(s) : Promise.resolve(''))
const inline = (s?: string) => (s ? md.renderInline(s) : '')
// Drop the "**Required ...**" scope line from operation descriptions — the
// Authorization header row already documents the required scopes.
const stripRequired = (s?: string) =>
s
? s
.split('\n')
.filter((l) => !l.trimStart().startsWith('**Required'))
.join('\n')
: s
const refName = (ref: string) => ref.split('/').pop() as string
const resolve = (ref: string): any =>
ref
.replace(/^#\//, '')
.split('/')
.reduce((acc, key) => acc?.[key], spec)
const deref = (obj: any): any => (obj?.$ref ? resolve(obj.$ref) : obj)
// Named schema references link to their per-schema page.
const link = (name: string) => `<a href="/docs/api/schemas/${name}"><code>${name}</code></a>`
const typeHtml = (s: any): string => {
if (!s) return '<code>any</code>'
if (s.$ref) return link(refName(s.$ref))
if (s.oneOf) return s.oneOf.map(typeHtml).join(' | ')
if (s.allOf) return s.allOf.map(typeHtml).join(' &amp; ')
if (s.type === 'array') return typeHtml(s.items) + '[]'
if (s.type === 'null') return '<code>null</code>'
if (s.type === 'object' && s.additionalProperties)
return `map&lt;string, ${typeHtml(s.additionalProperties)}&gt;`
let t = s.type || 'object'
if (s.format) t += ` &lt;${s.format}&gt;`
let out = `<code>${t}</code>`
if (Array.isArray(s.enum))
out += `: ${s.enum.map((e: any) => `<code>${e}</code>`).join(' | ')}`
return out
}
const constraints = (s: any): string => {
if (!s) return ''
const bits: string[] = []
if (s.default !== undefined) bits.push(`default: ${s.default}`)
if (s.minimum !== undefined) bits.push(`min: ${s.minimum}`)
if (s.maximum !== undefined) bits.push(`max: ${s.maximum}`)
if (s.pattern) bits.push(`pattern: <code>${s.pattern}</code>`)
return bits.length ? ` <em>(${bits.join(', ')})</em>` : ''
}
// Flatten a schema to its full property set, resolving $ref and allOf
// (including inherited fields) so each body can render a complete table.
// `seen` guards against recursive schemas.
const flatten = (
s: any,
seen: string[] = []
): { props: Record<string, any>; required: string[]; addl: any } => {
const acc = { props: {} as Record<string, any>, required: [] as string[], addl: null as any }
if (!s) return acc
if (s.$ref) {
const name = refName(s.$ref)
return seen.includes(name) ? acc : flatten(resolve(s.$ref), [...seen, name])
}
for (const part of s.allOf || []) {
const f = flatten(part, seen)
Object.assign(acc.props, f.props)
acc.required.push(...f.required)
if (f.addl) acc.addl = f.addl
}
if (s.properties) Object.assign(acc.props, s.properties)
if (s.required) acc.required.push(...s.required)
if (s.additionalProperties) acc.addl = s.additionalProperties
return acc
}
// Turn a request/response body schema into a renderable field table.
const bodyTable = (schema: any): BodyTable => {
let s = schema
let arrayOf = false
while (s?.$ref) s = resolve(s.$ref)
if (s?.type === 'array') {
arrayOf = true
s = s.items
while (s?.$ref) s = resolve(s.$ref)
}
const label = schema ? typeHtml(schema) : '<code>any</code>'
const f = flatten(s)
const rows: Field[] = Object.entries<any>(f.props).map(([pname, ps]) => ({
name: pname,
typeHtml: typeHtml(ps),
required: f.required.includes(pname),
descriptionHtml: inline(ps.description),
depth: 0
}))
const note = f.addl ? `Additional properties: map&lt;string, ${typeHtml(f.addl)}&gt;` : ''
return { label, arrayOf, note, rows }
}
// Recursively expand an object/array/map schema into indented field rows,
// descending into nested objects (used for the request body table). `seen`
// tracks $ref names on the current branch to break recursive schemas.
const childrenOf = (schema: any, depth: number, seen: string[]): Field[] => {
let s = schema
const branch = [...seen]
while (s) {
if (s.$ref) {
const n = refName(s.$ref)
if (branch.includes(n)) return []
branch.push(n)
s = resolve(s.$ref)
} else if (s.type === 'array') s = s.items
else if (s.oneOf) s = s.oneOf.find((o: any) => o.type !== 'null') ?? s.oneOf[0]
else break
}
if (!s) return []
const f = flatten(s, branch)
const entries = Object.entries<any>(f.props)
// A pure map (additionalProperties only): descend into the value's fields.
if (entries.length === 0 && f.addl) return childrenOf(f.addl, depth, branch)
const out: Field[] = []
for (const [name, ps] of entries) {
out.push({
name,
typeHtml: typeHtml(ps),
required: f.required.includes(name),
descriptionHtml: inline(ps.description),
depth
})
out.push(...childrenOf(ps, depth + 1, branch))
}
return out
}
// Synthesize a representative value for a schema (no examples in the spec).
// `seen` tracks $ref names on the current branch to break recursive schemas.
const sample = (schema: any, seen: string[] = []): any => {
if (!schema) return null
if (schema.$ref) {
const name = refName(schema.$ref)
if (seen.includes(name)) return null
return sample(resolve(schema.$ref), [...seen, name])
}
if (schema.example !== undefined) return schema.example
if (schema.default !== undefined) return schema.default
if (Array.isArray(schema.enum)) return schema.enum[0]
if (schema.allOf)
return schema.allOf.reduce((acc: any, s: any) => Object.assign(acc, sample(s, seen)), {})
if (schema.oneOf) {
const pick = schema.oneOf.find((s: any) => s.type !== 'null') ?? schema.oneOf[0]
return sample(pick, seen)
}
switch (schema.type) {
case 'array':
return [sample(schema.items, seen)]
case 'object': {
const obj: Record<string, any> = {}
for (const [k, v] of Object.entries<any>(schema.properties || {})) obj[k] = sample(v, seen)
if (schema.additionalProperties && typeof schema.additionalProperties === 'object')
obj['<key>'] = sample(schema.additionalProperties, seen)
return obj
}
case 'integer':
case 'number':
return 1
case 'boolean':
return true
case 'null':
return null
default:
if (schema.format === 'date-time') return '2024-01-01T00:00:00Z'
if (schema.format === 'uri') return 'https://opengist.example/...'
if (schema.format === 'binary') return '<binary data>'
return 'string'
}
}
const HOST = 'https://opengist.example.com'
const base = spec.servers?.[0]?.url ?? ''
const fence = (lang: string, code: string) => md.renderAsync(`\`\`\`${lang}\n${code}\n\`\`\``)
const tagDefs: any[] = spec.tags || []
const groups: Group[] = []
for (const t of tagDefs)
groups.push({ name: t.name, descriptionHtml: await html(t.description), operations: [] })
const groupByName = new Map(groups.map((g) => [g.name, g]))
for (const [path, item] of Object.entries<any>(spec.paths)) {
const pathParams: any[] = (item.parameters || []).map(deref)
for (const method of METHOD_ORDER) {
const op = item[method]
if (!op) continue
const allParams: any[] = [...pathParams, ...(op.parameters || []).map(deref)]
const sec: any[] = op.security ?? spec.security ?? []
const auth = { anonymous: false, anyToken: false, scopes: [] as string[] }
const scopeSet = new Set<string>()
for (const req of sec) {
if (Object.keys(req).length === 0) auth.anonymous = true
else {
const arr: string[] = req.bearerAuth || []
if (arr.length === 0) auth.anyToken = true
else arr.forEach((s) => scopeSet.add(s))
}
}
auth.scopes = [...scopeSet]
const parameters: Param[] = allParams.map((p) => ({
name: p.name,
in: p.in,
required: !!p.required,
typeHtml: typeHtml(p.schema),
descriptionHtml: inline(p.description) + constraints(p.schema)
}))
let requestBody: Operation['requestBody'] = null
let requestSchema: any = null
if (op.requestBody) {
const rb = deref(op.requestBody)
requestSchema = rb.content?.['application/json']?.schema
// Expand nested object fields inline so the whole body shape is visible.
requestBody = {
required: !!rb.required,
...bodyTable(requestSchema),
rows: childrenOf(requestSchema, 0, [])
}
}
// ---- curl request sample: -X, then --url, then headers / body ----
const query = allParams
.filter((p) => p.in === 'query' && (p.required || p.schema?.default !== undefined))
.map((p) => `${p.name}=${p.schema?.default ?? sample(p.schema)}`)
.join('&')
const url = `${HOST}${base}${path}${query ? '?' + query : ''}`
const curlLines = [
`curl -X ${method.toUpperCase()}`,
` --url "${url}"`,
` -H "Authorization: Bearer og_xxxxxxxx"`
]
if (requestSchema) {
curlLines.push(` -H "Content-Type: application/json"`)
curlLines.push(` -d '${JSON.stringify(sample(requestSchema), null, 2)}'`)
}
const curlHtml = await fence('bash', curlLines.join(' \\\n'))
// ---- example response (first 2xx with a JSON body) for the code panel ----
let responseStatus = ''
let responseHtml: string | null = null
for (const [status, resRaw] of Object.entries<any>(op.responses)) {
if (!status.startsWith('2')) continue
responseStatus = status
const schema = deref(resRaw).content?.['application/json']?.schema
if (schema) responseHtml = await fence('json', JSON.stringify(sample(schema), null, 2))
break
}
const responses: ResponseDef[] = Object.entries<any>(op.responses).map(([status, resRaw]) => {
const res = deref(resRaw)
const content = res.content || {}
const ct = content['application/json'] || Object.values<any>(content)[0]
return {
status,
descriptionHtml: inline(res.description),
headers: Object.entries<any>(res.headers || {}).map(([name, hRaw]) => {
const h = deref(hRaw)
return { name, typeHtml: typeHtml(h.schema), descriptionHtml: inline(h.description) }
}),
typeLabel: ct?.schema ? typeHtml(ct.schema) : null
}
})
const built: Operation = {
id: op.operationId,
method: method.toUpperCase(),
path,
summary: op.summary || '',
descriptionHtml: await html(stripRequired(op.description)),
auth,
parameters,
requestBody,
responses,
sample: { curlHtml, responseStatus, responseHtml }
}
;(groupByName.get(op.tags?.[0]) ?? groups[0])?.operations.push(built)
}
}
// ---- component schemas, each rendered on its own page ----
const schemas: SchemaDef[] = []
for (const [name, s] of Object.entries<any>(spec.components?.schemas || {})) {
const t = bodyTable(s)
schemas.push({
name,
descriptionHtml: await html(s.description),
note: t.note,
rows: t.rows
})
}
return {
info: {
title: spec.info.title,
version: spec.info.version,
descriptionHtml: await html(spec.info.description)
},
authHtml: await html(spec.components?.securitySchemes?.bearerAuth?.description),
servers: (spec.servers || []).map((s: any) => ({
url: s.url,
description: s.description || ''
})),
groups,
schemas
}
}
-37
View File
@@ -1,37 +0,0 @@
const colors = require('tailwindcss/colors')
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./.vitepress/theme/*.vue",
],
theme: {
colors: {
transparent: 'transparent',
current: 'currentColor',
white: colors.white,
black: colors.black,
gray: {
50: "#EEEFF1",
100: "#DEDFE3",
200: "#BABCC5",
300: "#999CA8",
400: "#75798A",
500: "#585B68",
600: "#464853",
700: "#363840",
800: "#232429",
900: "#131316"
},
indigo: colors.indigo,
},
extend: {
borderWidth: {
'1': '1px',
}
},
},
plugins: [],
darkMode: 'class',
}
-101
View File
@@ -1,101 +0,0 @@
<script>
import { withBase } from 'vitepress';
import './theme.css'
export default {
setup() {
return { withBase };
},
};
</script>
<template>
<main class="home">
<header class="hero">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="mx-auto lg:text-center">
<img class="rotating h-36 mx-auto my-8 " src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg" alt="" >
<a target="_blank" href="https://github.com/thomiceli/opengist/releases" class="inline-flex items-center rounded-full bg-indigo-100 hover:bg-indigo-200 px-4 py-1.5 text-lg font-medium text-indigo-700">
<span class="pr-1">Released 1.11</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
</svg>
</a>
<h1 class="mt-5 text-4xl font-bold tracking-tight sm:text-5xl">Opengist</h1>
<h2 class="mt-4 text-xl">Self-hosted pastebin powered by Git, open-source alternative to Github Gist.</h2>
</div>
<div class="space-x-2 my-12">
<a href="/docs" class="rounded-md bg-indigo-600 mt-6 px-5 py-3 text-xl font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Docs</a>
<a target="_blank" href="https://demo.opengist.io" class="rounded-md bg-indigo-400 mt-6 px-5 py-3 text-xl border-white font-semibold text-white shadow-sm hover:bg-indigo-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Live demo</a>
<a target="_blank" href="https://github.com/thomiceli/opengist" class="rounded-md bg-gray-800 mt-6 px-3 py-3 text-xl dark:border dark:border-1 dark:border-gray-400 font-semibold text-white shadow-sm hover:bg-gray-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 16 16" class="w-7 h-auto inline" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"></path></svg>
</a>
</div>
<div class="border border-1 mt-6 px-5 py-3 rounded-md shadow-sm ">
<code class="select-all ">docker run --name <span class="text-indigo-700 dark:text-indigo-300 font-bold">opengist</span> -p <span class="text-indigo-700 dark:text-indigo-300 font-bold">6157</span>:6157 -v "<span class="text-indigo-700 dark:text-indigo-300 font-bold">$HOME/.opengist</span>:/opengist" ghcr.io/thomiceli/opengist:1</code>
</div>
</div>
</header>
<div class="relative w-full sm:max-w-7xl mx-auto overflow-auto">
<img class="block w-[200vw] max-w-none sm:w-full h-auto" :src="withBase('/opengist-demo.png')" alt="demo-opengist-screenshot" />
</div>
</main>
</template>
<style>
@-webkit-keyframes rotating /* Safari and Chrome */ {
from {
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes rotating {
from {
-ms-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-ms-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.home {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
flex-direction: column;
gap: 1rem;
text-align: center;
}
.rotating {
-webkit-animation: rotating 8s linear infinite;
-moz-animation: rotating 4s linear infinite;
-ms-animation: rotating 4s linear infinite;
-o-animation: rotating 4s linear infinite;
animation: rotating 12s linear infinite;
}
</style>
+8 -3
View File
@@ -1,16 +1,21 @@
<script setup>
import { computed } from 'vue'
import { useData } from 'vitepress'
import Home from './Home.vue'
import Home from './layouts/Home.vue'
import SectionNav from './components/SectionNav.vue'
import DefaultTheme from 'vitepress/theme'
const { Layout } = DefaultTheme
const { frontmatter } = useData()
const isHome = computed(() => frontmatter.value.layout === 'home')
</script>
<template>
<SectionNav v-if="!isHome" />
<Layout>
<template v-if="frontmatter.layout === 'home'" #home-hero-after>
<template v-if="isHome" #home-hero-after>
<Home />
</template>
</Layout>
</template>
</template>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { computed } from 'vue'
import { data } from '../../openapi.data'
const props = defineProps<{ id: string }>()
const op = computed(() =>
data.groups.flatMap((g) => g.operations).find((o) => o.id === props.id)
)
// Split the path so `{param}` segments can be highlighted.
const pathParts = computed(() =>
(op.value?.path ?? '')
.split(/(\{[^}]+\})/g)
.filter(Boolean)
.map((text) => ({ text, param: text.startsWith('{') && text.endsWith('}') }))
)
// The Authorization header is implied by the spec's bearerAuth security scheme
// rather than declared as a parameter, so synthesize a row from `auth`.
const authHeader = computed(() => {
const a = op.value?.auth
if (!a || (!a.scopes.length && !a.anyToken && !a.anonymous)) return null
const desc = a.scopes.map((s) => `<code>${s}</code>`).join(', ')
return {
name: 'Authorization',
typeHtml: '<code>string</code>',
required: !a.anonymous,
descriptionHtml: desc
}
})
// Request parameters grouped by location, in display order, empty groups dropped.
const paramGroups = computed(() => {
const o = op.value
if (!o) return []
const headers = [
...(authHeader.value ? [authHeader.value] : []),
...o.parameters.filter((p) => p.in === 'header')
]
const groups = [
{ label: 'Headers', descLabel: 'Scopes', optional: true, items: headers },
{ label: 'Path parameters', descLabel: 'Description', optional: false, items: o.parameters.filter((p) => p.in === 'path') },
{ label: 'Query parameters', descLabel: 'Description', optional: false, items: o.parameters.filter((p) => p.in === 'query') }
]
return groups.filter((g) => g.items.length)
})
const hasRequest = computed(() => paramGroups.value.length > 0 || !!op.value?.requestBody)
</script>
<template>
<div v-if="op" class="oas-op-grid">
<!-- left / center: description, request, responses -->
<div class="oas-main">
<h1>{{ op.summary }}</h1>
<div class="oas-endpoint">
<span class="oas-method" :class="`m-${op.method.toLowerCase()}`">{{ op.method }}</span>
<code class="oas-path"><span
v-for="(part, i) in pathParts"
:key="i"
:class="{ 'oas-param': part.param }"
>{{ part.text }}</span></code>
</div>
<div class="oas-desc" v-html="op.descriptionHtml" />
<template v-if="hasRequest">
<h2>Request</h2>
<template v-for="g in paramGroups" :key="g.label">
<h3>{{ g.label }}</h3>
<table class="oas-table">
<thead>
<tr><th>Name</th><th>Type</th><th>{{ g.descLabel }}</th></tr>
</thead>
<tbody>
<tr v-for="p in g.items" :key="p.name">
<td>
<code>{{ p.name }}</code>
<span v-if="p.required" class="oas-req">required</span>
<span v-else-if="g.optional" class="oas-opt">optional</span>
</td>
<td v-html="p.typeHtml" />
<td v-html="p.descriptionHtml" />
</tr>
</tbody>
</table>
</template>
<template v-if="op.requestBody">
<h3>Body parameters</h3>
<p class="oas-bodyhead">
<span v-html="op.requestBody.label" />
<span v-if="op.requestBody.required" class="oas-req">required</span>
<span class="oas-ct">application/json</span>
</p>
<table v-if="op.requestBody.rows.length" class="oas-table">
<thead>
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
</thead>
<tbody>
<tr v-for="(row, i) in op.requestBody.rows" :key="i" :class="{ 'oas-nested': row.depth }">
<td>
<span class="oas-field" :style="{ paddingLeft: row.depth * 1.25 + 'rem' }">
<span v-if="row.depth" class="oas-nest"></span>
<code>{{ row.name }}</code>
<span v-if="row.required" class="oas-req">required</span>
<span v-else class="oas-opt">optional</span>
</span>
</td>
<td v-html="row.typeHtml" />
<td v-html="row.descriptionHtml" />
</tr>
</tbody>
</table>
<p v-if="op.requestBody.note" class="oas-note" v-html="op.requestBody.note" />
</template>
</template>
<h2>Responses</h2>
<table class="oas-table">
<thead>
<tr><th>Status</th><th>Body</th><th>Description</th></tr>
</thead>
<tbody>
<template v-for="r in op.responses" :key="r.status">
<tr>
<td><code class="oas-status" :class="`s-${r.status[0]}`">{{ r.status }}</code></td>
<td><span v-if="r.typeLabel" v-html="r.typeLabel" /><span v-else></span></td>
<td v-html="r.descriptionHtml" />
</tr>
<tr v-if="r.headers.length" class="oas-headers">
<td></td>
<td colspan="2">
<span class="oas-hlabel">Headers:</span>
<span v-for="h in r.headers" :key="h.name" class="oas-header"><code>{{ h.name }}</code></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- right: code samples -->
<aside class="oas-code">
<div class="oas-code-sticky">
<div class="oas-code-label">Request</div>
<div class="oas-code-block" v-html="op.sample.curlHtml" />
<template v-if="op.sample.responseHtml">
<div class="oas-code-label">
Response
<code class="oas-status" :class="`s-${op.sample.responseStatus[0]}`">{{ op.sample.responseStatus }}</code>
</div>
<div class="oas-code-block" v-html="op.sample.responseHtml" />
</template>
</div>
</aside>
</div>
<div v-else><p>Unknown operation: <code>{{ id }}</code></p></div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import { data } from '../../openapi.data'
const props = defineProps<{ name: string }>()
const schema = computed(() => data.schemas.find((s) => s.name === props.name))
</script>
<template>
<div v-if="schema" class="oas-schema">
<h1>{{ schema.name }}</h1>
<div v-html="schema.descriptionHtml" />
<table v-if="schema.rows.length" class="oas-table">
<thead>
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
</thead>
<tbody>
<tr v-for="row in schema.rows" :key="row.name">
<td>
<code>{{ row.name }}</code>
<span v-if="row.required" class="oas-req">required</span>
</td>
<td v-html="row.typeHtml" />
<td v-html="row.descriptionHtml" />
</tr>
</tbody>
</table>
<p v-if="schema.note" class="oas-note" v-html="schema.note" />
</div>
<div v-else><p>Unknown schema: <code>{{ name }}</code></p></div>
</template>
@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue'
import { useRoute, withBase } from 'vitepress'
const route = useRoute()
const isApi = computed(() => route.path.startsWith(withBase('/docs/api')))
const items = computed(() => [
{ text: 'Guide', link: '/docs', active: !isApi.value },
{ text: 'API', link: '/docs/api', active: isApi.value },
])
</script>
<template>
<div class="og-sectionbar">
<div class="og-sectionbar__inner">
<a
v-for="it in items"
:key="it.link"
:href="withBase(it.link)"
class="og-sectionbar__link"
:class="{ 'is-active': it.active }"
>
{{ it.text }}
</a>
</div>
</div>
</template>
<style scoped>
.og-sectionbar {
display: none;
}
@media (min-width: 960px) {
.og-sectionbar {
position: fixed;
top: var(--vp-nav-height);
left: 0;
right: 0;
z-index: 28;
display: block;
height: var(--og-bar-height);
background-color: var(--vp-nav-bg-color);
}
}
/* Same container + frame borders as the top navbar so the bar lines up: full
--vp-layout-max-width centered, 32px inner padding, and 2px vertical + bottom
borders matching the rest of the frame. */
.og-sectionbar__inner {
display: flex;
align-items: center;
gap: 1.5rem;
height: 100%;
max-width: var(--vp-layout-max-width);
margin: 0 auto;
padding-inline: 32px;
border-inline: var(--og-frame-border) solid var(--vp-c-divider);
border-bottom: var(--og-frame-border) solid var(--vp-c-divider);
}
.og-sectionbar__link {
position: relative;
display: inline-flex;
align-items: center;
height: 100%;
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-2);
transition: color 0.2s ease;
}
.og-sectionbar__link:hover {
color: var(--vp-c-text-1);
}
.og-sectionbar__link.is-active {
color: var(--vp-c-brand-1);
}
.og-sectionbar__link.is-active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background: var(--vp-c-brand-1);
}
</style>
@@ -0,0 +1,135 @@
<script setup>
const features = [
{
title: 'Public, unlisted & private',
desc: 'Set the visibility of every snippet - public, unlisted, or fully private.',
},
{
title: 'Powered by Git',
desc: 'Each snippet is a Git repository. Clone and push over HTTP or SSH, or create one with a plain git push.',
},
{
title: 'Git CLI & API',
desc: 'Manage your snippets straight from the git command line, or via the API.',
},
{
title: 'Revisions & diffs',
desc: 'Browse the full history of a snippet with diffs between every revision.',
},
{
title: 'Rich rendering',
desc: 'Syntax highlighting for hundreds of languages, plus Markdown with Mermaid, LaTeX, CSV and media files.',
},
{
title: 'Files & uploads',
desc: 'Multiple files per snippet, including binary files and images, with a download as ZIP.',
},
{
title: 'Likes, forks & topics',
desc: 'Star and fork snippets, and organize them with topics for easy discovery.',
},
{
title: 'Embed & JSON',
desc: 'Drop a snippet into any webpage with the embed widget, or fetch it as JSON.',
},
{
title: 'Full-text & code search',
desc: 'Search across snippets via code or metadata.',
},
{
title: 'OAuth, LDAP & passkeys',
desc: 'Log in with GitHub, GitLab, Gitea or OIDC, LDAP, and secure accounts with TOTP or WebAuthn.',
},
{
title: 'Admin & instance controls',
desc: 'Require login for a private instance, allow anonymous snippets, manage users, and expose Prometheus metrics.',
},
{
title: 'Self-hosted your way',
desc: 'A single binary, Docker image or K8s deployment, backed by SQLite, PostgreSQL or MySQL.',
},
]
</script>
<template>
<section class="og-features">
<div class="og-features__head">
<h2 class="og-features__title">Features</h2>
<p class="og-features__desc">
All you need to self-host, manage and share your snippets.
</p>
</div>
<ul class="og-features__grid">
<li v-for="f in features" :key="f.title" class="og-feature">
<h3 class="og-feature__title">{{ f.title }}</h3>
<p class="og-feature__desc">{{ f.desc }}</p>
</li>
</ul>
</section>
</template>
<style scoped>
.og-features {
border-bottom: var(--og-frame-border) solid var(--vp-c-divider);
}
.og-features__head {
padding: 3rem 1.5rem 2.5rem;
text-align: center;
}
.og-features__title {
margin: 0;
border: 0;
font-size: 1.8rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.01em;
}
.og-features__desc {
margin: 0.6rem 0 0;
font-size: 1rem;
color: var(--vp-c-text-2);
}
/* Flush grid: cells separated by the same frame border (no radius, no margin). */
.og-features__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--og-frame-border);
margin: 0;
padding: 0;
list-style: none;
background: var(--vp-c-divider);
border-top: var(--og-frame-border) solid var(--vp-c-divider);
}
.og-feature {
padding: 1.75rem;
background: var(--vp-c-bg);
}
.og-feature__title {
margin: 0;
border: 0;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.og-feature__desc {
margin: 0.4rem 0 0;
font-size: 0.9rem;
line-height: 1.55;
color: var(--vp-c-text-2);
}
@media (max-width: 900px) {
.og-features__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.og-features__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,338 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const version = 'v1.13'
const dockerCommand = 'docker run -p 6157:6157 -v "$HOME/.opengist:/opengist" ghcr.io/thomiceli/opengist:1.13'
const stars = ref(null)
const formattedStars = computed(() => {
const n = stars.value
if (n == null) return ''
// if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
return String(n)
})
// Cache the star count in localStorage so we don't hit the GitHub API on every
// page load / navigation (unauthenticated API is rate-limited to 60/hour/IP).
const STARS_KEY = 'og-gh-stars'
const STARS_TTL = 6 * 60 * 60 * 1000 // 6 hours
onMounted(async () => {
try {
const cached = JSON.parse(localStorage.getItem(STARS_KEY) || 'null')
if (cached && Date.now() - cached.ts < STARS_TTL) {
stars.value = cached.value
return
}
} catch {}
try {
const res = await fetch('https://api.github.com/repos/thomiceli/opengist')
if (res.ok) {
stars.value = (await res.json()).stargazers_count
try {
localStorage.setItem(STARS_KEY, JSON.stringify({ value: stars.value, ts: Date.now() }))
} catch {}
}
} catch {}
})
const copied = ref(false)
let timer
const copy = async () => {
try {
await navigator.clipboard.writeText(dockerCommand)
copied.value = true
clearTimeout(timer)
timer = setTimeout(() => (copied.value = false), 1600)
} catch {}
}
onBeforeUnmount(() => clearTimeout(timer))
</script>
<template>
<section class="og-hero">
<a
class="og-badge"
href="https://github.com/thomiceli/opengist/releases"
target="_blank"
rel="noopener noreferrer"
>
<span class="og-badge__dot" />
{{ version }} released: Restful API + brand new docs page
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="og-badge__arrow">
<path stroke-linecap="round" stroke-linejoin="round" d="m9 6 6 6-6 6" />
</svg>
</a>
<img
class="og-logo"
src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg"
alt="Opengist"
/>
<h1 class="og-title">
<span class="og-title__shine">Opengist</span>
</h1>
<p class="og-tagline">
Self-hosted and open-source pastebin powered by Git
</p>
<div class="og-actions">
<a class="og-btn og-btn--primary" href="/docs">Get started</a>
<a class="og-btn" href="https://demo.opengist.io" target="_blank" rel="noopener noreferrer">
Live demo
</a>
<a
class="og-btn og-btn--github"
href="https://github.com/thomiceli/opengist"
target="_blank"
rel="noopener noreferrer"
aria-label="Opengist on GitHub"
>
<svg class="og-btn__gh" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8" />
</svg>
<span v-if="formattedStars" class="og-btn__stars">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="m12 17.27 6.18 3.73-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
{{ formattedStars }}
</span>
</a>
</div>
<div class="og-install">
<span class="og-install__prompt">$</span>
<code class="og-install__cmd">{{ dockerCommand }}</code>
<button class="og-install__copy" type="button" aria-label="Copy command" @click="copy">
<svg v-if="copied" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m5 13 4 4L19 7" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
</button>
</div>
</section>
</template>
<style scoped>
.og-hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 2.5rem 1.5rem 2rem;
border-bottom: 1px solid var(--vp-c-divider);
}
.og-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.9rem;
border-radius: 999px;
font-size: 0.825rem;
font-weight: 500;
color: var(--vp-c-brand-1);
background: var(--vp-c-brand-soft);
transition: filter 0.2s ease;
}
.og-badge:hover {
filter: brightness(1.05);
}
.og-badge__dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
background: var(--vp-c-brand-1);
}
.og-badge__arrow {
width: 0.85rem;
height: 0.85rem;
}
.og-logo {
width: 4.5rem;
height: 4.5rem;
margin: 2rem 0 1.25rem;
}
.og-title {
margin: 0;
font-size: clamp(1.25rem, 6vw, 2.75rem);
line-height: 1.18;
font-weight: 800;
letter-spacing: -0.02em;
}
.og-title__shine {
display: block;
background: linear-gradient(
110deg,
var(--vp-c-text-1) 0%,
var(--vp-c-text-1) 40%,
var(--vp-c-brand-1) 50%,
var(--vp-c-text-1) 60%,
var(--vp-c-text-1) 100%
);
background-size: 250% 100%;
background-position: 100% 0;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: og-shine 6s ease-in-out 0.3s 1 forwards;
}
@keyframes og-shine {
to {
background-position: 0 0;
}
}
.og-tagline {
max-width: 34rem;
margin: 1.2rem 0 0;
font-size: 1.35rem;
line-height: 1;
color: var(--vp-c-text-1);
}
.og-license {
margin: 0.85rem 0 0;
font-size: 0.85rem;
color: var(--vp-c-text-3);
}
.og-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 2rem;
}
.og-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.875rem;
padding: 0 1.5rem;
border-radius: 0.625rem;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
font-size: 0.95rem;
font-weight: 600;
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.og-btn:hover {
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
}
.og-btn--primary {
border-color: transparent;
background: var(--vp-c-brand-3);
color: #fff;
}
.og-btn--primary:hover {
background: var(--vp-c-brand-2);
color: #fff;
}
.og-btn--icon {
width: 2.875rem;
padding: 0;
}
.og-btn--icon svg {
width: 1.35rem;
height: 1.35rem;
}
/* GitHub button with star count. */
.og-btn--github {
gap: 0.6rem;
padding: 0 1.1rem;
}
.og-btn__gh {
width: 1.35rem;
height: 1.35rem;
}
.og-btn__stars {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding-left: 0.6rem;
border-left: 1px solid var(--vp-c-divider);
font-variant-numeric: tabular-nums;
}
.og-btn__stars svg {
width: 0.95rem;
height: 0.95rem;
color: #e3b341;
}
.og-install {
display: flex;
align-items: center;
gap: 0.75rem;
max-width: 100%;
margin-top: 2rem;
padding: 0.6rem 0.6rem 0.6rem 1rem;
border-radius: 0.75rem;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-alt);
font-family: var(--vp-font-family-mono);
}
.og-install__prompt {
color: var(--vp-c-brand-1);
user-select: none;
}
.og-install__cmd {
flex: 1;
min-width: 0; /* allow the long command to shrink + scroll instead of overflowing */
overflow-x: auto;
white-space: nowrap;
font-size: 0.85rem;
color: var(--vp-c-text-1);
background: transparent;
padding: 0;
}
.og-install__copy {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 0.5rem;
color: var(--vp-c-text-2);
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
transition: color 0.2s ease, border-color 0.2s ease;
}
.og-install__copy:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
.og-install__copy svg {
width: 1.05rem;
height: 1.05rem;
}
@media (max-width: 640px) {
.og-hero {
padding: 2.5rem 1.25rem 2.5rem;
}
.og-install {
width: 100%;
}
}
@media (min-width: 640px) {
.og-hero {
padding-top: 3.5rem;
}
}
</style>
@@ -0,0 +1,75 @@
<script setup>
import { useData, withBase } from 'vitepress'
const { isDark } = useData()
</script>
<template>
<section class="og-showcase">
<div class="og-showcase__text">
<h2 class="og-showcase__title">A clean, familiar interface</h2>
<p class="og-showcase__desc">
Create, browse and share snippets with syntax highlighting, revisions and more.
</p>
</div>
<div class="og-showcase__frame">
<img
v-show="!isDark"
class="og-showcase__img"
:src="withBase('/opengist-demo.png')"
alt="Opengist web interface"
/>
<img
v-show="isDark"
class="og-showcase__img"
:src="withBase('/opengist-demo-dark.png')"
alt="Opengist web interface"
/>
</div>
</section>
</template>
<style scoped>
.og-showcase {
padding: 3rem 1.5rem;
text-align: center;
}
@media (max-width: 640px) {
.og-showcase {
padding: 2rem 1.25rem;
}
}
.og-showcase__text {
max-width: 34rem;
margin: 0 auto 2rem;
}
.og-showcase__title {
margin: 0;
border: 0;
font-size: 1.6rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.01em;
}
.og-showcase__desc {
margin: 0.5rem 0 0;
font-size: 0.95rem;
color: var(--vp-c-text-2);
}
/* Smaller, centered screenshot frame. */
.og-showcase__frame {
max-width: 72rem;
margin: 0 auto;
border-radius: 0.75rem;
overflow: hidden;
}
.og-showcase__img {
display: block;
width: 100%;
height: auto;
padding: 0;
}
</style>
+6 -1
View File
@@ -2,11 +2,16 @@ import { h } from 'vue'
import type { Theme } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import Layout from "./Layout.vue";
import OpenApiOperation from "./components/OpenApiOperation.vue";
import OpenApiSchema from "./components/OpenApiSchema.vue";
import './style.css'
import './openapi.css'
export default {
...DefaultTheme,
Layout,
enhanceApp({ app, router, siteData }) {
// ...
app.component('OpenApiOperation', OpenApiOperation)
app.component('OpenApiSchema', OpenApiSchema)
}
} satisfies Theme
+45
View File
@@ -0,0 +1,45 @@
<script setup>
import Hero from '../components/home/Hero.vue'
import Showcase from '../components/home/Showcase.vue'
import Features from '../components/home/Features.vue'
// Add further sections here as the landing page grows (e.g. Features, FAQ).
</script>
<template>
<main class="og-home">
<div class="og-wrapper">
<Hero />
<Features />
<Showcase />
<!-- Empty growing section so the side borders run to the bottom. -->
<div class="og-fill" />
</div>
</main>
</template>
<style scoped>
.og-home {
width: 100%;
}
/* Centered content column framed with vertical borders (vite-plus style).
Sections inside add their own border-bottom to act as horizontal rules.
Width matches the navbar container so the home content aligns with the header. */
.og-wrapper {
display: flex;
flex-direction: column;
max-width: var(--vp-layout-max-width);
margin: 0 auto;
/* Fill the viewport below the navbar so the side borders run to the bottom
even when the content is shorter than the screen. */
min-height: calc(100vh - var(--vp-nav-height));
/* Gap inside the frame, so the left/right borders stay visible through it. */
padding-bottom: 2rem;
border-inline: var(--og-frame-border) solid var(--vp-c-divider);
}
/* Empty section that absorbs the remaining height, extending the side borders. */
.og-fill {
flex: 1;
}
</style>
+138
View File
@@ -0,0 +1,138 @@
/* Shared styling for the generated API reference components. */
.oas-op h3,
.oas-schema h3 {
display: flex;
align-items: center;
gap: 0.6rem;
}
.oas-method {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.15rem 0.5rem;
border-radius: 5px;
color: #fff;
}
.oas-method.m-get { background: #1f7a4d; }
.oas-method.m-post { background: #1860c2; }
.oas-method.m-put { background: #b06800; }
.oas-method.m-patch { background: #8754c9; }
.oas-method.m-delete { background: #c0392b; }
/* Endpoint shown as a code block: method badge on the left, then the path. */
.oas-endpoint {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 0 1.5rem;
padding: 0.6rem 0.9rem;
background: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow-x: auto;
}
/* Plain path text (no inline-code bg/color); only `.oas-param` is highlighted. */
.vp-doc code.oas-path {
font-size: 0.95rem;
background: none;
color: var(--vp-c-text-1);
padding: 0;
white-space: nowrap;
}
/* Highlight `{param}` segments within the path. */
.oas-param {
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
padding: 0 0.25rem;
border-radius: 4px;
}
.oas-req {
margin-left: 0.4rem;
font-size: 0.7rem;
font-weight: 600;
color: var(--vp-c-red-1);
text-transform: uppercase;
}
.oas-opt {
margin-left: 0.4rem;
font-size: 0.7rem;
font-weight: 600;
color: var(--vp-c-text-3);
text-transform: uppercase;
}
.oas-ct {
margin-left: 0.5rem;
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
/* `.vp-doc table` is display:block / fit-content, so force full-width here. */
.vp-doc table.oas-table {
display: table;
width: 100%;
margin: 0.5rem 0 1rem;
}
.oas-table th { text-align: left; }
.oas-status { font-weight: 700; }
.oas-status.s-2 { color: #1f7a4d; }
.oas-status.s-4,
.oas-status.s-5 { color: #c0392b; }
.oas-headers td {
padding-top: 0;
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.oas-hlabel { margin-right: 0.4rem; }
.oas-header { margin-right: 0.4rem; }
.oas-includes a { margin-right: 0.5rem; }
.oas-note { font-size: 0.85rem; color: var(--vp-c-text-2); }
.oas-bodyhead { display: flex; align-items: center; gap: 0.5rem; }
.oas-field { display: inline-flex; align-items: center; gap: 0.35rem; }
.oas-nest { color: var(--vp-c-text-3); }
.oas-nested { background: var(--vp-c-bg-soft); }
/* ---- three-pane endpoint layout (pages with pageClass: api-page) ---- */
.api-page .VPDoc .container { max-width: 1180px; }
.api-page .VPDoc.has-sidebar .content { max-width: none; }
.api-page .VPDoc .content-container { max-width: none; }
.oas-op-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 26rem;
gap: 2.5rem;
align-items: start;
}
.oas-main { min-width: 0; }
.oas-main h1 { margin-top: 0; }
.oas-code-sticky {
position: sticky;
top: calc(var(--vp-nav-height) + 1.5rem);
}
.oas-code-label {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0 0.4rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--vp-c-text-2);
}
.oas-code-label:first-child { margin-top: 0; }
/* Smaller, denser code in the side panel. VitePress applies
`font-size: var(--vp-code-font-size)` to the <code>, so override the var. */
.oas-code {
--vp-code-font-size: 0.75rem;
--vp-code-line-height: 1.5;
}
.oas-code-block [class*='language-'] { margin: 0; }
.oas-code-block pre {
white-space: pre;
padding: 14px 16px;
}
/* Collapse to a single column when there isn't room for the side panel. */
@media (max-width: 1280px) {
.oas-op-grid { grid-template-columns: 1fr; }
.oas-code-sticky { position: static; }
}
+184 -7
View File
@@ -1,3 +1,22 @@
@import "tailwindcss";
/* Map Tailwind's `dark:` variant onto VitePress's `.dark` class. */
@custom-variant dark (&:where(.dark, .dark *));
/* Opengist brand gray palette (replaces Tailwind's default gray). */
@theme {
--color-gray-50: #EEEFF1;
--color-gray-100: #DEDFE3;
--color-gray-200: #BABCC5;
--color-gray-300: #999CA8;
--color-gray-400: #75798A;
--color-gray-500: #585B68;
--color-gray-600: #464853;
--color-gray-700: #363840;
--color-gray-800: #232429;
--color-gray-900: #131316;
}
/**
* Customize default theme styling by overriding CSS variables:
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
@@ -49,10 +68,11 @@
--vp-c-default-3: var(--vp-c-gray-3);
--vp-c-default-soft: var(--vp-c-gray-soft);
--vp-c-brand-1: var(--vp-c-indigo-1);
--vp-c-brand-2: var(--vp-c-indigo-2);
--vp-c-brand-3: var(--vp-c-indigo-3);
--vp-c-brand-soft: var(--vp-c-indigo-soft);
/* Opengist brand blue (matches --color-primary-* in the app). */
--vp-c-brand-1: #3c79e2;
--vp-c-brand-2: #356fc0;
--vp-c-brand-3: #3c79e2;
--vp-c-brand-soft: rgba(60, 121, 226, 0.14);
--vp-c-tip-1: var(--vp-c-brand-1);
--vp-c-tip-2: var(--vp-c-brand-2);
@@ -70,6 +90,19 @@
--vp-c-danger-soft: var(--vp-c-red-soft);
}
/* Dark theme background (brand gray-900). */
.dark {
--vp-c-bg: #131316;
--vp-c-bg-alt: #0f0f12;
--vp-c-bg-elv: #1a1a1e;
/* Lighter brand blue for contrast on dark backgrounds. */
--vp-c-brand-1: #74a4f6;
--vp-c-brand-2: #588fee;
--vp-c-brand-3: #3c79e2;
--vp-c-brand-soft: rgba(116, 164, 246, 0.16);
}
/**
* Component: Button
* -------------------------------------------------------------------------- */
@@ -142,6 +175,150 @@
height: 108px;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Home: drop VitePress's bottom margin on the home container so our framed
wrapper (and its side borders) reaches the very bottom of the page. */
.VPHome {
margin-bottom: 0 !important;
}
/**
* Navbar: left-align the menu (Docs / Resources) next to the logo,
* keeping the appearance toggle and social links on the right.
* -------------------------------------------------------------------------- */
@media (min-width: 768px) {
/* Drop VitePress's `margin-right: -100vw; padding-right: 100vw` background
bleed: it makes the flex free space negative, which neutralises the auto
margin below. The bar's own background covers the width anyway. */
.VPNavBar .content-body {
margin-right: 0 !important;
padding-right: 0 !important;
}
/* The (empty) search slot has flex-grow:1 and would eat all the free space,
leaving none for the menu's auto margin. Stop it from growing. */
.VPNavBar .VPNavBarSearch {
flex-grow: 0 !important;
}
.VPNavBar .VPNavBarMenu {
margin-right: auto !important;
}
}
/* Keep the navbar identical on the landing page and the docs pages:
VitePress otherwise makes it transparent and borderless on the home page. */
.VPNavBar {
background-color: var(--vp-nav-bg-color) !important;
}
/* Bottom border: match the side borders' style (2px, --vp-c-divider) and keep
it the same width as the container instead of spanning the whole page. */
.VPNavBar .divider {
max-width: var(--vp-layout-max-width);
margin-inline: auto;
}
.VPNavBar .divider-line {
height: var(--og-frame-border);
background-color: var(--vp-c-divider) !important;
}
/* Drop the bottom border under the logo/title (shown on docs pages). */
.VPNavBarTitle.has-sidebar .title {
border-bottom-color: transparent !important;
}
/* Remove the small vertical divider lines between the menu, theme toggle
(.appearance) and social links in the navbar. */
.VPNavBar .menu + .translations::before,
.VPNavBar .menu + .appearance::before,
.VPNavBar .menu + .social-links::before,
.VPNavBar .translations + .appearance::before,
.VPNavBar .appearance + .social-links::before {
display: none !important;
}
/* The dividers also provided the spacing between modules; add it back. */
.VPNavBar .appearance,
.VPNavBar .social-links {
margin-left: 12px;
}
/* Match the home content layout: full --vp-layout-max-width container centered
(native margin: 0 auto), with no side padding on the wrapper. The container
carries the same vertical borders as the home .og-wrapper, with 32px of
inner padding so the nav content sits inside the borders. */
.VPNavBar .wrapper {
padding-inline: 0 !important;
}
.VPNavBar .container {
max-width: var(--vp-layout-max-width) !important;
padding-inline: 32px;
border-inline: var(--og-frame-border) solid var(--vp-c-divider);
}
/* On docs pages the navbar gets `.has-sidebar`, which offsets the title to the
sidebar width and changes the container. Undo it so the navbar lays out the
same as on the landing page (centered, max-width container). */
@media (min-width: 960px) {
.VPNavBar.has-sidebar .wrapper {
padding: 0 !important;
}
.VPNavBar.has-sidebar .container {
max-width: var(--vp-layout-max-width) !important;
}
.VPNavBar.has-sidebar .title {
position: static !important;
width: auto !important;
padding: 0 !important;
}
.VPNavBar.has-sidebar .content {
padding-left: 0 !important;
padding-right: 0 !important;
}
.VPNavBar.has-sidebar .divider {
padding-left: 0 !important;
}
}
/* Horizontal section bar (Guide / API) below the top navbar. Desktop only;
on mobile the top-nav menu provides section switching. */
:root {
--og-bar-height: 48px;
/* Frame border width, between 1px and 2px without using px (0.1rem ≈ 1.6px). */
--og-frame-border: 0.09rem;
}
@media (min-width: 960px) {
/* Push the sidebar, main content and outline down by the bar height. */
.VPSidebar {
padding-top: calc(var(--vp-nav-height) + var(--og-bar-height)) !important;
}
.VPContent.has-sidebar {
padding-top: calc(var(--vp-nav-height) + var(--og-bar-height)) !important;
}
.VPDoc {
--vp-doc-top-height: var(--og-bar-height);
}
}
/* Continue the navbar/home frame down the docs pages: the sidebar gets the
outer-left border and the sidebar/content divider, and the content (.VPDoc)
gets the outer-right border. Same style as the rest of the frame. */
@media (min-width: 960px) {
.VPSidebar {
border-left: var(--og-frame-border) solid var(--vp-c-divider);
border-right: var(--og-frame-border) solid var(--vp-c-divider);
}
.VPDoc {
border-right: var(--og-frame-border) solid var(--vp-c-divider);
}
}
/* At >=1440px the layout is centered, so pull the sidebar box in to start at the
frame's left edge (same X as the navbar's left border) instead of the viewport
edge. Content/nav positions are unchanged; only the box origin and width move. */
@media (min-width: 1440px) {
.VPSidebar {
left: calc((100% - var(--vp-layout-max-width)) / 2) !important;
width: var(--vp-sidebar-width) !important;
padding-left: 32px !important;
}
}
+114
View File
@@ -0,0 +1,114 @@
---
aside: true
---
# Opengist API Reference
Opengist exposes a REST API authenticated with Personal Access Tokens, intended
for programmatic access to gist and user resources.
The base URL for the API is
```
https://opengist.example.com/api/
```
> OpenAPI 3.1 spec is available at
> [`openapi.yaml`](api/openapi.yaml).
>
> A running instance also serves the raw spec at `GET /api/openapi.yaml`.
## Getting an access token
The API authenticates with a Personal Access Token. To create one:
1. Go to **Settings**
2. Select the **Access Tokens** menu
3. Choose a name, select the scopes the token should grant and an optional
expiration date, then click **Create Access Token**
4. Copy the token (starting with `og_`). It is shown only once.
Tokens carry per-resource scopes, each at read or read/write level:
| Scope | Grants |
|-------|--------|
| `gist:read` | Read gists, including the caller's private and unlisted ones |
| `gist:write` | Create, update, delete and fork gists |
| `user:read` | Read the authenticated user's account |
| `user:write` | Update the authenticated user and toggle likes |
## Authentication
Send the token in the `Authorization` header using the `Bearer` scheme:
```
Authorization: Bearer og_xxxxxxxx
```
Each endpoint documents the scope it requires in its **Headers** section.
Note that every endpoint requires authentication when an admin enables the "Require login" setting, which both works for the API and the web interface.
The single gist endpoints are available without authentication when an admin enables "Allow individual gists without login" setting.
## Schema
The API lives under the `/api/` prefix on the same host as your Opengist
instance. All data is sent and received as JSON.
Every endpoint responds with JSON unless specified otherwise
```
Content-Type: application/json
```
All timestamps are returned in ISO 8601 / RFC 3339 format, in UTC:
```
2024-01-01T00:00:00Z
```
## Pagination
List endpoints (such as `GET /gists`) return a JSON array and page the results.
Tune the window with these query parameters:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | integer | `1` | Page number, 1-based. |
| `per_page` | integer | `30` | Items per page (maximum `100`). |
| `since` | string (date-time) | — | Gist lists only: return only gists updated at or after this RFC 3339 timestamp. |
Pagination metadata is returned in the response headers:
| Header | Description |
|--------|-------------|
| `Link` | RFC 5988 links to other pages: `rel="next"` (when more pages exist) and `rel="prev"` (when `page > 1`). |
| `X-Page` | The current page number (1-based). |
| `X-Per-Page` | Items per page. |
| `X-Total` | Total number of items across all pages. |
| `X-Total-Pages` | Total number of pages, i.e. `ceil(total / per_page)`. |
The `Link` header is formatted as follows:
```
Link: <https://opengist.example/api/gists?page=2&per_page=30>; rel="next",
<https://opengist.example/api/gists?page=1&per_page=30>; rel="prev"
```
## Disabling the API
The API is **enabled by default**. To disable it, define it in the config as follows
### YAML
```yaml
api.enabled: false
```
### Environment variable
```shell
OG_API_ENABLED=false
```
While disabled, the routing layer returns `403` for every endpoint until it is
enabled again. Disabling does not revoke issued tokens — they resume working
once the API is turned back on.
+10
View File
@@ -0,0 +1,10 @@
---
aside: false
pageClass: api-page
---
<script setup>
import { useData } from 'vitepress'
const { params } = useData()
</script>
<OpenApiOperation :id="params.id" />
+10
View File
@@ -0,0 +1,10 @@
import { listOperations } from '../.vitepress/openapi'
// One generated page per operation, keyed by operationId.
export default {
paths() {
return listOperations().map((op) => ({
params: { operation: op.id, id: op.id, title: `${op.method} ${op.path}` }
}))
}
}
+9
View File
@@ -0,0 +1,9 @@
---
aside: false
---
<script setup>
import { useData } from 'vitepress'
const { params } = useData()
</script>
<OpenApiSchema :name="params.name" />
+8
View File
@@ -0,0 +1,8 @@
import { listSchemas } from '../../.vitepress/openapi'
// One generated page per component schema, keyed by its name.
export default {
paths() {
return listSchemas().map((name) => ({ params: { schema: name, name } }))
}
}
+6
View File
@@ -0,0 +1,6 @@
---
description: Release notes and version history for Opengist — new features, fixes and changes in every release.
outline: [2, 2]
---
<!--@include: ../CHANGELOG.md-->
+7 -2
View File
@@ -15,18 +15,21 @@ aside: false
| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
| search.default | OG_SEARCH_DEFAULT | `content` | Set the default search fields. Can contain multiple fields (e.g., `content,username`). Fields: `content,user,title,description,filename,extension,language,topic`. |
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) |
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
| api.enabled | OG_API_ENABLED | `true` | Enable or disable the REST API. (`true` or `false`) |
| unix-socket-permissions | OG_UNIX_SOCKET_PERMISSIONS | `0666` | File permissions for Unix socket (octal format). |
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics endpoint at `/metrics` (`true` or `false`) |
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics server (`true` or `false`) |
| metrics.host | OG_METRICS_HOST | `0.0.0.0` | The host on which the metrics server should bind. |
| metrics.port | OG_METRICS_PORT | `6158` | The port on which the metrics server should listen. |
| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) |
| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. |
| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. |
| ssh.external-domain | OG_SSH_EXTERNAL_DOMAIN | none | Public domain for the Git SSH connection, if it has to be different from the HTTP one. If not set, uses the URL from the request. |
| ssh.keygen-executable | OG_SSH_KEYGEN_EXECUTABLE | `ssh-keygen` | Path to the SSH key generation executable. |
| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. |
| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. |
| gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. |
@@ -41,6 +44,8 @@ aside: false
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
| oidc.group-claim-name | OG_OIDC_GROUP_CLAIM_NAME | none | Name of the claim containing the groups. |
| oidc.admin-group | OG_OIDC_ADMIN_GROUP | none | Name of the group that should receive admin rights. |
| ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled |
| ldap.bind-dn | OG_LDAP_BIND_DN | none | Bind DN to authenticate against the LDAP. e.g: cn=read-only-admin,dc=example,dc=com |
| ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. |
+2 -2
View File
@@ -27,7 +27,7 @@ Usage via command line :
./opengist --config /path/to/config.yml
```
You can start by copying and/or modifying the provided [config.yml](https://github.com/thomiceli/opengist/blob/stable/config.yml) file.
You can start by copying and/or modifying the provided [config.yml](https://github.com/thomiceli/opengist/blob/master/config.yml) file.
## Configuration via Environment Variables
@@ -69,4 +69,4 @@ services:
secrets:
opengist_secrets:
file: ./secrets.env
```
```
+23 -13
View File
@@ -4,10 +4,10 @@ Opengist offers built-in support for Prometheus metrics to help you monitor the
## Enabling metrics
By default, the metrics endpoint is disabled for security and performance reasons. To enable it, update your configuration as stated in the [configuration cheat sheet](cheat-sheet.md):
By default, the metrics server is disabled for security and performance reasons. To enable it, update your configuration as stated in the [configuration cheat sheet](cheat-sheet.md):
```yaml
metrics.enabled = true
metrics.enabled: true
```
Alternatively, you can use the environment variable:
@@ -16,7 +16,25 @@ Alternatively, you can use the environment variable:
OG_METRICS_ENABLED=true
```
Once enabled, metrics are available at the /metrics endpoint.
Once enabled, metrics are available on a separate server at `http://0.0.0.0:6158/metrics` by default.
## Configuration
The metrics server runs on a separate port from the main application. By default, it binds to `0.0.0.0` (all interfaces) on port `6158`.
| Config Key | Environment Variable | Default | Description |
|----------------|---------------------|-------------|------------------------------------------------|
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable the metrics server |
| metrics.host | OG_METRICS_HOST | `0.0.0.0` | The host on which the metrics server binds |
| metrics.port | OG_METRICS_PORT | `6158` | The port on which the metrics server listens |
Example configuration:
```yaml
metrics.enabled: true
metrics.host: 0.0.0.0
metrics.port: 6158
```
## Available metrics
@@ -36,14 +54,6 @@ These standard metrics follow the Prometheus naming convention and include label
## Security Considerations
The metrics endpoint exposes information about your Opengist instance that might be sensitive in some environments. Consider using a reverse proxy with authentication for the `/metrics` endpoint if your Opengist instance is publicly accessible.
The metrics server binds to `0.0.0.0` by default, making it accessible on all network interfaces. This default works well for containerized deployments (Docker, Kubernetes) where network isolation is handled at the infrastructure level.
Example with Nginx:
```shell
location /metrics {
auth_basic "Metrics";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://localhost:6157/metrics;
}
```
For bare-metal or VM deployments where the metrics port may be exposed, consider restricting to localhost by setting `metrics.host: 127.0.0.1` to only allow local access.
+3 -2
View File
@@ -25,13 +25,14 @@ Opengist is now running on port 6157, you can browse http://localhost:6157
Requirements:
* [Git](https://git-scm.com/downloads) (2.28+)
* [Go](https://go.dev/doc/install) (1.23+)
* [Node.js](https://nodejs.org/en/download/) (16+)
* [Go](https://go.dev/doc/install) (1.25+)
* [Node.js](https://nodejs.org/en/download/) (20+)
* [Make](https://linux.die.net/man/1/make) (optional, but easier)
```shell
git clone git@github.com:thomiceli/opengist.git
cd opengist
make install
make watch
```
+3 -1
View File
@@ -1,4 +1,6 @@
---
layout: home
navbar: false
title: Opengist — self-hosted pastebin powered by Git
titleTemplate: false
description: Opengist is an open-source, self-hosted pastebin powered by Git — a lightweight alternative to GitHub Gist that you host and fully control.
---
+5
View File
@@ -1,7 +1,12 @@
---
description: Install Opengist with Docker, Kubernetes, a prebuilt binary, or from source — pick the method that fits your environment.
---
# Install Opengist
There are several ways to install Opengist, depending on your preferences and your environment.
- [Docker](installation/docker.md)
- [Kubernetes](installation/kubernetes.md)
- [Source](installation/source.md)
- [Binary](installation/binary.md)
+2 -2
View File
@@ -4,9 +4,9 @@ Download the archive for your system from the release page [here](https://github
```shell
# example for linux amd64
wget https://github.com/thomiceli/opengist/releases/download/v1.11.0/opengist1.11.0-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.13.1/opengist1.13.1-linux-amd64.tar.gz
tar xzvf opengist1.11.0-linux-amd64.tar.gz
tar xzvf opengist1.13.1-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`
+25
View File
@@ -39,3 +39,28 @@ services:
UID: 1001
GID: 1001
```
## Rootless
By default the container starts as `root` and the entrypoint drops privileges to the
user defined by `UID`/`GID` (see above).
If you'd rather have the container run as a
non-root user from the start — for example with `user:` in Compose, or under rootless
Docker/Podman — set the `user` key instead:
```yml
services:
opengist:
# ...
user: "1001:1001"
volumes:
- "./opengist-data:/opengist"
```
In this mode the entrypoint runs Opengist directly as that user.
Create the Opengist data directory and own it on the host first:
```shell
mkdir -p ./opengist-data && sudo chown -R 1001:1001 ./opengist-data
```
+3 -3
View File
@@ -2,15 +2,15 @@
Requirements:
* [Git](https://git-scm.com/downloads) (2.28+)
* [Go](https://go.dev/doc/install) (1.23+)
* [Node.js](https://nodejs.org/en/download/) (16+)
* [Go](https://go.dev/doc/install) (1.25+)
* [Node.js](https://nodejs.org/en/download/) (20+)
* [Make](https://linux.die.net/man/1/make) (optional, but easier)
```shell
git clone https://github.com/thomiceli/opengist
cd opengist
git checkout v1.11.0 # optional, to checkout the latest release
git checkout v1.13.1
make
./opengist
+5 -1
View File
@@ -1,6 +1,10 @@
---
description: Opengist is a self-hosted pastebin powered by Git. Snippets are stored in Git repositories and managed via standard Git commands or the web interface.
---
# Opengist
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg" alt="Opengist" align="right" />
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg" alt="Opengist" align="right" />
Opengist is a **self-hosted** pastebin **powered by Git**. All snippets are stored in a Git repository and can be
read and/or modified using standard Git commands, or with the web interface.
+3367
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "opengist-docs",
"version": "0.0.0",
"private": true,
"description": "Documentation site for Opengist, built with VitePress.",
"type": "module",
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"tailwindcss": "^4.2.4",
"vitepress": "^2.0.0-alpha.17",
"vue": "^3.5.27",
"yaml": "^2.9.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 666 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://opengist.io/sitemap.xml
+2 -2
View File
@@ -27,9 +27,9 @@ Stop the running instance; then like your first installation of Opengist, downlo
```shell
# example for linux amd64
wget https://github.com/thomiceli/opengist/releases/download/v1.11.0/opengist1.11.0-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.13.1/opengist1.13.1-linux-amd64.tar.gz
tar xzvf opengist1.11.0-linux-amd64.tar.gz
tar xzvf opengist1.13.1-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`
+26
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
```
+17
View File
@@ -1,5 +1,10 @@
# Embed a Gist to your webpage
> [!Tip]
> Fancy to enforce light or dark mode on the embedded Gist?
> Just append `?light` or `?dark` to the Gist-URL.
> Omitting this parameter will cause OpenGist to fallback to `auto`, thus the Browser deciding on the users preference.
To embed a Gist to your webpage, you can add a script tag with the URL of your gist followed by `.js` to your HTML page:
```html
@@ -7,5 +12,17 @@ To embed a Gist to your webpage, you can add a script tag with the URL of your g
<!-- Dark mode: -->
<script src="http://opengist.url/user/gist-url.js?dark"></script>
<!-- Light mode: -->
<script src="http://opengist.url/user/gist-url.js?light"></script>
```
If you have a Gist that holds several different files, you can also explicitely call a specific file by its filename:
```html
<script src="http://opengist.url/user/gist-url.js?file=filename"></script>
<!-- Dark mode: -->
<script src="http://opengist.url/user/gist-url.js?file=filename&dark"></script>
<!-- Light mode: -->
<script src="http://opengist.url/user/gist-url.js?file=filename&light"></script>
```
+17
View File
@@ -30,3 +30,20 @@ git push -o visibility=public
git push -o visibility=unlisted
git push -o visibility=private
```
## Change topics
```shell
git push -o topics="golang devops"
```
## Set expiration
Only applies when creating a gist. The value is either a preset
(`1hour`, `12hours`, `1day`, `7days`, `15days`) or a custom date
(RFC3339, e.g. `2026-01-02T15:04:05Z`).
```shell
git push -o expire=1day
git push -o expire=2026-01-02T15:04:05Z
```
+79 -80
View File
@@ -1,129 +1,128 @@
module github.com/thomiceli/opengist
go 1.23.0
go 1.26.4
require (
github.com/Kunde21/markdownfmt/v3 v3.1.0
github.com/alecthomas/chroma/v2 v2.20.0
github.com/blevesearch/bleve/v2 v2.5.0
github.com/alecthomas/chroma/v2 v2.26.1
github.com/blevesearch/bleve/v2 v2.6.0
github.com/dustin/go-humanize v1.0.1
github.com/gabriel-vasile/mimetype v1.4.8
github.com/gabriel-vasile/mimetype v1.4.13
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.8
github.com/go-playground/validator/v10 v10.26.0
github.com/go-webauthn/webauthn v0.12.3
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.4.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/labstack/echo-contrib v0.17.3
github.com/labstack/echo/v4 v4.13.3
github.com/markbates/goth v1.81.0
github.com/meilisearch/meilisearch-go v0.31.0
github.com/pquerna/otp v1.4.0
github.com/prometheus/client_golang v1.21.1
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.6
github.com/yuin/goldmark v1.7.8
github.com/yuin/goldmark-emoji v1.0.5
github.com/labstack/echo-contrib v0.50.1
github.com/labstack/echo/v4 v4.15.2
github.com/markbates/goth v1.82.0
github.com/meilisearch/meilisearch-go v0.36.3
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-emoji v1.0.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/mermaid v0.5.0
golang.org/x/crypto v0.36.0
golang.org/x/text v0.23.0
go.abhg.dev/goldmark/mermaid v0.6.0
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.38.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/RoaringBitmap/roaring/v2 v2.18.2 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blevesearch/bleve_index_api v1.2.7 // indirect
github.com/blevesearch/geo v0.1.20 // indirect
github.com/blevesearch/go-faiss v1.0.25 // indirect
github.com/bits-and-blooms/bitset v1.24.5 // indirect
github.com/blevesearch/bleve_index_api v1.3.12 // indirect
github.com/blevesearch/geo v0.2.5 // indirect
github.com/blevesearch/go-faiss v1.1.4 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 // indirect
github.com/blevesearch/mmap-go v1.2.0 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.4.7 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.1 // indirect
github.com/blevesearch/zapx/v12 v12.4.1 // indirect
github.com/blevesearch/zapx/v13 v13.4.1 // indirect
github.com/blevesearch/zapx/v14 v14.4.1 // indirect
github.com/blevesearch/zapx/v15 v15.4.1 // indirect
github.com/blevesearch/zapx/v16 v16.2.2 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/blevesearch/vellum v1.2.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.3 // indirect
github.com/blevesearch/zapx/v12 v12.4.3 // indirect
github.com/blevesearch/zapx/v13 v13.4.3 // indirect
github.com/blevesearch/zapx/v14 v14.4.3 // indirect
github.com/blevesearch/zapx/v15 v15.4.3 // indirect
github.com/blevesearch/zapx/v16 v16.3.4 // indirect
github.com/blevesearch/zapx/v17 v17.1.6 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // 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.3.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/go-webauthn/x v0.1.20 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.4 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/labstack/gommon v0.5.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-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // 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/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
modernc.org/libc v1.62.1 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.73.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
modernc.org/sqlite v1.37.0 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.52.0 // indirect
)
+204 -253
View File
@@ -1,139 +1,141 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0=
github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc=
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/RoaringBitmap/roaring/v2 v2.18.2 h1:oPq3Cgx//iDuJQVp6xSInAKW34J9CEwE5GmLI2z+Eic=
github.com/RoaringBitmap/roaring/v2 v2.18.2/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4=
github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk=
github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY=
github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/bits-and-blooms/bitset v1.24.5 h1:654xBVHc23gJMAgOTkPNoCVfiRxuIOAUnAZFtopqJ4w=
github.com/bits-and-blooms/bitset v1.24.5/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.6.0 h1:Cyd3dd4q5tCbOV8MnKUVRUDYMHOir9xn12NZzXVSEd4=
github.com/blevesearch/bleve/v2 v2.6.0/go.mod h1:gLmI8lWgHgrIYf7UpUX7JISI1CaqC6VScu46mHThuAY=
github.com/blevesearch/bleve_index_api v1.3.12 h1:MirVNltwGq8z0PhOgiQp+bKL5qq8OvCxEwOOC7NnHNE=
github.com/blevesearch/bleve_index_api v1.3.12/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE=
github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4=
github.com/blevesearch/go-faiss v1.1.4 h1:wGHK+yiOSIvBAQMr4LcTaHBFf9v1dBebs3WpFqT93Rg=
github.com/blevesearch/go-faiss v1.1.4/go.mod h1:w3W9AiWsFRGVaMG+/cmJi7iHEAuGyC6blsgO1EzCK/M=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 h1:X6nJXnNHl7nasXW+U6y2Ns2Aw8F9STszkYkyBfQ+p0o=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9/go.mod h1:IrzspZlVjhf4X29oJiEhBxEteTqOY9RlYlk1lCmYHr4=
github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
github.com/blevesearch/scorch_segment_api/v2 v2.4.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw=
github.com/blevesearch/scorch_segment_api/v2 v2.4.7/go.mod h1://IJ7tG3QCf0cWW/aVSXqy77tc1AvLu3fcJLYEvOAFs=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zapx/v11 v11.4.1 h1:qFCPlFbsEdwbbckJkysptSQOsHn4s6ZOHL5GMAIAVHA=
github.com/blevesearch/zapx/v11 v11.4.1/go.mod h1:qNOGxIqdPC1MXauJCD9HBG487PxviTUUbmChFOAosGs=
github.com/blevesearch/zapx/v12 v12.4.1 h1:K77bhypII60a4v8mwvav7r4IxWA8qxhNjgF9xGdb9eQ=
github.com/blevesearch/zapx/v12 v12.4.1/go.mod h1:QRPrlPOzAxBNMI0MkgdD+xsTqx65zbuPr3Ko4Re49II=
github.com/blevesearch/zapx/v13 v13.4.1 h1:EnkEMZFUK0lsW/jOJJF2xOcp+W8TjEsyeN5BeAZEYYE=
github.com/blevesearch/zapx/v13 v13.4.1/go.mod h1:e6duBMlCvgbH9rkzNMnUa9hRI9F7ri2BRcHfphcmGn8=
github.com/blevesearch/zapx/v14 v14.4.1 h1:G47kGCshknBZzZAtjcnIAMn3oNx8XBLxp8DMq18ogyE=
github.com/blevesearch/zapx/v14 v14.4.1/go.mod h1:O7sDxiaL2r2PnCXbhh1Bvm7b4sP+jp4unE9DDPWGoms=
github.com/blevesearch/zapx/v15 v15.4.1 h1:B5IoTMUCEzFdc9FSQbhVOxAY+BO17c05866fNruiI7g=
github.com/blevesearch/zapx/v15 v15.4.1/go.mod h1:b/MreHjYeQoLjyY2+UaM0hGZZUajEbE0xhnr1A2/Q6Y=
github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc6r0Rzk=
github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk=
github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY=
github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo=
github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE=
github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM=
github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU=
github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0=
github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg=
github.com/blevesearch/zapx/v17 v17.1.6 h1:rVGeyH0EPElBXM4PvjrCdt8LDdRLpa4GC1gMRQkCWUE=
github.com/blevesearch/zapx/v17 v17.1.6/go.mod h1:c+mPvbZgZnDPOUS5Z9EXhntMcJnpIVjQTM9TF5yEGJM=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 h1:wMSvdj3BswqfQOXp2R1bJOAE7xIQLt2dlMQDMf836VY=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0=
github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
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.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY=
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw=
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3 h1:8COTSTFIIXnaD81+kfCw4dRANNAKuCp06EdYLqwX30g=
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -145,23 +147,22 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -180,8 +181,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -192,31 +191,24 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo-contrib v0.17.3 h1:hj+qXksKZG1scSe9ksUXMtv7fZYN+PtQT+bPcYA3/TY=
github.com/labstack/echo-contrib v0.17.3/go.mod h1:TcRBrzW8jcC4JD+5Dc/pvOyAps0rtgzj7oBqoR3nYsc=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/labstack/echo-contrib v0.50.1 h1:W9cZZ9viA4TDdFtm8cuA+XGFwOcnfbjJpl7VgfsRLHE=
github.com/labstack/echo-contrib v0.50.1/go.mod h1:8r/++U/Fw/QniApFnzunLanKaviPfBX7fX7/2QX0qOk=
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE=
github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/meilisearch/meilisearch-go v0.31.0 h1:yZRhY1qJqdH8h6GFZALGtkDLyj8f9v5aJpsNMyrUmnY=
github.com/meilisearch/meilisearch-go v0.31.0/go.mod h1:aNtyuwurDg/ggxQIcKqWH6G9g2ptc8GyY7PLY4zMn/g=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meilisearch/meilisearch-go v0.36.3 h1:Yx1aTY5jDgtbStPVkhJTDoLnZTy5sejQSPyjfNMy6e4=
github.com/meilisearch/meilisearch-go v0.36.3/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -226,177 +218,136 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 h1:9bCMuD3TcnjeqjPT2gSlha4asp8NvgcFRYExCaikCxk=
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25/go.mod h1:eDjgYHYDJbPLBLsyZ6qRaugP0mX8vePOhZ5id1fdzJw=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/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.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.abhg.dev/goldmark/mermaid v0.5.0 h1:mDkykpSPJ+5wCQ8bSXgzJ2KQskjXkI5Ndxz7JYDHW38=
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY=
go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/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.73.0 h1:Y/KmTxbIN5T3x+NFjYOzV/+Ha7wKClfIecmTCTuYlqQ=
modernc.org/libc v1.73.0/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+42
View File
@@ -0,0 +1,42 @@
# Helm Chart Changelog
# 0.9.0 - 2026-06-10
- Bump Opengist image to 1.13.1
# 0.8.0 - 2026-06-09
- Bump Opengist image to 1.13.0
# 0.7.0 - 2026-03-14
- Bump Opengist image to 1.12.2
- Add environment variables and secrets to statefulset
## 0.6.0 - 2026-02-03
- Bump Opengist image to 1.12.1
## 0.5.0 - 2026-01-27
- Bump Opengist image to 1.12.0
- Add StatefulSet support
- Add Prometheus ServiceMonitor support if Opengist metrics are enabled
- New service for metrics endpoint, dissociated from the main service
- Use existing pvc claim of provided
## 0.4.0 - 2025-09-30
- Bump Opengist image to 1.11.1
## 0.3.0 - 2025-09-21
- Bump Opengist image to 1.11.0
## 0.2.0 - 2025-05-10
- Add `deployment.env[]` in values
## 0.1.0 - 2025-04-06
- Initial release, with Opengist image 1.10.0
+3 -3
View File
@@ -4,6 +4,6 @@ dependencies:
version: 16.7.27
- name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.17.1
digest: sha256:ad702e35f258fed1f804d3e48b071767499f5730e099a8c461610950e5182368
generated: "2025-09-21T04:49:08.679554149+02:00"
version: 0.26.0
digest: sha256:7182bad3df032b3cb21a793ea6b027eaa96e142ff207b607b62df974bc82de90
generated: "2026-03-09T03:39:04.820136+07:00"
+4 -4
View File
@@ -2,10 +2,10 @@ apiVersion: v2
name: opengist
description: Opengist Helm chart for Kubernetes
type: application
version: 0.3.0
appVersion: 1.11.0
version: 0.9.0
appVersion: 1.13.1
home: https://opengist.io
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg
sources:
- https://github.com/thomiceli/opengist
dependencies:
@@ -15,5 +15,5 @@ dependencies:
condition: postgresql.enabled
- name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.17.1
version: 0.26.0
condition: meilisearch.enabled
+372 -2
View File
@@ -1,11 +1,12 @@
# Opengist Helm Chart
![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![AppVersion: 1.11.0](https://img.shields.io/badge/AppVersion-1.11.0-informational?style=flat-square)
![Version: 0.8.0](https://img.shields.io/badge/Version-0.8.0-informational?style=flat-square) ![AppVersion: 1.13.1](https://img.shields.io/badge/AppVersion-1.13.1-informational?style=flat-square)
Opengist Helm chart for Kubernetes.
Opengist Helm chart for Kubernetes. Check [CHANGELOG.md](CHANGELOG.md) for release notes.
* [Install](#install)
* [Configuration](#configuration)
* [Metrics & Monitoring](#metrics--monitoring)
* [Dependencies](#dependencies)
* [Meilisearch Indexer](#meilisearch-indexer)
* [PostgreSQL Database](#postgresql-database)
@@ -47,6 +48,76 @@ If defined, this existing secret will be used instead of creating a new one.
configExistingSecret: <name of the secret>
```
## Metrics & Monitoring
Opengist exposes Prometheus metrics on a separate port (default: `6158`). The metrics server runs independently from the main HTTP server for security.
### Enabling Metrics
To enable metrics, set `metrics.enabled: true` in your Opengist config:
```yaml
config:
metrics.enabled: true
```
This will:
1. Start a metrics server on port 6158 inside the container
2. Create a Kubernetes Service exposing the metrics ports
### Available Metrics
| Metric Name | Type | Description |
|-------------|------|-------------|
| `opengist_users_total` | Gauge | Total number of registered users |
| `opengist_gists_total` | Gauge | Total number of gists |
| `opengist_ssh_keys_total` | Gauge | Total number of SSH keys |
| `opengist_request_duration_seconds_*` | Histogram | HTTP request duration metrics |
### ServiceMonitor for Prometheus Operator
If you're using [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator), you can enable automatic service discovery with a ServiceMonitor:
```yaml
config:
metrics.enabled: true
service:
metrics:
serviceMonitor:
enabled: true
labels:
release: prometheus # match your Prometheus serviceMonitorSelector
```
### Manual Prometheus Configuration
If you're not using Prometheus Operator, you can configure Prometheus to scrape the metrics endpoint directly:
```yaml
scrape_configs:
- job_name: 'opengist'
static_configs:
- targets: ['opengist-metrics:6158']
metrics_path: /metrics
```
Or use Kubernetes service discovery:
```yaml
scrape_configs:
- job_name: 'opengist'
kubernetes_sd_configs:
- role: service
relabel_configs:
- source_labels: [__meta_kubernetes_service_label_app_kubernetes_io_component]
regex: metrics
action: keep
- source_labels: [__meta_kubernetes_service_label_app_kubernetes_io_name]
regex: opengist
action: keep
```
## Dependencies
### Meilisearch Indexer
@@ -66,6 +137,40 @@ index.meili.api-key: MASTER_KEY # generated by Meilisearch
If you want to use the `bleve` indexer, you need to set the `replicas` to `1`.
#### Passing Meilisearch configuration via nested Helm values
When using the Helm CLI with `--set`, avoid mixing a scalar `config.index` value with nested `config.index.meili.*` keys. Instead use a nested map and a `type` field which the chart flattens automatically. Example:
```bash
helm template opengist ./helm/opengist \
--set statefulSet.enabled=true \
--set replicaCount=2 \
--set persistence.enabled=true \
--set persistence.existingClaim=opengist-shared-rwx \
--set postgresql.enabled=false \
--set config.db-uri="postgres://user:pass@db-host:5432/opengist" \
--set meilisearch.enabled=true \
--set config.index.type=meilisearch \
--set config.index.meili.host="http://opengist-meilisearch:7700" \
--set config.index.meili.api-key="MASTER_KEY"
```
Rendered `config.yml` fragment:
```yaml
index: meilisearch
index.meili.host: http://opengist-meilisearch:7700
index.meili.api-key: MASTER_KEY
```
How it works:
* You provide a map under `config.index` with keys `type` and `meili`.
* The template detects `config.index.type` and rewrites `index: <type>`.
* Nested `config.index.meili.host` / `api-key` are lifted to flat keys `index.meili.host` and `index.meili.api-key` required by Opengist.
If you set `--set config.index=meilisearch` directly and also try to set `--set config.index.meili.host=...`, Helm will first create the nested structure then overwrite it with the scalar, losing the host. Always prefer the `config.index.type` pattern for CLI usage.
### PostgreSQL Database
By default, Opengist uses the `sqlite` database. If needed, this chart also deploys a PostgreSQL instance.
@@ -79,3 +184,268 @@ Then define the connection string in your Opengist config:
db-uri: postgres://user:password@opengist-postgresql:5432/opengist
```
Note: `opengist-postgresql` is the name of the K8S Service deployed by this chart.
### Database Configuration
You can supply an externally managed database connection explicitly via `config.db-uri` (PostgreSQL/MySQL) or enable the bundled PostgreSQL subchart.
Behavior:
* If `postgresql.enabled: true` and `config.db-uri` is omitted, the chart auto-generates:
`postgres://<username>:<password>@<release-name>-postgresql:<port>/<database>` using values under `postgresql.global.postgresql.auth.*`.
* If any of username/password/database are missing, templating fails fast with an error message.
* If you prefer an external database or a different Postgres distribution, set `postgresql.enabled: false` and provide `config.db-uri` yourself.
**Licensing note**: Bitnami's PostgreSQL distribution may have licensing constraints. For strictly open alternatives use an external managed PostgreSQL/MySQL service and disable the subchart.
### Multi-Replica Requirements
Running more than one Opengist replica (Deployment or StatefulSet) requires:
1. Non-SQLite database (`config.db-uri` must start with `postgres://` or `mysql://`).
2. Shared RWX storage if using StatefulSet with `replicaCount > 1` (provide `persistence.existingClaim`). The chart now fails fast if you attempt `replicaCount > 1` without an explicit shared claim to prevent silent data divergence across perpod PVCs.
The chart will fail fast during templating if these conditions are not met when scaling above 1 replica.
Examples:
* External PostgreSQL:
```yaml
postgresql:
enabled: false
config:
db-uri: postgres://user:pass@db-host:5432/opengist
index: meilisearch
statefulSet:
enabled: true
replicaCount: 2
persistence:
existingClaim: opengist-shared-rwx
```
Bundled PostgreSQL (auto db-uri):
```yaml
postgresql:
enabled: true
config:
index: meilisearch
statefulSet:
enabled: true
replicaCount: 2
persistence:
existingClaim: opengist-shared-rwx
```
#### Recovering from an initial misconfiguration
If you previously scaled a StatefulSet above 1 replica **without** an `existingClaim`, each pod received its own PVC and only one held the authoritative `/opengist` data. To consolidate:
1. Scale down to 1 replica (keep the pod with the desired data):
```bash
kubectl scale sts/opengist --replicas=1
```
1. (Optional) Inspect other PVCs and manually copy any missing files by temporarily attaching them to a debug pod.
1. Create or provision a ReadWriteMany (NFS / CephFS / Longhorn RWX / etc.) PersistentVolumeClaim named (for example) `opengist-shared-rwx`.
1. Update values with `persistence.existingClaim: opengist-shared-rwx` and redeploy.
1. Scale back up:
```bash
kubectl scale sts/opengist --replicas=2
```
Going forward, all replicas mount the same shared volume and data remains consistent.
### Quick Start Examples
Common deployment scenarios with copy-paste configurations:
#### Scenario 1: Single replica with SQLite (default)
Minimal local development setup with ephemeral or persistent storage:
```yaml
# Ephemeral (emptyDir)
statefulSet:
enabled: true
replicaCount: 1
persistence:
enabled: false
# OR with persistent RWO storage
statefulSet:
enabled: true
replicaCount: 1
persistence:
enabled: true
mode: perReplica # default
```
#### Scenario 2: Multi-replica with external PostgreSQL + existing RWX PVC
Production HA setup with your own database and storage:
```yaml
statefulSet:
enabled: true
replicaCount: 2
postgresql:
enabled: false
config:
db-uri: "postgres://user:pass@db-host:5432/opengist"
index: meilisearch # required for multi-replica
persistence:
enabled: true
mode: shared
existingClaim: "opengist-shared-rwx" # pre-created RWX PVC
meilisearch:
enabled: true
```
#### Scenario 3: Multi-replica with bundled PostgreSQL + auto-created RWX PVC
Chart manages both database and storage:
```yaml
statefulSet:
enabled: true
replicaCount: 2
postgresql:
enabled: true
global:
postgresql:
auth:
username: opengist
password: changeme
database: opengist
config:
index: meilisearch
persistence:
enabled: true
mode: shared
existingClaim: "" # empty to trigger auto-creation
create:
enabled: true
accessModes: [ReadWriteMany]
storageClass: "nfs-client" # your RWX-capable storage class
size: 20Gi
meilisearch:
enabled: true
```
### Persistence Modes
The chart supports two persistence strategies controlled by `persistence.mode`:
| Mode | Behavior | Scaling | Storage Objects | Recommended Use |
|-------------|----------|---------|-----------------|-----------------|
| `perReplica` (default) | One PVC per pod via StatefulSet `volumeClaimTemplates` (RWO) when no `existingClaim` | Safe only at `replicaCount=1` unless you supply `existingClaim` | One PVC per replica | Local dev, quick single-node trials |
| `shared` | Single RWX PVC (existing or auto-created) mounted by all pods | Horizontally scalable | One shared PVC | Production / HA |
Configuration examples:
Per-replica (single node):
```yaml
statefulSet:
enabled: true
persistence:
mode: perReplica
enabled: true
accessModes:
- ReadWriteOnce
```
Shared (scale ready) with an existing RWX claim:
```yaml
statefulSet:
enabled: true
replicaCount: 2
persistence:
mode: shared
existingClaim: opengist-shared-rwx
```
Shared with chart-created RWX PVC:
```yaml
statefulSet:
enabled: true
replicaCount: 2
persistence:
mode: shared
existingClaim: "" # leave empty
create:
enabled: true
accessModes: [ReadWriteMany]
size: 10Gi
```
When `mode=shared` and `existingClaim` is empty, the chart creates a single PVC named `<release>-shared` (suffix configurable via `persistence.create.nameSuffix`).
Fail-fast conditions:
* `replicaCount>1` & missing external DB (still enforced).
* `replicaCount>1` & persistence disabled.
* `replicaCount>1` & neither `existingClaim` nor `mode=shared`.
* `mode=shared` & create.enabled=true but `accessModes` lacks `ReadWriteMany`.
Migration (perReplica → shared): scale down to 1, create RWX claim (or rely on create.enabled), copy data, switch mode to shared, scale up.
### Troubleshooting
#### Common Errors and Solutions
##### Error: "replicaCount=2 requires PostgreSQL/MySQL config.db-uri; scheme 'sqlite' unsupported"
* **Cause**: Multi-replica with SQLite database
* **Solution**: Either scale down to `replicaCount: 1` or configure external database:
```yaml
config:
db-uri: "postgres://user:pass@host:5432/opengist"
```
##### Error: "replicaCount=2 requires either persistence.existingClaim OR persistence.mode=shared"
* **Cause**: Multi-replica without shared storage
* **Solution**: Choose one approach:
```yaml
# Option A: Use existing PVC
persistence:
existingClaim: "my-rwx-pvc"
# Option B: Let chart create PVC
persistence:
mode: shared
create:
enabled: true
accessModes: [ReadWriteMany]
```
##### Error: "persistence.mode=shared create.accessModes must include ReadWriteMany for multi-replica"
* **Cause**: Chart-created PVC lacks RWX access mode
* **Solution**: Ensure RWX is specified:
```yaml
persistence:
create:
accessModes:
- ReadWriteMany
```
##### Pods mount different data (data divergence)
* **Cause**: Previously scaled with `perReplica` mode and `replicaCount > 1`
* **Solution**: Follow recovery steps in "Recovering from an initial misconfiguration" section above
##### PVC creation fails: "no storage class available with ReadWriteMany"
* **Cause**: Cluster lacks RWX-capable storage provisioner
* **Solution**: Install a storage provider (NFS, CephFS, Longhorn) or use external managed storage and provide `existingClaim`
+25
View File
@@ -1,3 +1,4 @@
{{- if not .Values.statefulSet.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -62,10 +63,28 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if or .Values.deployment.env .Values.deployment.envFromSecrets }}
env:
{{- if .Values.deployment.env }}
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
{{- range .Values.deployment.envFromSecrets }}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName }}
key: {{ .secretKey }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.http.port }}
protocol: TCP
{{- if index .Values.config "metrics.enabled" }}
- name: metrics
containerPort: {{ .Values.service.metrics.port }}
protocol: TCP
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}
@@ -95,7 +114,11 @@ spec:
- name: opengist-data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
{{- if .Values.persistence.existingClaim }}
claimName: {{ .Values.persistence.existingClaim }}
{{- else }}
claimName: {{ include "opengist.fullname" . }}-data
{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
@@ -120,3 +143,5 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
+48
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 }}
+2 -2
View File
@@ -1,4 +1,4 @@
{{- if .Values.persistence.enabled }}
{{- if and .Values.persistence.enabled (not .Values.statefulSet.enabled) (not .Values.persistence.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
@@ -25,4 +25,4 @@ spec:
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
{{- end }}
+50 -1
View File
@@ -1,4 +1,53 @@
{{- if (not .Values.configExistingSecret) }}
{{- $cfg := deepCopy .Values.config }}
{{- /* Backward compatibility: map db-uri (deprecated) to db-uri key still expected by app, also accept dbUri coming from user */}}
{{- if and (hasKey $cfg "dbUri") (not (hasKey $cfg "db-uri")) }}
{{- $_ := set $cfg "db-uri" (index $cfg "dbUri") }}
{{- end }}
{{- $dburi := default "" (index $cfg "db-uri") }}
{{- /* Flatten possible nested index.meili.* structure if user passed --set config.index.meili.host=... */}}
{{- if and (hasKey $cfg "index") (kindIs "map" (index $cfg "index")) }}
{{- $indexMap := (index $cfg "index") }}
{{- if hasKey $indexMap "type" }}
{{- $_ := set $cfg "index" (index $indexMap "type") }}
{{- end }}
{{- if hasKey $indexMap "meili" }}
{{- $meili := (index $indexMap "meili") }}
{{- if hasKey $meili "host" }}
{{- $_ := set $cfg "index.meili.host" (index $meili "host") }}
{{- end }}
{{- if hasKey $meili "api-key" }}
{{- $_ := set $cfg "index.meili.api-key" (index $meili "api-key") }}
{{- end }}
{{- end }}
{{- end }}
{{- if and .Values.postgresql.enabled (eq $dburi "") }}
{{- $user := default "" .Values.postgresql.global.postgresql.auth.username }}
{{- $pass := default "" .Values.postgresql.global.postgresql.auth.password }}
{{- $db := default "" .Values.postgresql.global.postgresql.auth.database }}
{{- $port := int (default 5432 .Values.postgresql.global.postgresql.service.ports.postgresql) }}
{{- if or (eq $user "") (eq $pass "") (eq $db "") }}
{{- fail "postgresql.enabled=true requires username/password/database (postgresql.global.postgresql.auth.*) or set config.db-uri manually" }}
{{- end }}
{{- $autoHost := printf "%s-postgresql" (include "opengist.fullname" .) }}
{{- $autoUri := printf "postgres://%s:%s@%s:%d/%s" $user $pass $autoHost $port $db }}
{{- $_ := set $cfg "db-uri" $autoUri }}
{{- end }}
{{- $replicas := int .Values.replicaCount }}
{{- $index := default "" (index $cfg "index") }}
{{- /* Auto-set Meilisearch host if subchart enabled and host missing */}}
{{- $meiliHost := default "" (index $cfg "index.meili.host") }}
{{- if and .Values.meilisearch.enabled (eq $meiliHost "") }}
{{- $autoMeiliHost := printf "http://%s-meilisearch:7700" (include "opengist.fullname" .) }}
{{- $_ := set $cfg "index.meili.host" $autoMeiliHost }}
{{- if or (eq $index "") (ne $index "meilisearch") }}
{{- $_ := set $cfg "index" "meilisearch" }}
{{- $index = "meilisearch" }}
{{- end }}
{{- end }}
{{- if and (gt $replicas 1) (or (eq $index "") (eq $index "bleve")) }}
{{- fail "replicaCount>1 requires index set to 'meilisearch' (bleve not supported with multiple replicas)" }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
@@ -9,5 +58,5 @@ metadata:
type: Opaque
stringData:
config.yml: |-
{{- .Values.config | toYaml | nindent 4 }}
{{- $cfg | toYaml | nindent 4 }}
{{- end }}
@@ -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 }}
+280
View File
@@ -0,0 +1,280 @@
{{- if .Values.statefulSet.enabled }}
{{- /*
========================================
VALIDATION BLOCK: Multi-replica requirements
========================================
Enforces constraints for scaling beyond 1 replica:
1. Database: Must use PostgreSQL/MySQL (not SQLite)
2. Persistence: Must be enabled
3. Storage sharing: Must use either existingClaim or mode=shared with create.enabled
4. Access mode: For mode=shared + create, must specify ReadWriteMany
*/}}
{{- $replicas := int .Values.replicaCount }}
{{- $dburi := "" }}
{{- if and .Values.config (hasKey .Values.config "dbUri") }}
{{- $dburi = (index .Values.config "dbUri") }}
{{- else if and .Values.config (hasKey .Values.config "db-uri") }}
{{- $dburi = (index .Values.config "db-uri") }}
{{- end }}
{{- $scheme := "" }}
{{- if ne $dburi "" }}
{{- $parts := splitList "://" $dburi }}
{{- if gt (len $parts) 0 }}
{{- $scheme = lower (index $parts 0) }}
{{- end }}
{{- end }}
{{- $multiAllowed := or (eq $scheme "postgres") (eq $scheme "postgresql") (eq $scheme "mysql") (eq $scheme "mariadb") }}
{{- $p := .Values.persistence }}
{{- $mode := default "perReplica" $p.mode }}
{{- $hasExisting := ne (default "" $p.existingClaim) "" }}
{{- $isShared := eq $mode "shared" }}
{{- /* Fail fast: Database validation */}}
{{- if and (gt $replicas 1) (not $multiAllowed) }}
{{- fail (printf "replicaCount=%d requires PostgreSQL/MySQL config.db-uri; scheme '%s' unsupported" $replicas $scheme) }}
{{- end }}
{{- /* Fail fast: Persistence must be enabled */}}
{{- if and (gt $replicas 1) (not $p.enabled) }}
{{- fail (printf "replicaCount=%d requires persistence.enabled=true" $replicas) }}
{{- end }}
{{- /* Fail fast: Prevent per-replica PVC divergence */}}
{{- if and (gt $replicas 1) (not (or $hasExisting $isShared)) }}
{{- fail (printf "replicaCount=%d requires either persistence.existingClaim (shared RWX PVC) OR persistence.mode=shared to create one; perReplica PVCs would diverge" $replicas) }}
{{- end }}
{{- /* Fail fast: Shared mode requires PVC source */}}
{{- if and (gt $replicas 1) $isShared (not $hasExisting) (hasKey $p "create") (not (get $p.create "enabled")) }}
{{- fail (printf "persistence.mode=shared but neither existingClaim nor create.enabled=true provided") }}
{{- end }}
{{- /* Fail fast: Auto-created shared PVC must be RWX */}}
{{- if and (gt $replicas 1) $isShared (not $hasExisting) $p.create.enabled }}
{{- $am := list }}
{{- if hasKey $p.create "accessModes" }}
{{- $am = $p.create.accessModes }}
{{- end }}
{{- $rwxOk := false }}
{{- range $am }}
{{- if or (eq . "ReadWriteMany") (eq . "RWX") }}
{{- $rwxOk = true }}
{{- end }}
{{- end }}
{{- if not $rwxOk }}
{{- fail "persistence.mode=shared create.accessModes must include ReadWriteMany for multi-replica" }}
{{- end }}
{{- end }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "opengist.fullname" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- if .Values.deployment.labels }}
{{- toYaml .Values.deployment.labels | nindent 4 }}
{{- end }}
{{- with .Values.deployment.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.replicaCount }}
serviceName: {{ include "opengist.fullname" . }}-http
podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }}
updateStrategy:
{{- toYaml .Values.statefulSet.updateStrategy | nindent 4 }}
selector:
matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "opengist.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- if .Values.deployment.terminationGracePeriodSeconds }}
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "opengist.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
initContainers:
- name: init-config
image: busybox:1.37
imagePullPolicy: IfNotPresent
command: ['sh', '-c', 'cp /init/config/config.yml /config-volume/config.yml']
volumeMounts:
- name: config-secret
mountPath: /init/config
- name: config-volume
mountPath: /config-volume
{{- if .Values.deployment.env }}
env:
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if or .Values.deployment.env .Values.deployment.envFromSecrets }}
env:
{{- if .Values.deployment.env }}
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
{{- range .Values.deployment.envFromSecrets }}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName }}
key: {{ .secretKey }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.http.port }}
protocol: TCP
{{- if .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 }}
+32
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 }}
+114 -8
View File
@@ -8,6 +8,7 @@ namespace: ""
config:
log-level: "warn"
log-output: "stdout"
metrics.enabled: false
## If defined, the existing secret will be used instead of creating a new one.
## The secret must contain a key named `config.yml` with the YAML configuration.
@@ -17,7 +18,7 @@ configExistingSecret: ""
image:
repository: ghcr.io/thomiceli/opengist
pullPolicy: Always
tag: "1.10.0"
tag: "1.13.1"
digest: ""
imagePullSecrets: []
# - name: "image-pull-secret"
@@ -32,12 +33,44 @@ strategy:
maxSurge: "100%"
maxUnavailable: 0
## StatefulSet configuration
## Enables StatefulSet workload instead of Deployment (required for volumeClaimTemplates or stable pod identities).
##
## Single-replica SQLite example (default behavior):
## statefulSet.enabled: true
## replicaCount: 1
## persistence.mode: perReplica # or omit (default)
## # Creates one PVC per pod via volumeClaimTemplates (RWO)
##
## Multi-replica requirements (replicaCount > 1):
## 1. External database: config.db-uri must be postgres:// or mysql:// (SQLite NOT supported)
## 2. Shared storage: Use ONE of:
## a) Existing claim: persistence.existingClaim: "my-rwx-pvc"
## b) Chart-created: persistence.mode: shared + persistence.create.enabled: true + accessModes: [ReadWriteMany]
## 3. Chart will FAIL FAST if constraints are not met to prevent data divergence
##
## Persistence decision tree:
## - persistence.existingClaim set → mount that PVC directly (no volumeClaimTemplates)
## - persistence.mode=shared + create.* → chart creates single RWX PVC, all pods mount it
## - persistence.mode=perReplica (default) → volumeClaimTemplates (one PVC/pod, RWO typically)
## - persistence.enabled=false → emptyDir (ephemeral)
statefulSet:
enabled: false
podManagementPolicy: OrderedReady
updateStrategy:
type: RollingUpdate
## Security Context settings
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
podSecurityContext:
fsGroup: 1000
securityContext: {}
# allowPrivilegeEscalation: false
# runAsUser: 1000
# runAsGroup: 1000
# runAsNonRoot: true
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
## Pod Disruption Budget settings
## ref: https://kubernetes.io/docs/tasks/run-application/configure-pdb/
@@ -73,6 +106,26 @@ service:
loadBalancerSourceRanges: []
externalTrafficPolicy:
# A metrics K8S service on port 6158 is created when the Opengist config metrics.enabled: true
metrics:
type: ClusterIP
clusterIP:
port: 6158
nodePort:
labels: {}
annotations: {}
# A service monitor can be used to work with your Prometheus setup.
serviceMonitor:
enabled: true
labels: {}
# release: kube-prom-stack
interval:
scrapeTimeout:
annotations: {}
relabelings: []
metricRelabelings: []
## HTTP Ingress for Opengist
## ref: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress:
@@ -99,20 +152,66 @@ serviceAccount:
annotations: {}
name: ""
## Set persistence using a Persistent Volume Claim
## If more than 2 replicas are set, the access mode must be ReadWriteMany
## Persistent storage for /opengist data directory
## ref: https://kubernetes.io/docs/concepts/storage/persistent-volumes/
persistence:
enabled: true
## Persistence mode controls how storage is provisioned:
##
## perReplica (DEFAULT):
## - StatefulSet creates one PVC per replica via volumeClaimTemplates
## - Typically RWO (ReadWriteOnce) storage
## - Safe ONLY for replicaCount=1 (multi-replica causes data divergence)
## - Use when: single-node dev/test, no horizontal scaling needed
##
## shared:
## - Single RWX (ReadWriteMany) PVC shared by all replicas
## - Required for replicaCount > 1
## - Two provisioning paths:
## a) existingClaim: "my-rwx-pvc" (you manage the PVC lifecycle)
## b) existingClaim: "" + create.enabled: true (chart creates PVC automatically)
## - Use when: multi-replica HA, horizontal scaling, shared file access
##
## WARNING: Switching modes after initial deploy requires manual data migration:
## 1. Scale down to 1 replica
## 2. Create/provision RWX PVC and copy data
## 3. Update values: mode=shared, existingClaim or create.enabled
## 4. Scale up
mode: perReplica
## Reference an existing PVC (takes precedence over create.*)
## When set:
## - Chart will NOT create a PVC
## - StatefulSet mounts this claim directly (no volumeClaimTemplates)
## - Must be RWX for replicaCount > 1
## Example: existingClaim: "opengist-shared-rwx"
existingClaim: ""
storageClass: ""
## Common persistence parameters (apply to perReplica mode OR as defaults for create.*)
storageClass: "" # Empty = cluster default
labels: {}
annotations:
helm.sh/resource-policy: keep
helm.sh/resource-policy: keep # Prevents PVC deletion on helm uninstall
size: 5Gi
accessModes:
- ReadWriteOnce
subPath: ""
- ReadWriteOnce # perReplica default; override to [ReadWriteMany] if using existingClaim
subPath: "" # Optional subpath within volume
## Chart-managed PVC creation (ONLY for mode=shared when existingClaim is empty)
## Renders templates/pvc-shared.yaml
create:
enabled: true
nameSuffix: shared # PVC name: <release-name>-shared
storageClass: "" # Empty = cluster default; override if you need specific storage class
size: 5Gi # Override top-level persistence.size if needed
accessModes:
- ReadWriteMany # REQUIRED for multi-replica; NFS/CephFS/Longhorn RWX/etc.
labels: {}
annotations: {}
## Example for specific storage:
## storageClass: "nfs-client"
## size: 20Gi
extraVolumes: []
extraVolumeMounts: []
@@ -163,6 +262,13 @@ autoscaling:
## Additional deployment configuration
deployment:
env: []
## Load environment variables from specific secret keys
## Each entry creates an env.valueFrom.secretKeyRef in the container spec
## ref: https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-environment-variables
envFromSecrets: []
# - name: OG_OIDC_SECRET
# secretName: opengist-oidc-client-secret
# secretKey: client_secret
terminationGracePeriodSeconds: 60
labels: {}
annotations: {}
+76 -51
View File
@@ -1,21 +1,20 @@
package actions
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"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"
"os"
"path/filepath"
"strings"
"sync"
)
type ActionStatus struct {
Running bool
}
const (
SyncReposFromFS = iota
SyncReposFromDB
@@ -24,63 +23,71 @@ const (
ResetHooks
IndexGists
SyncGistLanguages
DeleteExpiredGists
numActions // keep last — sizes the `running` array
)
var (
mutex sync.Mutex
actions = make(map[int]ActionStatus)
)
// running tracks which actions are in progress in this instance, one slot per
// action type. It dedupes concurrent runs (e.g. a double-clicked admin button)
// and backs IsRunning for the admin panel; cross-instance single-flighting is
// handled separately by the DB action lock.
var running [numActions]atomic.Bool
func updateActionStatus(actionType int, running bool) {
actions[actionType] = ActionStatus{
Running: running,
}
const lockLease = time.Hour
type action struct {
run func()
spec string
}
var registry = map[int]action{
SyncReposFromFS: {run: syncReposFromFS},
SyncReposFromDB: {run: syncReposFromDB},
GitGcRepos: {run: gitGcRepos},
SyncGistPreviews: {run: syncGistPreviews},
ResetHooks: {run: resetHooks},
IndexGists: {run: indexGists},
SyncGistLanguages: {run: syncGistLanguages},
DeleteExpiredGists: {run: deleteExpiredGists, spec: "@every 1m"},
}
func IsRunning(actionType int) bool {
mutex.Lock()
defer mutex.Unlock()
return actions[actionType].Running
return actionType >= 0 && actionType < numActions && running[actionType].Load()
}
func Run(actionType int) {
mutex.Lock()
if actions[actionType].Running {
mutex.Unlock()
func RunOnce(actionType int) {
a, ok := registry[actionType]
if !ok {
log.Error().Msgf("Unknown action type %d", actionType)
return
}
updateActionStatus(actionType, true)
mutex.Unlock()
if !running[actionType].CompareAndSwap(false, true) {
return // already running in this instance
}
defer running[actionType].Store(false)
// Single-flight the action across instances sharing the database so only
// one replica runs it at a time, whether triggered by the scheduler or
// manually.
acquired, err := db.AcquireLock(actionType, lockLease)
if err != nil {
log.Error().Err(err).Msgf("Could not acquire lock for action %d", actionType)
return
}
if !acquired {
return
}
defer func() {
mutex.Lock()
updateActionStatus(actionType, false)
mutex.Unlock()
if err := db.ReleaseLock(actionType); err != nil {
log.Error().Err(err).Msgf("Could not release lock for action %d", actionType)
}
}()
var functionToRun func()
switch actionType {
case SyncReposFromFS:
functionToRun = syncReposFromFS
case SyncReposFromDB:
functionToRun = syncReposFromDB
case GitGcRepos:
functionToRun = gitGcRepos
case SyncGistPreviews:
functionToRun = syncGistPreviews
case ResetHooks:
functionToRun = resetHooks
case IndexGists:
functionToRun = indexGists
case SyncGistLanguages:
functionToRun = syncGistLanguages
default:
log.Error().Msg("Unknown action type")
}
functionToRun()
log.Info().Msgf("Starting running action %d", actionType)
a.run()
log.Info().Msgf("Finished running action %d", actionType)
}
func syncReposFromFS() {
@@ -136,6 +143,7 @@ func syncGistPreviews() {
return
}
for _, gist := range gists {
fmt.Println("Syncing preview for gist", gist.ID)
if err = gist.UpdatePreviewAndCount(false); err != nil {
log.Error().Err(err).Msgf("Cannot update preview and count for gist %d", gist.ID)
}
@@ -150,7 +158,12 @@ func resetHooks() {
}
func indexGists() {
log.Info().Msg("Indexing all Gists...")
log.Info().Msg("Rebuilding index from scratch...")
if err := index.ResetIndex(); err != nil {
log.Error().Err(err).Msg("Cannot reset index")
return
}
gists, err := db.GetAllGistsRows()
if err != nil {
log.Error().Err(err).Msg("Cannot get gists")
@@ -183,3 +196,15 @@ func syncGistLanguages() {
gist.UpdateLanguages()
}
}
func deleteExpiredGists() {
gists, err := db.DeleteExpiredGists()
if err != nil {
log.Error().Err(err).Msg("Cannot delete expired gists")
return
}
if len(gists) > 0 {
log.Info().Msgf("Deleted %d expired gist(s)", len(gists))
}
}
+49
View File
@@ -0,0 +1,49 @@
package actions
import (
"time"
"github.com/robfig/cron/v3"
"github.com/rs/zerolog/log"
)
const cronDrainTimeout = 10 * time.Second
// StartCron registers every scheduled action in `registry` (those with a spec)
// and starts the scheduler. It returns a stop function that halts the scheduler
// and waits (up to cronDrainTimeout) for any in-flight job to finish — call it
// before tearing down the DB so a running action can release its lock cleanly.
// Panicking jobs are recovered so a single failed run can't take down the server.
func StartCron() (stop func()) {
c := cron.New(cron.WithChain(cron.Recover(cronLogger{})))
for actionType, a := range registry {
if a.spec == "" {
continue
}
if _, err := c.AddFunc(a.spec, func() { RunOnce(actionType) }); err != nil {
log.Error().Err(err).Msgf("Invalid cron spec %q for action %d", a.spec, actionType)
}
}
c.Start()
return func() {
log.Info().Msg("Stopping crons...")
select {
case <-c.Stop().Done():
case <-time.After(cronDrainTimeout):
log.Warn().Msg("cron: timed out waiting for jobs to finish")
}
}
}
type cronLogger struct{}
func (cronLogger) Info(msg string, _ ...interface{}) {
log.Info().Msgf("cron: %s", msg)
}
func (cronLogger) Error(err error, msg string, _ ...interface{}) {
log.Error().Err(err).Msgf("cron: %s", msg)
}
+7 -31
View File
@@ -2,16 +2,15 @@ package oauth
import (
gocontext "context"
gojson "encoding/json"
"net/http"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/gitea"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"io"
"net/http"
)
type GiteaProvider struct {
@@ -80,34 +79,11 @@ func (p *GiteaCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
func (p *GiteaCallbackProvider) UpdateUserDB(user *db.User) {
user.GiteaID = p.User.UserID
user.AvatarURL = p.User.AvatarURL
}
resp, err := http.Get(urlJoin(config.C.GiteaUrl, "/api/v1/users/", p.User.UserID))
if err != nil {
log.Error().Err(err).Msg("Cannot get user from Gitea")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("Cannot read Gitea response body")
return
}
var result map[string]interface{}
err = gojson.Unmarshal(body, &result)
if err != nil {
log.Error().Err(err).Msg("Cannot unmarshal Gitea response body")
return
}
field, ok := result["avatar_url"]
if !ok {
log.Error().Msg("Field 'avatar_url' not found in Gitea JSON response")
return
}
user.AvatarURL = field.(string)
func (p *GiteaCallbackProvider) IsAdmin() bool {
return false
}
func NewGiteaCallbackProvider(user *goth.User) CallbackProvider {
+4
View File
@@ -77,6 +77,10 @@ func (p *GitHubCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = "https://avatars.githubusercontent.com/u/" + p.User.UserID + "?v=4"
}
func (p *GitHubCallbackProvider) IsAdmin() bool {
return false
}
func NewGitHubCallbackProvider(user *goth.User) CallbackProvider {
return &GitHubCallbackProvider{
User: user,
+4
View File
@@ -111,6 +111,10 @@ func (p *GitLabCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = field.(string)
}
func (p *GitLabCallbackProvider) IsAdmin() bool {
return false
}
func NewGitLabCallbackProvider(user *goth.User) CallbackProvider {
return &GitLabCallbackProvider{
User: user,
+32
View File
@@ -3,9 +3,12 @@ package oauth
import (
gocontext "context"
"errors"
"slices"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/openidConnect"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
@@ -37,6 +40,10 @@ func (p *OIDCProvider) RegisterProvider() error {
}
func (p *OIDCProvider) BeginAuthHandler(ctx *context.Context) {
if err := enablePKCE(ctx, OpenIDConnectString); err != nil {
log.Error().Err(err).Msg("Cannot enable PKCE for OIDC provider")
}
ctxValue := gocontext.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, OpenIDConnectString)
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
@@ -79,6 +86,31 @@ func (p *OIDCCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = p.User.AvatarURL
}
func (p *OIDCCallbackProvider) IsAdmin() bool {
if config.C.OIDCAdminGroup == "" {
return false
}
groupClaimName := config.C.OIDCGroupClaimName
if groupClaimName == "" {
return false
}
groups, ok := p.User.RawData[groupClaimName].([]interface{})
if !ok {
return false
}
var groupNames []string
for _, group := range groups {
if groupName, ok := group.(string); ok {
groupNames = append(groupNames, groupName)
}
}
return slices.Contains(groupNames, config.C.OIDCAdminGroup)
}
func NewOIDCCallbackProvider(user *goth.User) CallbackProvider {
return &OIDCCallbackProvider{
User: user,
+51
View File
@@ -0,0 +1,51 @@
package oauth
import (
"github.com/markbates/goth"
"github.com/markbates/goth/providers/openidConnect"
"github.com/thomiceli/opengist/internal/web/context"
"golang.org/x/oauth2"
)
const codeVerifierSessionKey = "oauth_code_verifier"
func enablePKCE(ctx *context.Context, providerName string) error {
gothProvider, err := goth.GetProvider(providerName)
if err != nil {
return err
}
oidcProvider, ok := gothProvider.(*openidConnect.Provider)
if !ok {
return nil
}
verifier := oauth2.GenerateVerifier()
sess := ctx.GetSession()
sess.Values[codeVerifierSessionKey] = verifier
ctx.SaveSession(sess)
oidcProvider.SetAuthCodeOptions(map[string]string{
"code_challenge": oauth2.S256ChallengeFromVerifier(verifier),
"code_challenge_method": "S256",
})
return nil
}
func injectCodeVerifier(ctx *context.Context) {
sess := ctx.GetSession()
verifier, ok := sess.Values[codeVerifierSessionKey].(string)
if !ok || verifier == "" {
return
}
req := ctx.Request()
q := req.URL.Query()
q.Set("code_verifier", verifier)
req.URL.RawQuery = q.Encode()
delete(sess.Values, codeVerifierSessionKey)
ctx.SaveSession(sess)
}
+31 -4
View File
@@ -2,15 +2,16 @@ package oauth
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"io"
"net/http"
"net/url"
"strings"
)
const (
@@ -32,6 +33,7 @@ type CallbackProvider interface {
GetProviderUserID(user *db.User) bool
GetProviderUserSSHKeys() ([]string, error)
UpdateUserDB(user *db.User)
IsAdmin() bool
}
func DefineProvider(provider string, url string) (Provider, error) {
@@ -50,6 +52,8 @@ func DefineProvider(provider string, url string) (Provider, error) {
}
func CompleteUserAuth(ctx *context.Context) (CallbackProvider, error) {
injectCodeVerifier(ctx)
user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request())
if err != nil {
return nil, err
@@ -69,6 +73,29 @@ func CompleteUserAuth(ctx *context.Context) (CallbackProvider, error) {
return nil, fmt.Errorf("unsupported provider %s", user.Provider)
}
func NewCallbackProviderFromSession(provider string, userID string, nickname string, email string, avatarURL string) (CallbackProvider, error) {
user := &goth.User{
Provider: provider,
UserID: userID,
NickName: nickname,
Email: email,
AvatarURL: avatarURL,
}
switch provider {
case GitHubProviderString:
return NewGitHubCallbackProvider(user), nil
case GitLabProviderString:
return NewGitLabCallbackProvider(user), nil
case GiteaProviderString:
return NewGiteaCallbackProvider(user), nil
case OpenIDConnectString:
return NewOIDCCallbackProvider(user), nil
}
return nil, fmt.Errorf("unsupported provider %s", provider)
}
func urlJoin(base string, elem ...string) string {
joined, err := url.JoinPath(base, elem...)
if err != nil {
+427
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")
}
}
+193
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")
}
})
}
}
+4 -2
View File
@@ -19,7 +19,8 @@ func AESEncrypt(key, text []byte) ([]byte, error) {
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
return nil, err
}
// TODO: remove deprecated
//nolint:staticcheck
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(ciphertext[aes.BlockSize:], text)
@@ -38,7 +39,8 @@ func AESDecrypt(key, ciphertext []byte) ([]byte, error) {
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
// TODO: remove deprecated
//nolint:staticcheck
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(ciphertext, ciphertext)
+430
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)
}
})
}
}
+8 -7
View File
@@ -4,20 +4,21 @@ import (
"bytes"
"crypto/rand"
"encoding/base64"
"github.com/pquerna/otp/totp"
"html/template"
"image/png"
"strings"
"github.com/pquerna/otp/totp"
)
const secretSize = 16
func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.URL, error, []byte) {
func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.URL, []byte, error) {
var err error
if secret == nil {
secret, err = generateSecret()
if err != nil {
return "", "", err, nil
return "", "", nil, err
}
}
@@ -28,22 +29,22 @@ func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.U
Secret: secret,
})
if err != nil {
return "", "", err, nil
return "", "", nil, err
}
qrcode, err := otpKey.Image(320, 240)
if err != nil {
return "", "", err, nil
return "", "", nil, err
}
var imgBytes bytes.Buffer
if err = png.Encode(&imgBytes, qrcode); err != nil {
return "", "", err, nil
return "", "", nil, err
}
qrcodeImage := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes()))
return otpKey.Secret(), qrcodeImage, nil, secret
return otpKey.Secret(), qrcodeImage, secret, nil
}
func Validate(passcode, secret string) bool {
+431
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)
}
}
}
+4 -3
View File
@@ -2,13 +2,14 @@ package webauthn
import (
"encoding/json"
"net/http"
"net/url"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"net/http"
"net/url"
)
var webAuthn *webauthn.WebAuthn
@@ -101,7 +102,7 @@ func FinishDiscoverableLogin(jsonSession []byte, response *http.Request) (uint,
return 0, err
}
return waUser.(*user).User.ID, nil
return waUser.(*user).ID, nil
}
func BeginLogin(dbUser *db.User) (credCreation *protocol.CredentialAssertion, jsonSession []byte, err error) {
+25 -10
View File
@@ -2,20 +2,23 @@ package cli
import (
"fmt"
"os"
"os/signal"
"path"
"path/filepath"
"syscall"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/actions"
"github.com/thomiceli/opengist/internal/auth/webauthn"
"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/ssh"
"github.com/thomiceli/opengist/internal/web/handlers/metrics"
"github.com/thomiceli/opengist/internal/web/server"
"github.com/urfave/cli/v2"
"os"
"os/signal"
"path"
"path/filepath"
"syscall"
)
var CmdVersion = cli.Command{
@@ -36,12 +39,20 @@ var CmdStart = cli.Command{
Initialize(ctx)
server := server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false)
go server.Start()
httpServer := server.NewServer(os.Getenv("OG_DEV") == "1")
go httpServer.Start()
go ssh.Start()
stopCron := actions.StartCron()
var metricsServer *metrics.Server
if config.C.MetricsEnabled {
metricsServer = metrics.NewServer()
go metricsServer.Start()
}
<-stopCtx.Done()
shutdown(server)
stopCron()
shutdown(httpServer, metricsServer)
return nil
},
}
@@ -131,7 +142,7 @@ func Initialize(ctx *cli.Context) {
}
}
func shutdown(server *server.Server) {
func shutdown(httpServer *server.Server, metricsServer *metrics.Server) {
log.Info().Msg("Shutting down database...")
if err := db.Close(); err != nil {
log.Error().Err(err).Msg("Failed to close database")
@@ -142,7 +153,11 @@ func shutdown(server *server.Server) {
index.Close()
}
server.Stop()
httpServer.Stop()
if metricsServer != nil {
metricsServer.Stop()
}
log.Info().Msg("Shutdown complete")
}
+18 -9
View File
@@ -2,7 +2,6 @@ package config
import (
"fmt"
"github.com/thomiceli/opengist/internal/session"
"io"
"net/url"
"os"
@@ -13,6 +12,8 @@ import (
"strings"
"time"
"github.com/thomiceli/opengist/internal/session"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
@@ -37,11 +38,12 @@ type config struct {
DBUri string `yaml:"db-uri" env:"OG_DB_URI"`
DBFilename string `yaml:"db-filename" env:"OG_DB_FILENAME"` // deprecated
IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"` // deprecated
Index string `yaml:"index" env:"OG_INDEX"`
BleveDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` // deprecated
MeiliHost string `yaml:"index.meili.host" env:"OG_MEILI_HOST"`
MeiliAPIKey string `yaml:"index.meili.api-key" env:"OG_MEILI_API_KEY"`
IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"` // deprecated
Index string `yaml:"index" env:"OG_INDEX"`
BleveDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` // deprecated
MeiliHost string `yaml:"index.meili.host" env:"OG_MEILI_HOST"`
MeiliAPIKey string `yaml:"index.meili.api-key" env:"OG_MEILI_API_KEY"`
SearchDefault string `yaml:"search.default" env:"OG_SEARCH_DEFAULT"`
GitDefaultBranch string `yaml:"git.default-branch" env:"OG_GIT_DEFAULT_BRANCH"`
@@ -51,13 +53,14 @@ type config struct {
HttpPort string `yaml:"http.port" env:"OG_HTTP_PORT"`
HttpGit bool `yaml:"http.git-enabled" env:"OG_HTTP_GIT_ENABLED"`
ApiEnabled bool `yaml:"api.enabled" env:"OG_API_ENABLED"`
UnixSocketPermissions string `yaml:"unix-socket-permissions" env:"OG_UNIX_SOCKET_PERMISSIONS"`
SshGit bool `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"`
SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"`
SshPort string `yaml:"ssh.port" env:"OG_SSH_PORT"`
SshExternalDomain string `yaml:"ssh.external-domain" env:"OG_SSH_EXTERNAL_DOMAIN"`
SshKeygen string `yaml:"ssh.keygen-executable" env:"OG_SSH_KEYGEN_EXECUTABLE"`
GithubClientKey string `yaml:"github.client-key" env:"OG_GITHUB_CLIENT_KEY"`
GithubSecret string `yaml:"github.secret" env:"OG_GITHUB_SECRET"`
@@ -79,7 +82,9 @@ type config struct {
OIDCGroupClaimName string `yaml:"oidc.group-claim-name" env:"OG_OIDC_GROUP_CLAIM_NAME"`
OIDCAdminGroup string `yaml:"oidc.admin-group" env:"OG_OIDC_ADMIN_GROUP"`
MetricsEnabled bool `yaml:"metrics.enabled" env:"OG_METRICS_ENABLED"`
MetricsEnabled bool `yaml:"metrics.enabled" env:"OG_METRICS_ENABLED"`
MetricsHost string `yaml:"metrics.host" env:"OG_METRICS_HOST"`
MetricsPort string `yaml:"metrics.port" env:"OG_METRICS_PORT"`
LDAPUrl string `yaml:"ldap.url" env:"OG_LDAP_URL"`
LDAPBindDn string `yaml:"ldap.bind-dn" env:"OG_LDAP_BIND_DN"`
@@ -108,6 +113,7 @@ func configWithDefaults() (*config, error) {
c.OpengistHome = ""
c.DBUri = "opengist.db"
c.Index = "bleve"
c.SearchDefault = "content"
c.SqliteJournalMode = "WAL"
@@ -115,12 +121,13 @@ func configWithDefaults() (*config, error) {
c.HttpPort = "6157"
c.HttpGit = true
c.ApiEnabled = true
c.UnixSocketPermissions = "0666"
c.SshGit = true
c.SshHost = "0.0.0.0"
c.SshPort = "2222"
c.SshKeygen = "ssh-keygen"
c.GitlabName = "GitLab"
@@ -128,6 +135,8 @@ func configWithDefaults() (*config, error) {
c.GiteaName = "Gitea"
c.MetricsEnabled = false
c.MetricsHost = "0.0.0.0"
c.MetricsPort = "6158"
return c, nil
}
+156
View File
@@ -0,0 +1,156 @@
package db
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"time"
)
const (
ScopeGist = iota
ScopeUser
)
const (
NoPermission = iota
ReadPermission
ReadWritePermission
)
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
ScopeUser 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
}
func (t *AccessToken) HasUserReadPermission() bool {
return t.ScopeUser >= ReadPermission
}
func (t *AccessToken) HasUserWritePermission() bool {
return t.ScopeUser >= ReadWritePermission
}
func (t *AccessToken) CheckForPermission(scope, permission uint) bool {
if scope == ScopeGist {
return t.ScopeGist >= permission
}
if scope == ScopeUser {
return t.ScopeUser >= permission
}
return false
}
// -- DTO -- //
type AccessTokenDTO struct {
Name string `form:"name" validate:"required,max=255"`
ScopeGist uint `form:"scope_gist" validate:"min=0,max=2"`
ScopeUser uint `form:"scope_user" 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,
ScopeUser: dto.ScopeUser,
ExpiresAt: expiresAt,
}
}
// SaveAccessTokenForTest is exported for tests only; saves the entire AccessToken row.
func SaveAccessTokenForTest(t *AccessToken) error {
return db.Save(t).Error
}
+23
View File
@@ -0,0 +1,23 @@
package db_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
)
func TestAccessTokenScopeUser(t *testing.T) {
tok := &db.AccessToken{ScopeUser: db.ReadPermission}
require.True(t, tok.HasUserReadPermission(), "ScopeUser=1 should grant user read")
tokNone := &db.AccessToken{ScopeUser: 0}
require.False(t, tokNone.HasUserReadPermission(), "ScopeUser=0 should not grant user read")
}
func TestAccessTokenDTOScopeUser(t *testing.T) {
dto := &db.AccessTokenDTO{Name: "t", ScopeGist: 1, ScopeUser: 1}
tok := dto.ToAccessToken()
require.Equal(t, uint(1), tok.ScopeUser)
require.Equal(t, uint(1), tok.ScopeGist)
}
+52
View File
@@ -0,0 +1,52 @@
package db
import (
"time"
"gorm.io/gorm/clause"
)
// ActionLock is a DB-backed lease used to single-flight an action across
// multiple Opengist instances sharing the same database. Each lock is one row
// keyed by Action (the action's identifier); LockedUntil holds the Unix
// timestamp the current lease expires at (0 = free).
type ActionLock struct {
Action int `gorm:"primaryKey"`
LockedUntil int64
}
func (ActionLock) TableName() string {
return "action_lock"
}
// AcquireLock atomically grabs the lock for action when it is free or its lease
// has expired, extending the lease by leaseTTL. It returns true only for the
// single caller that won the row. The conditional UPDATE is what makes this
// safe across SQLite/PostgreSQL/MySQL: concurrent writers serialize on the row
// (SQLite serializes all writes), so at most one re-evaluates the
// `locked_until < now` predicate to true. leaseTTL only needs to outlast a
// normal run; it's a safety net so a crashed holder doesn't block future runs.
func AcquireLock(action int, leaseTTL time.Duration) (bool, error) {
now := time.Now().Unix()
if err := db.Clauses(clause.OnConflict{DoNothing: true}).
Create(&ActionLock{Action: action, LockedUntil: 0}).Error; err != nil {
return false, err
}
res := db.Model(&ActionLock{}).
Where("action = ? AND locked_until < ?", action, now).
Update("locked_until", time.Now().Add(leaseTTL).Unix())
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected == 1, nil
}
// ReleaseLock frees the lock for action so the next run can acquire it
// immediately instead of waiting for the lease to expire.
func ReleaseLock(action int) error {
return db.Model(&ActionLock{}).
Where("action = ?", action).
Update("locked_until", 0).Error
}
+1 -1
View File
@@ -20,7 +20,7 @@ const (
func GetSetting(key string) (string, error) {
var setting AdminSetting
var err error
switch db.Dialector.Name() {
switch db.Name() {
case "mysql", "sqlite":
err = db.Where("`key` = ?", key).First(&setting).Error
case "postgres":
+2 -2
View File
@@ -155,7 +155,7 @@ func Setup(dbUri string) error {
return err
}
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}, &ActionLock{}); err != nil {
return err
}
@@ -269,5 +269,5 @@ func DeprecationDBFilename() {
}
func TruncateDatabase() error {
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{})
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{})
}
+315 -54
View File
@@ -12,6 +12,7 @@ import (
"github.com/alecthomas/chroma/v2/lexers"
"github.com/dustin/go-humanize"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/index"
"gorm.io/gorm"
@@ -71,8 +72,10 @@ type Gist struct {
Uuid string
Title string
URL string
URLNormalized string
Preview string
PreviewFilename string
PreviewMimeType string
Description string
Private Visibility // 0: public, 1: unlisted, 2: private
UserID uint
@@ -82,6 +85,7 @@ type Gist struct {
NbForks int
CreatedAt int64
UpdatedAt int64
ExpiresAt int64 // 0: never expires
Likes []User `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Forked *Gist `gorm:"foreignKey:ForkedID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
@@ -97,7 +101,14 @@ type Like struct {
CreatedAt int64
}
func (gist *Gist) BeforeSave(_ *gorm.DB) error {
gist.URLNormalized = strings.ToLower(gist.URL)
return nil
}
func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
gist.DeleteRepository()
gist.RemoveFromIndex()
// Decrement fork counter if the gist was forked
err := tx.Model(&Gist{}).
Omit("updated_at").
@@ -109,13 +120,20 @@ func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
func GetGist(user string, gistUuid string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("(gists.uuid like ? OR gists.url = ?) AND users.username like ?", gistUuid+"%", gistUuid, user).
Where("(gists.uuid LIKE ? OR gists.url_normalized = ?) AND users.username_normalized = ?",
strings.ToLower(gistUuid)+"%", strings.ToLower(gistUuid), strings.ToLower(user)).
Joins("join users on gists.user_id = users.id").
First(&gist).Error
return gist, err
}
func GetGistByUUID(uuid string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Preload("Forked.User").Where("uuid = ?", uuid).First(gist).Error
return gist, err
}
func GetGistByID(gistId string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Preload("Forked.User").Preload("Topics").
@@ -125,14 +143,88 @@ func GetGistByID(gistId string) (*Gist, error) {
return gist, err
}
func GetAllGistsForCurrentUser(currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
// GetAllGistsForCurrentUser returns gists visible to currentUserId - all public
// gists plus the user's own private/unlisted ones - ordered by sort/order and
// paginated to one extra row (the 11th is the peek-next sentinel).
// `since`, when non-nil, restricts results to gists updated at or after that
// instant (used by the API; the web handler passes nil).
func GetAllGistsForCurrentUser(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
err := db.Preload("User").
query := db.Preload("User").
Preload("Forked.User").
Preload("Topics").
Where("gists.private = 0 or gists.user_id = ?", currentUserId).
Limit(11).
Offset(offset * 10).
Where("gists.private = 0 or gists.user_id = ?", currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order(sort + "_at " + order).
Find(&gists).Error
return gists, err
}
// GetAllGistsFromUserVisibleTo returns gists owned by fromUserId, filtered
// to what currentUserId is allowed to see (public always; private/unlisted
// only when currentUserId == fromUserId). Same pagination/since shape as
// the other API list helpers. Pass currentUserId=0 to force the
// public-only subset.
func GetAllGistsFromUserVisibleTo(fromUserId uint, currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
query := gistsFromUserStatement(fromUserId, currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
}
// GetAllGistsOfUser returns every gist owned by userID - public, unlisted,
// and private - with the same pagination/since semantics as GetAllGistsForCurrentUser.
// Used by the API list endpoint for callers whose
// token holds gist:read: they see all of their own content but nothing from
// other users (others' public gists live under /gists/public).
func GetAllGistsOfUser(userID uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
query := db.Preload("User").
Preload("Forked.User").
Preload("Topics").
Where("gists.user_id = ?", userID)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order(sort + "_at " + order).
Find(&gists).Error
return gists, err
}
// GetAllPublicGistsOfUser returns only the public gists owned by userID, with
// the same pagination/since semantics as GetAllGistsForCurrentUser. Used by
// the API list endpoint for callers that authenticate but whose token lacks
// gist:read - they get only their own public gists.
func GetAllPublicGistsOfUser(userID uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
query := db.Preload("User").
Preload("Forked.User").
Preload("Topics").
Where("gists.private = 0 AND gists.user_id = ?", userID)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order(sort + "_at " + order).
Find(&gists).Error
@@ -227,10 +319,19 @@ func likedStatement(fromUserId uint, currentUserId uint) *gorm.DB {
Joins("join users on likes.user_id = users.id")
}
func GetAllGistsLikedByUser(fromUserId uint, currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
// GetAllGistsLikedByUser returns gists that fromUserId has starred, filtered
// to what currentUserId is allowed to see. `since`, when non-nil, restricts
// results to gists updated at or after that instant (used by the API; the web
// handler passes nil for both since and the explicit pagination args).
func GetAllGistsLikedByUser(fromUserId uint, currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
err := likedStatement(fromUserId, currentUserId).Limit(11).
Offset(offset * 10).
query := likedStatement(fromUserId, currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
@@ -249,10 +350,19 @@ func forkedStatement(fromUserId uint, currentUserId uint) *gorm.DB {
Joins("join users on gists.user_id = users.id")
}
func GetAllGistsForkedByUser(fromUserId uint, currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
// GetAllGistsForkedByUser returns gists forked by fromUserId, filtered to
// what currentUserId is allowed to see. `since`, when non-nil, restricts
// results to gists updated at or after that instant (used by the API; the
// web handler passes nil for both since and the explicit pagination args).
func GetAllGistsForkedByUser(fromUserId uint, currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
err := forkedStatement(fromUserId, currentUserId).Limit(11).
Offset(offset * 10).
query := forkedStatement(fromUserId, currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
@@ -264,6 +374,59 @@ func CountAllGistsForkedByUser(fromUserId uint, currentUserId uint) (int64, erro
return count, err
}
// applySince narrows a gist query to rows updated at or after `since` when it
// is non-nil, matching the filter the API list queries apply. Kept separate so
// the count helpers stay in sync with their Find counterparts.
func applySince(q *gorm.DB, since *time.Time) *gorm.DB {
if since != nil {
return q.Where("gists.updated_at >= ?", since.Unix())
}
return q
}
// The Count* helpers below mirror the API list queries (including the optional
// `since` filter) so list responses can report a total. They're separate from
// the web UI's CountAll* helpers above, which don't take `since`.
func CountAllGistsForCurrentUser(currentUserId uint, since *time.Time) (int64, error) {
var count int64
q := applySince(db.Model(&Gist{}).Where("gists.private = 0 or gists.user_id = ?", currentUserId), since)
err := q.Count(&count).Error
return count, err
}
func CountAllGistsFromUserVisibleTo(fromUserId uint, currentUserId uint, since *time.Time) (int64, error) {
var count int64
err := applySince(gistsFromUserStatement(fromUserId, currentUserId).Model(&Gist{}), since).Count(&count).Error
return count, err
}
func CountAllGistsOfUser(userID uint, since *time.Time) (int64, error) {
var count int64
q := applySince(db.Model(&Gist{}).Where("gists.user_id = ?", userID), since)
err := q.Count(&count).Error
return count, err
}
func CountAllPublicGistsOfUser(userID uint, since *time.Time) (int64, error) {
var count int64
q := applySince(db.Model(&Gist{}).Where("gists.private = 0 AND gists.user_id = ?", userID), since)
err := q.Count(&count).Error
return count, err
}
func CountAllGistsLikedByUserSince(fromUserId uint, currentUserId uint, since *time.Time) (int64, error) {
var count int64
err := applySince(likedStatement(fromUserId, currentUserId).Model(&Gist{}), since).Count(&count).Error
return count, err
}
func CountAllGistsForkedByUserSince(fromUserId uint, currentUserId uint, since *time.Time) (int64, error) {
var count int64
err := applySince(forkedStatement(fromUserId, currentUserId).Model(&Gist{}), since).Count(&count).Error
return count, err
}
func GetAllGistsRows() ([]*Gist, error) {
var gists []*Gist
err := db.Table("gists").
@@ -327,11 +490,6 @@ func (gist *Gist) UpdateNoTimestamps() error {
}
func (gist *Gist) Delete() error {
err := gist.DeleteRepository()
if err != nil {
return err
}
return db.Delete(&gist).Error
}
@@ -381,39 +539,58 @@ func (gist *Gist) GetUsersLikes(offset int) ([]*User, error) {
return users, err
}
func (gist *Gist) GetForks(currentUserId uint, offset int) ([]*Gist, error) {
// GetForks returns gists that fork this gist, filtered to what
// currentUserId is allowed to see. `offset` is the page index (0-based);
// `limit` caps the returned slice (pass perPage+1 for the peek-next
// sentinel). `perPage` is the slice size used for the offset arithmetic
// (offset * perPage rows are skipped).
func (gist *Gist) GetForks(currentUserId uint, offset int, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
err := db.Model(&gist).Preload("User").
Where("forked_id = ?", gist.ID).
Where("(gists.private = 0) or (gists.private > 0 and gists.user_id = ?)", currentUserId).
Limit(11).
Offset(offset * 10).
Limit(limit).
Offset(offset * perPage).
Order("updated_at desc").
Find(&gists).Error
return gists, err
}
// CountForks returns the number of forks of this gist visible to currentUserId,
// using the same visibility filter as GetForks (pass 0 for the public subset).
func (gist *Gist) CountForks(currentUserId uint) (int64, error) {
var count int64
err := db.Model(&Gist{}).
Where("forked_id = ?", gist.ID).
Where("(gists.private = 0) or (gists.private > 0 and gists.user_id = ?)", currentUserId).
Count(&count).Error
return count, err
}
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 {
return git.InitRepository(gist.User.Username, gist.Uuid)
}
func (gist *Gist) DeleteRepository() error {
return git.DeleteRepository(gist.User.Username, gist.Uuid)
func (gist *Gist) DeleteRepository() {
err := git.DeleteRepository(gist.User.Username, gist.Uuid)
if err != nil {
log.Warn().Err(err).Msgf("Could not delete repository %s/%s", gist.User.Username, gist.Uuid)
}
}
func (gist *Gist) Files(revision string, truncate bool) ([]*git.File, error) {
filesCat, err := git.CatFileBatch(gist.User.Username, gist.Uuid, revision, truncate)
func (gist *Gist) Files(revision string, truncate bool) ([]*git.File, bool, error) {
filesCat, gistTruncated, err := git.CatFileBatch(gist.User.Username, gist.Uuid, revision, truncate)
if err != nil {
// if the revision or the file do not exist
if exiterr, ok := err.(*exec.ExitError); ok && exiterr.ExitCode() == 128 {
return nil, &git.RevisionNotFoundError{}
return nil, false, &git.RevisionNotFoundError{}
}
return nil, err
return nil, false, err
}
var files []*git.File
@@ -431,10 +608,10 @@ func (gist *Gist) Files(revision string, truncate bool) ([]*git.File, error) {
HumanSize: humanize.IBytes(fileCat.Size),
Content: fileCat.Content,
Truncated: fileCat.Truncated,
MimeType: git.DetectMimeType([]byte(shortContent)),
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(fileCat.Name)),
})
}
return files, err
return files, gistTruncated, err
}
func (gist *Gist) File(revision string, filename string, truncate bool) (*git.File, error) {
@@ -465,7 +642,7 @@ func (gist *Gist) File(revision string, filename string, truncate bool) (*git.Fi
HumanSize: humanize.IBytes(size),
Content: content,
Truncated: truncated,
MimeType: git.DetectMimeType([]byte(shortContent)),
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(filename)),
}, err
}
@@ -473,8 +650,55 @@ func (gist *Gist) FileNames(revision string) ([]string, error) {
return git.GetFilesOfRepository(gist.User.Username, gist.Uuid, revision)
}
func (gist *Gist) Log(skip int) ([]*git.Commit, error) {
return git.GetLog(gist.User.Username, gist.Uuid, skip)
// GistCommit pairs a raw git commit with the Opengist account whose email
// matches the commit's AuthorEmail (when one exists). The git.Commit pointer
// is embedded so callers/templates can read AuthorName, Hash, Timestamp,
// Files, etc. directly. User is nil when no account matches - callers can
// fall back to the embedded AuthorName/AuthorEmail.
type GistCommit struct {
*git.Commit
User *User
}
// Log returns the gist's commit history starting from `revision` (pass
// "HEAD" for the full history or a SHA to walk from a specific commit
// downward), with each commit's author resolved to an Opengist user via a
// single bulk email lookup. Lookup is case-insensitive on both sides -
// matches the historical web behavior even when the DB stores mixed-case
// emails. `skip` is the number of commits to skip from the top of the walk
// (use offset*per_page for paging); `limit` caps the returned slice (pass
// per_page+1 to enable the peek-next sentinel trick).
func (gist *Gist) Log(revision string, skip int, limit int) ([]*GistCommit, error) {
raw, err := git.GetLog(gist.User.Username, gist.Uuid, revision, skip, limit)
if err != nil {
return nil, err
}
// Collect distinct lowercased author emails.
loweredSet := make(map[string]struct{}, len(raw))
for _, c := range raw {
if c.AuthorEmail == "" {
continue
}
loweredSet[strings.ToLower(c.AuthorEmail)] = struct{}{}
}
// One IN query, then re-key by lowercased email so we can look up
// case-insensitively even if the DB column holds a mixed-case value.
byDBEmail, _ := GetUsersFromEmails(loweredSet)
byLowered := make(map[string]*User, len(byDBEmail))
for e, u := range byDBEmail {
byLowered[strings.ToLower(e)] = u
}
out := make([]*GistCommit, len(raw))
for i, c := range raw {
out[i] = &GistCommit{
Commit: c,
User: byLowered[strings.ToLower(c.AuthorEmail)],
}
}
return out, nil
}
func (gist *Gist) NbCommits() (string, error) {
@@ -551,6 +775,7 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
if len(filesStr) == 0 {
gist.Preview = ""
gist.PreviewFilename = ""
gist.PreviewMimeType = ""
} else {
for _, fileStr := range filesStr {
file, err := gist.File("HEAD", fileStr, true)
@@ -562,6 +787,7 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
}
gist.Preview = ""
gist.PreviewFilename = file.Filename
gist.PreviewMimeType = file.MimeType.ContentType
if !file.MimeType.CanBeEdited() {
continue
@@ -602,8 +828,36 @@ func (gist *Gist) Identifier() string {
return gist.Uuid
}
// HTTPCloneURL returns the HTTPS clone URL (`{baseURL}/{user}/{identifier}.git`).
// Returns "" when HTTP git access is disabled (config.HttpGit == false).
func (gist *Gist) HTTPCloneURL(baseURL string) string {
if !config.C.HttpGit {
return ""
}
return baseURL + "/" + gist.User.Username + "/" + gist.Identifier() + ".git"
}
// SSHCloneURL returns the SSH clone URL. `fallbackHost` is the request's Host
// header (or any host:port-shaped string) used when SshExternalDomain isn't
// configured — only its hostname part is kept. Returns "" when SSH git access
// is disabled (config.SshGit == false).
func (gist *Gist) SSHCloneURL(fallbackHost string) string {
if !config.C.SshGit {
return ""
}
sshDomain := config.C.SshExternalDomain
if sshDomain == "" {
sshDomain = strings.Split(fallbackHost, ":")[0]
}
path := gist.User.Username + "/" + gist.Identifier() + ".git"
if config.C.SshPort == "22" {
return sshDomain + ":" + path
}
return "ssh://" + sshDomain + ":" + config.C.SshPort + "/" + path
}
func (gist *Gist) GetLanguagesFromFiles() ([]string, error) {
files, err := gist.Files("HEAD", true)
files, _, err := gist.Files("HEAD", true)
if err != nil {
return nil, err
}
@@ -684,7 +938,7 @@ func (gist *Gist) UpdateLanguages() {
}
func (gist *Gist) ToDTO() (*GistDTO, error) {
files, err := gist.Files("HEAD", false)
files, _, err := gist.Files("HEAD", false)
if err != nil {
return nil, err
}
@@ -717,13 +971,19 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
// -- DTO -- //
type GistDTO struct {
Title string `validate:"max=250" form:"title"`
Description string `validate:"max=1000" form:"description"`
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
Files []FileDTO `validate:"min=1,dive"`
Name []string `form:"name"`
Content []string `form:"content"`
Topics string `validate:"gisttopics" form:"topics"`
Title string `validate:"max=250" form:"title"`
Description string `validate:"max=1000" form:"description"`
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
Files []FileDTO `validate:"min=1,dive"`
Name []string `form:"name"`
Content []string `form:"content"`
Topics string `validate:"gisttopics" form:"topics"`
UploadedFilesUUID []string `validate:"omitempty,dive,required,uuid" form:"uploadedfile_uuid"`
UploadedFilesNames []string `validate:"omitempty,dive,required" form:"uploadedfile_filename"`
BinaryFileOldName []string `form:"binary_old_name"`
BinaryFileNewName []string `form:"binary_new_name"`
Expire ExpirationType `validate:"omitempty,oneof=never 1hour 12hours 1day 7days 15days custom" form:"expire"`
ExpireAt string `validate:"expirationdate" form:"expire_at"`
VisibilityDTO
}
@@ -772,7 +1032,7 @@ func (dto *GistDTO) TopicStrToSlice() []GistTopic {
// -- Index -- //
func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
files, err := gist.Files("HEAD", true)
files, _, err := gist.Files("HEAD", true)
if err != nil {
return nil, err
}
@@ -803,18 +1063,19 @@ func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
}
indexedGist := &index.Gist{
GistID: gist.ID,
UserID: gist.UserID,
Visibility: gist.Private.Uint(),
Username: gist.User.Username,
Title: gist.Title,
Content: wholeContent,
Filenames: fileNames,
Extensions: exts,
Languages: langs,
Topics: topics,
CreatedAt: gist.CreatedAt,
UpdatedAt: gist.UpdatedAt,
GistID: gist.ID,
UserID: gist.UserID,
Visibility: gist.Private.Uint(),
Username: gist.User.Username,
Description: gist.Description,
Title: gist.Title,
Content: wholeContent,
Filenames: fileNames,
Extensions: exts,
Languages: langs,
Topics: topics,
CreatedAt: gist.CreatedAt,
UpdatedAt: gist.UpdatedAt,
}
return indexedGist, nil
+129
View File
@@ -0,0 +1,129 @@
package db
import (
"encoding/base64"
"net/url"
"strconv"
"time"
"github.com/thomiceli/opengist/internal/render/lang"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
)
// ToAPISimple returns the v1 API list-shape representation of the gist. baseURL is
// the scheme+host root (no trailing slash) the caller derived from config or
// the request.
func (gist *Gist) ToAPISimple(baseURL string) types.GistSimple {
sshHost := ""
if u, err := url.Parse(baseURL); err == nil {
sshHost = u.Host
}
var expiresAt *time.Time
if gist.ExpiresAt > 0 {
t := time.Unix(gist.ExpiresAt, 0).UTC()
expiresAt = &t
}
return types.GistSimple{
ID: gist.Uuid,
Owner: gist.User.ToSimpleAPI(),
Title: gist.Title,
HTMLUrl: baseURL + "/" + gist.User.Username + "/" + gist.Identifier(),
SlugUrl: gist.Identifier(),
Description: gist.Description,
Public: gist.Private == PublicVisibility,
Visibility: gist.Private.String(),
LikeCount: gist.NbLikes,
ForkCount: gist.NbForks,
CloneUrl: gist.HTTPCloneURL(baseURL),
SSHUrl: gist.SSHCloneURL(sshHost),
Topics: gist.TopicsSlice(),
CreatedAt: time.Unix(gist.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(gist.UpdatedAt, 0).UTC(),
ExpiresAt: expiresAt,
}
}
// ToAPI returns the v1 API detail-shape representation, including file
// contents at `revision` and the 10 most recent commits up to that
// revision. Pass "HEAD" for the current state; pass a SHA to render the
// gist (and its history) as it stood at that commit. Returns any error
// encountered while listing the gist's files or commit log (an unknown
// revision surfaces here as the Files error).
func (gist *Gist) ToAPI(baseURL string, revision string) (types.Gist, error) {
files, truncated, err := gist.Files(revision, true)
if err != nil {
return types.Gist{}, err
}
fm := make(map[string]types.GistFile, len(files))
for _, f := range files {
ff := types.GistFile{
Filename: f.Filename,
Type: f.MimeType.ContentType,
Language: lang.Parse(f),
Size: int(f.Size),
Truncated: f.Truncated,
}
if f.MimeType.CanBeEdited() {
ff.Content = f.Content
ff.Encoding = f.MimeType.Charset
} else {
ff.Content = base64.StdEncoding.EncodeToString([]byte(f.Content))
ff.Encoding = "base64"
}
fm[f.Filename] = ff
}
var forked *types.GistSimple
if gist.Forked != nil {
ff := gist.Forked.ToAPISimple(baseURL)
forked = &ff
}
forks, err := gist.GetForks(gist.UserID, 0, 11, 10)
if err != nil {
return types.Gist{}, err
}
forksMap := make([]types.GistSimple, 0)
for _, fork := range forks {
forksMap = append(forksMap, fork.ToAPISimple(baseURL))
}
logCommits, err := gist.Log(revision, 0, 10)
if err != nil {
return types.Gist{}, err
}
commits := make([]types.GistCommit, 0, len(logCommits))
for _, c := range logCommits {
commits = append(commits, c.ToAPI())
}
return types.Gist{
GistSimple: gist.ToAPISimple(baseURL),
ForkOf: forked,
Forks: forksMap,
Files: fm,
Commits: commits,
Truncated: truncated,
}, nil
}
// ToAPI converts a single resolved commit into its API shape. Shared by the
// gist-detail endpoint (10-most-recent embed) and the dedicated
// /gists/:uuid/commits endpoint so the wire shape is identical.
func (c *GistCommit) ToAPI() types.GistCommit {
ts, _ := strconv.ParseInt(c.Timestamp, 10, 64)
entry := types.GistCommit{
Version: c.Hash,
Author: types.CommitAuthor{Name: c.AuthorName, Email: c.AuthorEmail},
ChangeStatus: types.CommitChangeStatus{
Files: c.FilesChanged,
Additions: c.Additions,
Deletions: c.Deletions,
Total: c.Additions + c.Deletions,
},
CommittedAt: time.Unix(ts, 0).UTC(),
}
if c.User != nil {
s := c.User.ToSimpleAPI()
entry.User = &s
}
return entry
}
+96
View File
@@ -0,0 +1,96 @@
package db
import (
"strings"
"time"
"github.com/thomiceli/opengist/internal/validator"
)
// ExpirationType is the expiration choice a user can pick when creating a
// gist: either a fixed-duration preset, "custom" (paired with an explicit
// date), or "never". The empty value and "never" both mean "never expires".
type ExpirationType string
const (
ExpiryNever ExpirationType = "never"
ExpiryOneHour ExpirationType = "1hour"
ExpiryTwelveHours ExpirationType = "12hours"
ExpiryOneDay ExpirationType = "1day"
ExpirySevenDays ExpirationType = "7days"
ExpiryFifteenDays ExpirationType = "15days"
ExpiryCustom ExpirationType = "custom"
)
// Duration returns the time span of a fixed-duration preset, or 0 for
// "never", "custom", and any unknown value (those resolve their timestamp
// elsewhere).
func (e ExpirationType) Duration() time.Duration {
switch e {
case ExpiryOneHour:
return time.Hour
case ExpiryTwelveHours:
return 12 * time.Hour
case ExpiryOneDay:
return 24 * time.Hour
case ExpirySevenDays:
return 7 * 24 * time.Hour
case ExpiryFifteenDays:
return 15 * 24 * time.Hour
default:
return 0
}
}
// ExpiresAtTimestamp converts a fixed-duration preset into an absolute Unix
// expiration timestamp relative to now, returning 0 for "never"/"custom".
// Use GistDTO.ExpiresAtTimestamp to resolve a custom date.
func (e ExpirationType) ExpiresAtTimestamp() int64 {
d := e.Duration()
if d == 0 {
return 0
}
return time.Now().Add(d).Unix()
}
// ExpiresAtTimestamp resolves the gist's absolute expiration time (Unix
// seconds) from the chosen preset, or from the custom date when Expire is
// "custom". Returns 0 when the gist never expires or the custom date can't be
// parsed - callers validate the DTO (the `expirationdate` rule) beforehand, so
// an unparseable value here only happens on the non-validated push-option path,
// where "never" is the safe fallback.
func (dto *GistDTO) ExpiresAtTimestamp() int64 {
if dto.Expire != ExpiryCustom {
return dto.Expire.ExpiresAtTimestamp()
}
t, err := validator.ParseDateTime(strings.TrimSpace(dto.ExpireAt))
if err != nil {
return 0
}
return t.Unix()
}
func (gist *Gist) IsExpired() bool {
return gist.ExpiresAt > 0 && gist.ExpiresAt <= time.Now().Unix()
}
func DeleteExpiredGists() ([]*Gist, error) {
var gists []*Gist
err := db.Preload("User").
Where("expires_at > 0 AND expires_at <= ?", time.Now().Unix()).
Find(&gists).Error
if err != nil {
return nil, err
}
if len(gists) == 0 {
return nil, nil
}
if err := db.Delete(&gists).Error; err != nil {
return nil, err
}
return gists, nil
}
+1 -1
View File
@@ -16,7 +16,7 @@ type Invitation struct {
func GetAllInvitations() ([]*Invitation, error) {
var invitations []*Invitation
dialect := db.Dialector.Name()
dialect := db.Name()
query := db.Model(&Invitation{})
switch dialect {
+53 -28
View File
@@ -2,7 +2,9 @@ package db
import (
"fmt"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type MigrationVersion struct {
@@ -12,60 +14,74 @@ type MigrationVersion struct {
func applyMigrations(dbInfo *databaseInfo) error {
switch dbInfo.Type {
case SQLite:
return applySqliteMigrations()
case PostgreSQL, MySQL:
return nil
case SQLite, PostgreSQL, MySQL:
return applyAllMigrations(dbInfo.Type)
default:
return fmt.Errorf("unknown database type: %s", dbInfo.Type)
}
}
func applySqliteMigrations() error {
// Create migration table if it doesn't exist
func applyAllMigrations(dbType databaseType) error {
if err := db.AutoMigrate(&MigrationVersion{}); err != nil {
log.Fatal().Err(err).Msg("Error creating migration version table")
return err
}
// Get the current migration version
var currentVersion MigrationVersion
db.First(&currentVersion)
// Define migrations
migrations := []struct {
Version uint
DBTypes []databaseType // nil = all types
Func func() error
}{
{1, v1_modifyConstraintToSSHKeys},
{2, v2_lowercaseEmails},
// Add more migrations here as needed
{1, []databaseType{SQLite}, v1_modifyConstraintToSSHKeys},
{2, []databaseType{SQLite}, v2_lowercaseEmails},
{3, nil, v3_normalizedColumns},
}
// Apply migrations
for _, m := range migrations {
if m.Version > currentVersion.Version {
tx := db.Begin()
if err := tx.Error; err != nil {
log.Fatal().Err(err).Msg("Error starting transaction")
return err
}
if m.Version <= currentVersion.Version {
continue
}
if err := m.Func(); err != nil {
log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version))
tx.Rollback()
return err
} else {
if err = tx.Commit().Error; err != nil {
log.Fatal().Err(err).Msg(fmt.Sprintf("Error committing migration %d:", m.Version))
return err
// Skip migrations not intended for this DB type
if len(m.DBTypes) > 0 {
applicable := false
for _, t := range m.DBTypes {
if t == dbType {
applicable = true
break
}
}
if !applicable {
// Advance version so we don't retry on next startup
currentVersion.Version = m.Version
db.Save(&currentVersion)
log.Info().Msg(fmt.Sprintf("Migration %d applied successfully", m.Version))
continue
}
}
tx := db.Begin()
if err := tx.Error; err != nil {
log.Fatal().Err(err).Msg("Error starting transaction")
return err
}
if err := m.Func(); err != nil {
tx.Rollback()
log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version))
return err
}
if err := tx.Commit().Error; err != nil {
log.Fatal().Err(err).Msg(fmt.Sprintf("Error committing migration %d:", m.Version))
return err
}
currentVersion.Version = m.Version
db.Save(&currentVersion)
log.Info().Msg(fmt.Sprintf("Migration %d applied successfully", m.Version))
}
return nil
@@ -112,3 +128,12 @@ func v2_lowercaseEmails() error {
copySQL := `UPDATE users SET email = lower(email);`
return db.Exec(copySQL).Error
}
func v3_normalizedColumns() error {
if err := db.Model(&User{}).Where("username_normalized = '' OR username_normalized IS NULL").
Updates(map[string]interface{}{"username_normalized": gorm.Expr("LOWER(username)")}).Error; err != nil {
return err
}
return db.Model(&Gist{}).Where("url_normalized = '' OR url_normalized IS NULL").
Updates(map[string]interface{}{"url_normalized": gorm.Expr("LOWER(url)")}).Error
}
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
@@ -29,7 +30,7 @@ func (*binaryData) GormDataType() string {
}
func (*binaryData) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
switch db.Dialector.Name() {
switch db.Name() {
case "sqlite":
return "BLOB"
case "mysql":
@@ -67,7 +68,7 @@ func (*jsonData) GormDataType() string {
}
func (*jsonData) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
switch db.Dialector.Name() {
switch db.Name() {
case "mysql", "sqlite":
return "JSON"
case "postgres":
+35 -15
View File
@@ -2,29 +2,38 @@ package db
import (
"encoding/json"
"strings"
"github.com/thomiceli/opengist/internal/git"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"`
Password string
IsAdmin bool
CreatedAt int64
Email string
MD5Hash string // for gravatar, if no Email is specified, the value is random
AvatarURL string
GithubID string
GitlabID string
GiteaID string
OIDCID string `gorm:"column:oidc_id"`
StylePreferences string
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"`
UsernameNormalized string `gorm:"index"`
Password string
IsAdmin bool
CreatedAt int64
Email string
MD5Hash string // for gravatar, if no Email is specified, the value is random
AvatarURL string
GithubID string
GitlabID string
GiteaID string
OIDCID string `gorm:"column:oidc_id"`
StylePreferences string
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
AccessTokens []AccessToken `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
}
func (user *User) BeforeSave(_ *gorm.DB) error {
user.UsernameNormalized = strings.ToLower(user.Username)
return nil
}
func (user *User) BeforeDelete(tx *gorm.DB) error {
@@ -72,6 +81,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
return err
}
err = tx.Where("user_id = ?", user.ID).Delete(&AccessToken{}).Error
if err != nil {
return err
}
err = tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
if err != nil {
return err
@@ -87,7 +101,7 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
func UserExists(username string) (bool, error) {
var count int64
err := db.Model(&User{}).Where("username like ?", username).Count(&count).Error
err := db.Model(&User{}).Where("username_normalized = ?", strings.ToLower(username)).Count(&count).Error
return count > 0, err
}
@@ -105,7 +119,7 @@ func GetAllUsers(offset int) ([]*User, error) {
func GetUserByUsername(username string) (*User, error) {
user := new(User)
err := db.
Where("username like ?", username).
Where("username_normalized = ?", strings.ToLower(username)).
First(&user).Error
return user, err
}
@@ -252,6 +266,11 @@ type UserDTO struct {
Password string `form:"password" validate:"required"`
}
type OAuthRegisterDTO struct {
Username string `form:"username" validate:"required,max=24,alphanumdash,notreserved"`
Email string `form:"email" validate:"omitempty,email"`
}
func (dto *UserDTO) ToUser() *User {
return &User{
Username: dto.Username,
@@ -268,6 +287,7 @@ type UserStyleDTO struct {
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 {
+32
View File
@@ -0,0 +1,32 @@
package db
import (
"time"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
)
// ToSimpleAPI returns the public simple-user shape used inside gist responses
// and by the public user-lookup endpoints. It carries no private fields (no
// email). Fields whose underlying feature doesn't exist in Opengist
// (followers, repos, ...) are still populated with the spec-shaped URLs so
// clients can parse cleanly.
func (u *User) ToSimpleAPI() types.SimpleUser {
return types.SimpleUser{
ID: u.ID,
Login: u.Username,
Username: u.Username,
AvatarURL: u.AvatarURL,
Type: "User",
CreatedAt: time.Unix(u.CreatedAt, 0).UTC(),
}
}
// ToPrivateAPI returns the self shape for the authenticated-user endpoints
// (GET/PATCH /user): the public fields plus the caller's own email.
func (u *User) ToPrivateAPI() types.PrivateUser {
return types.PrivateUser{
SimpleUser: u.ToSimpleAPI(),
Email: u.Email,
}
}
+4 -3
View File
@@ -2,8 +2,9 @@ package db
import (
"encoding/hex"
"github.com/go-webauthn/webauthn/webauthn"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
type WebAuthnCredential struct {
@@ -67,7 +68,7 @@ func GetUserByCredentialID(credID binaryData) (*User, error) {
var credential WebAuthnCredential
var err error
switch db.Dialector.Name() {
switch db.Name() {
case "postgres":
hexCredID := hex.EncodeToString(credID)
if err = db.Preload("User").Where("credential_id = decode(?, 'hex')", hexCredID).First(&credential).Error; err != nil {
@@ -93,7 +94,7 @@ func GetCredentialByID(id binaryData) (*WebAuthnCredential, error) {
var cred WebAuthnCredential
var err error
switch db.Dialector.Name() {
switch db.Name() {
case "postgres":
hexCredID := hex.EncodeToString(id)
if err = db.Where("credential_id = decode(?, 'hex')", hexCredID).First(&cred).Error; err != nil {
+54 -21
View File
@@ -132,21 +132,36 @@ type catFileBatch struct {
Truncated bool
}
func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*catFileBatch, error) {
func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*catFileBatch, bool, error) {
repositoryPath := RepositoryPath(user, gist)
maxFiles := 50
lsTreeCmd := exec.Command("git", "ls-tree", "-l", revision)
lsTreeCmd.Dir = repositoryPath
lsTreeOutput, err := lsTreeCmd.Output()
var lsTreeStderr bytes.Buffer
lsTreeCmd.Stderr = &lsTreeStderr
lsTreeStdout, err := lsTreeCmd.StdoutPipe()
if err != nil {
return nil, err
return nil, false, err
}
if err = lsTreeCmd.Start(); err != nil {
return nil, false, err
}
fileMap := make([]*catFileBatch, 0)
gistTruncated := false
lines := strings.Split(string(lsTreeOutput), "\n")
for _, line := range lines {
fields := strings.Fields(line)
scanner := bufio.NewScanner(lsTreeStdout)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
if truncate && len(fileMap) >= maxFiles {
gistTruncated = true
break
}
fields := strings.Fields(scanner.Text())
if len(fields) < 4 {
continue // Skip lines that don't have enough fields
}
@@ -164,19 +179,33 @@ func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*
Name: convertOctalToUTF8(name),
})
}
scanErr := scanner.Err()
// Closing the read end before git is done writing causes git's next write
// to fail (SIGPIPE on Unix, broken-pipe error on Windows). That shows up as
// a non-zero exit from Wait, but it's expected when we stop early — so we
// only treat the Wait error as real if git actually printed something to stderr.
_ = lsTreeStdout.Close()
waitErr := lsTreeCmd.Wait()
if scanErr != nil {
return nil, false, scanErr
}
if waitErr != nil && lsTreeStderr.Len() > 0 {
return nil, false, waitErr
}
catFileCmd := exec.Command("git", "cat-file", "--batch")
catFileCmd.Dir = repositoryPath
stdin, err := catFileCmd.StdinPipe()
if err != nil {
return nil, err
return nil, false, err
}
stdout, err := catFileCmd.StdoutPipe()
if err != nil {
return nil, err
return nil, false, err
}
if err = catFileCmd.Start(); err != nil {
return nil, err
return nil, false, err
}
reader := bufio.NewReader(stdout)
@@ -184,12 +213,12 @@ func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*
for _, file := range fileMap {
_, err = stdin.Write([]byte(file.Hash + "\n"))
if err != nil {
return nil, err
return nil, false, err
}
header, err := reader.ReadString('\n')
if err != nil {
return nil, err
return nil, false, err
}
parts := strings.Fields(header)
@@ -199,7 +228,7 @@ func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*
size, err := strconv.ParseUint(parts[2], 10, 64)
if err != nil {
return nil, err
return nil, false, err
}
// Don't truncate Jupyter notebooks
@@ -215,7 +244,7 @@ func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*
// Read exactly size bytes from header, or the max allowed if truncated
content := make([]byte, sizeToRead)
if _, err = io.ReadFull(reader, content); err != nil {
return nil, err
return nil, false, err
}
file.Content = string(content)
@@ -223,26 +252,26 @@ func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*
if truncate && size > truncateLimit {
// skip other bytes if truncated
if _, err = reader.Discard(int(size - truncateLimit)); err != nil {
return nil, err
return nil, false, err
}
file.Truncated = true
}
// Read the blank line following the content
if _, err := reader.ReadByte(); err != nil {
return nil, err
return nil, false, err
}
}
if err = stdin.Close(); err != nil {
return nil, err
return nil, false, err
}
if err = catFileCmd.Wait(); err != nil {
return nil, err
return nil, false, err
}
return fileMap, nil
return fileMap, gistTruncated, nil
}
func GetFileContent(user string, gist string, revision string, filename string, truncate bool) (string, bool, error) {
@@ -298,7 +327,11 @@ func GetFileSize(user string, gist string, revision string, filename string) (ui
return strconv.ParseUint(strings.TrimSuffix(string(stdout), "\n"), 10, 64)
}
func GetLog(user string, gist string, skip int) ([]*Commit, error) {
// GetLog returns commits walked from `revision` (typically "HEAD" or a
// specific SHA), skipping `skip` rows from the top of the walk and limited
// to `limit` rows. Pass "HEAD" for the gist's full history; pass a SHA to
// see the history ending at (and including) that commit.
func GetLog(user string, gist string, revision string, skip int, limit int) ([]*Commit, error) {
repositoryPath := RepositoryPath(user, gist)
cmd := exec.Command(
@@ -306,14 +339,14 @@ func GetLog(user string, gist string, skip int) ([]*Commit, error) {
"--no-pager",
"log",
"-n",
"11",
strconv.Itoa(limit),
"--no-color",
"-p",
"--skip",
strconv.Itoa(skip),
"--format=format:c %H%na %aN%nm %ae%nt %at",
"--shortstat",
"HEAD",
revision,
)
cmd.Dir = repositoryPath
stdout, _ := cmd.StdoutPipe()

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