mirror of
https://github.com/thomiceli/opengist.git
synced 2026-06-23 04:10:18 +00:00
Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cac21689cf | |||
| 28736d6b66 | |||
| 499f9c67b9 | |||
| 9d9b54a5e1 | |||
| 1ba588c90e | |||
| a2e4734e36 | |||
| 66f2793f8b | |||
| 2bba402787 | |||
| daeeed3dc5 | |||
| 06708eb351 | |||
| b41a80a335 | |||
| b43789943a | |||
| bf3257faa8 | |||
| 2946de2505 | |||
| c1ca19aec9 | |||
| 34e5a16a26 | |||
| 31bc25e569 | |||
| 3b8d947ad8 | |||
| 8e462397f4 | |||
| 5c23d7feed | |||
| 690f151592 | |||
| 8da72b9545 | |||
| f3c38ddbbb | |||
| 8a6f2d82ff | |||
| bfd75a9d58 | |||
| eef6029c95 | |||
| c60094c778 | |||
| f67bff59c3 | |||
| ec26888487 | |||
| 57d76151fd | |||
| c2ee390841 | |||
| d6fc346e70 | |||
| 4e977077ba | |||
| f865b2b099 | |||
| d26221de54 | |||
| e91139d3ec | |||
| 279da52899 | |||
| 5ad01a3304 | |||
| 1944502d14 | |||
| 2d7261ac83 | |||
| 50f2980c10 | |||
| 2e68b6893b | |||
| 9b68f08c62 | |||
| dfabdb403a | |||
| 4da067ab60 | |||
| a8339ff6bd | |||
| 7a5cdd1565 | |||
| 00dcb53e3a | |||
| f8b3bbce6a | |||
| a697b0f273 | |||
| 33cbfb0904 | |||
| dfea4eb435 | |||
| d796eeba98 | |||
| 4ab38f24c8 | |||
| e1d1b01d40 | |||
| 3c967729cc | |||
| 36bc576893 | |||
| c074d60d1d | |||
| 840a852ed2 | |||
| 34c0b0b3e2 | |||
| 093a4cb4a8 | |||
| f037206f41 | |||
| 6c22adba4e | |||
| bb63ecd048 | |||
| 6a61b720ab | |||
| 829cd68879 | |||
| 42490f2995 | |||
| f83018ebf2 | |||
| b097cfcbc0 | |||
| 7b1048ec30 | |||
| ce39df1030 | |||
| 07ba04244b | |||
| 4d29a50e64 | |||
| 3a4602d412 | |||
| 2e10c1732a | |||
| fe04c03acb | |||
| 2a1554d063 | |||
| b7dbdde66b | |||
| b7278b60ab | |||
| 84c6a41340 | |||
| 6bd8df6a74 | |||
| b48103c06a | |||
| 48f2c4f5c8 | |||
| 5ddea2265d | |||
| 1128a81071 | |||
| 145bf9d81a | |||
| 24d0918e73 | |||
| 4ff71fb255 | |||
| 67f7c4cadd | |||
| a17effb10f | |||
| b2161d8859 | |||
| 61bb22ebe9 | |||
| 6813c14e3a | |||
| 4ae25144a0 | |||
| 03420e4f91 | |||
| 22376d6cd3 | |||
| f3dc45fe0f | |||
| 7b4dab143b | |||
| f874b81e2e | |||
| 5fe6238da1 | |||
| f4e472a77b | |||
| 4350a66afd | |||
| 8a958de3d7 | |||
| 871cb356b7 | |||
| 0958e80d8e | |||
| cc27899b6c | |||
| 256da0077a | |||
| 0e5007dbad | |||
| 91de091874 | |||
| 07bdf983af | |||
| a5907c313c | |||
| dc0b429121 | |||
| b2373109b8 | |||
| 0a106b27db | |||
| f10d656355 | |||
| fe211b949b | |||
| a5778e77eb | |||
| f24c78d0a2 | |||
| 34bd7bec20 | |||
| 4d6809bc2d | |||
| a493de4325 | |||
| a67c80d148 | |||
| feac9dcb66 | |||
| 38024310df | |||
| 9512ba84b0 | |||
| b11306851b | |||
| 3957dfb3ea | |||
| 8129906b02 | |||
| 7880a3438e | |||
| d5a3400bf0 | |||
| f529bf6a22 | |||
| 425b123dd9 | |||
| a7eaffbf02 | |||
| 5d19825949 | |||
| c6dc2072bd | |||
| 4d4f1c36a9 | |||
| a7ad82e29a | |||
| 98d216038b | |||
| 395ea7bfc7 | |||
| 1c145e09c5 | |||
| 32ea7befaf | |||
| f653179cbf | |||
| f0a596aed0 | |||
| a468f0ecfa | |||
| 5ef5518795 | |||
| 92c5569538 | |||
| 132e4faed2 |
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
+21
-27
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ gist.db
|
||||
/**/.DS_Store
|
||||
public/assets/*
|
||||
public/manifest.json
|
||||
public/.vite/*
|
||||
./opengist
|
||||
opengist
|
||||
build/
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.vitepress/dist
|
||||
.vitepress/cache
|
||||
+229
-66
@@ -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
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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(' & ')
|
||||
if (s.type === 'array') return typeHtml(s.items) + '[]'
|
||||
if (s.type === 'null') return '<code>null</code>'
|
||||
if (s.type === 'object' && s.additionalProperties)
|
||||
return `map<string, ${typeHtml(s.additionalProperties)}>`
|
||||
let t = s.type || 'object'
|
||||
if (s.format) t += ` <${s.format}>`
|
||||
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<string, ${typeHtml(f.addl)}>` : ''
|
||||
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
|
||||
}
|
||||
}
|
||||
Vendored
-37
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
aside: false
|
||||
pageClass: api-page
|
||||
---
|
||||
<script setup>
|
||||
import { useData } from 'vitepress'
|
||||
const { params } = useData()
|
||||
</script>
|
||||
|
||||
<OpenApiOperation :id="params.id" />
|
||||
@@ -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}` }
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
<script setup>
|
||||
import { useData } from 'vitepress'
|
||||
const { params } = useData()
|
||||
</script>
|
||||
|
||||
<OpenApiSchema :name="params.name" />
|
||||
@@ -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 } }))
|
||||
}
|
||||
}
|
||||
@@ -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-->
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+3367
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://opengist.io/sitemap.xml
|
||||
+2
-2
@@ -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`
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -1,11 +1,12 @@
|
||||
# Opengist Helm Chart
|
||||
|
||||
 
|
||||
 
|
||||
|
||||
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 per‑pod 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 re‑deploy.
|
||||
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`
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(¤tVersion)
|
||||
|
||||
// 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(¤tVersion)
|
||||
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(¤tVersion)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user