Compare commits
76 Commits
v1.11.0
...
fix/gitea-
| Author | SHA1 | Date | |
|---|---|---|---|
|
a22475d692
|
|||
|
|
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 |
18
.github/dependabot.yml
vendored
Normal file
18
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
34
.github/workflows/docs.yml
vendored
34
.github/workflows/docs.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -28,20 +28,16 @@ jobs:
|
||||
npx tailwindcss -i .vitepress/theme/style.css -o .vitepress/theme/theme.css -c .vitepress/tailwind.config.js
|
||||
npm run docs:build
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USERNAME }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
source: "docs/.vitepress/dist/*"
|
||||
target: ${{ secrets.SERVER_PATH }}
|
||||
|
||||
- name: Update remote docs
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USERNAME }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
script: |
|
||||
${{ secrets.UPDATE_DOCS }}
|
||||
- name: Push to docs repository
|
||||
run: |
|
||||
git clone https://${{ secrets.STATIC_REPO_TOKEN }}@github.com/${{ secrets.STATIC_REPO }}.git target-repo
|
||||
rm -rf target-repo/srv/opengist
|
||||
mkdir -p target-repo/srv/opengist
|
||||
cp -r docs/.vitepress/dist/* target-repo/srv/opengist/
|
||||
cd target-repo
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
git commit -m "Deploy docs from ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit"
|
||||
git pull --rebase
|
||||
git push
|
||||
|
||||
42
.github/workflows/go.yml
vendored
42
.github/workflows/go.yml
vendored
@@ -17,18 +17,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go 1.23
|
||||
uses: actions/setup-go@v4
|
||||
- name: Set up Go 1.25
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v1.60
|
||||
args: --out-format=colored-line-number --timeout=20m
|
||||
version: v2.5
|
||||
args: --timeout=20m --disable=errcheck
|
||||
|
||||
- name: Format
|
||||
run: make fmt check_changes
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go 1.23
|
||||
uses: actions/setup-go@v4
|
||||
- name: Set up Go 1.25
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Check Go modules
|
||||
run: make go_mod check_changes
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ["ubuntu-latest"]
|
||||
go: ["1.23"]
|
||||
go: ["1.25"]
|
||||
database: [postgres, mysql]
|
||||
include:
|
||||
- database: postgres
|
||||
@@ -85,10 +85,10 @@ jobs:
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
@@ -101,15 +101,15 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
|
||||
go: ["1.23"]
|
||||
go: ["1.25"]
|
||||
database: ["sqlite"]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
@@ -122,14 +122,14 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
|
||||
go: ["1.23"]
|
||||
go: ["1.25"]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go 1.23
|
||||
uses: actions/setup-go@v4
|
||||
- name: Set up Go 1.25
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
|
||||
34
.github/workflows/helm.yml
vendored
34
.github/workflows/helm.yml
vendored
@@ -8,10 +8,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.3.0
|
||||
uses: azure/setup-helm@v4.3.1
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
@@ -34,20 +34,16 @@ jobs:
|
||||
helm repo index --url https://helm.opengist.io --merge index.yaml .
|
||||
fi
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USERNAME }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
source: "./helm/*.tgz,./helm/index.yaml"
|
||||
target: ${{ secrets.HELM_SERVER_PATH }}
|
||||
|
||||
- name: Update remote helm repository
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USERNAME }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
script: |
|
||||
${{ secrets.UPDATE_HELM_REPO }}
|
||||
- name: Push to docs repository
|
||||
run: |
|
||||
git clone https://${{ secrets.DOCS_REPO_TOKEN }}@github.com/${{ secrets.DOCS_REPO }}.git target-repo
|
||||
mkdir -p target-repo/helm
|
||||
cp helm/*.tgz target-repo/helm/
|
||||
cp helm/index.yaml target-repo/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
|
||||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@@ -11,18 +11,18 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go 1.23
|
||||
uses: actions/setup-go@v4
|
||||
- name: Set up Go 1.25
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Cross compile build
|
||||
run: make all_crosscompile
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
build/*.tar.gz
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/thomiceli/opengist
|
||||
@@ -54,26 +54,26 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ gist.db
|
||||
/**/.DS_Store
|
||||
public/assets/*
|
||||
public/manifest.json
|
||||
public/.vite/*
|
||||
./opengist
|
||||
opengist
|
||||
build/
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [1.12.1](https://github.com/thomiceli/opengist/compare/v1.12.0...v1.12.1) - 2026-02-03
|
||||
See here how to [update](https://opengist.io/docs/update) Opengist.
|
||||
|
||||
### Added
|
||||
- More translation strings (#605)
|
||||
|
||||
### Fixed
|
||||
- Allow Access Tokens with Required Login (#611)
|
||||
- Make text files renderable with mimetypes different than text/plain (#612)
|
||||
- Improve security on raw files endpoint (#613)
|
||||
|
||||
> Admins of Opengist instances may want to run "Synchronize all gists previews" in the admin panel.
|
||||
|
||||
## [1.12.0](https://github.com/thomiceli/opengist/compare/v1.11.1...v1.12.0) - 2026-01-27
|
||||
See here how to [update](https://opengist.io/docs/update) Opengist.
|
||||
|
||||
### Added
|
||||
- Access tokens (#602)
|
||||
- Fuzzy search for gist search (#555)
|
||||
- Allow Unicode letters/numbers in topics (#597)
|
||||
- Resize editor height (#600)
|
||||
- More translation strings (#516) (#604)
|
||||
|
||||
### Fixed
|
||||
- Don't panic on Go TCP errors (#601)
|
||||
|
||||
### Other
|
||||
- Reduce footprint of Docker image (#515)
|
||||
- Update Go + JS deps (#603)
|
||||
- Configure Dependabot for updates on Go and NPM (#449)
|
||||
|
||||
### [Helm Chart](helm/opengist)
|
||||
- Use existing pvc claim of provided (#547)
|
||||
- Adds StatefulSet support (#549)
|
||||
- Move Prom metrics to a dedicated port + support ServiceMonitor (#599)
|
||||
|
||||
## [1.11.1](https://github.com/thomiceli/opengist/compare/v1.11.0...v1.11.1) - 2025-09-30
|
||||
See here how to [update](https://opengist.io/docs/update) Opengist.
|
||||
|
||||
### Added
|
||||
- More translation strings (#511)
|
||||
|
||||
### Fixed
|
||||
- CSV errors for rendering (#514)
|
||||
|
||||
### Other
|
||||
- Reset default log level to warn
|
||||
|
||||
## [1.11.0](https://github.com/thomiceli/opengist/compare/v1.10.0...v1.11.0) - 2025-09-21
|
||||
See here how to [update](https://opengist.io/docs/update) Opengist.
|
||||
|
||||
|
||||
47
Dockerfile
47
Dockerfile
@@ -1,25 +1,18 @@
|
||||
FROM alpine:3.19 AS base
|
||||
FROM alpine:3.22 AS base
|
||||
|
||||
RUN apk update && \
|
||||
apk add --no-cache \
|
||||
make \
|
||||
shadow \
|
||||
openssl \
|
||||
openssh \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
gnupg \
|
||||
xz \
|
||||
gcc \
|
||||
git \
|
||||
musl-dev \
|
||||
libstdc++
|
||||
|
||||
COPY --from=golang:1.23-alpine /usr/local/go/ /usr/local/go/
|
||||
COPY --from=golang:1.25.6-alpine3.22 /usr/local/go/ /usr/local/go/
|
||||
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
COPY --from=node:20-alpine /usr/local/ /usr/local/
|
||||
COPY --from=node:24.13.0-alpine3.22 /usr/local/ /usr/local/
|
||||
ENV NODE_PATH="/usr/local/lib/node_modules"
|
||||
ENV PATH="/usr/local/bin:${PATH}"
|
||||
|
||||
@@ -29,8 +22,20 @@ COPY . .
|
||||
|
||||
|
||||
FROM base AS dev
|
||||
RUN apk add --no-cache \
|
||||
openssl \
|
||||
openssh-server \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
gnupg \
|
||||
xz
|
||||
|
||||
EXPOSE 6157 6158 2222 16157
|
||||
|
||||
RUN git config --global --add safe.directory /opengist
|
||||
RUN make install
|
||||
|
||||
EXPOSE 6157 2222 16157
|
||||
VOLUME /opengist
|
||||
|
||||
CMD ["make", "watch"]
|
||||
@@ -41,33 +46,25 @@ FROM base AS build
|
||||
RUN make
|
||||
|
||||
|
||||
FROM alpine:3.19 as prod
|
||||
FROM alpine:3.22 AS prod
|
||||
|
||||
RUN apk update && \
|
||||
apk add --no-cache \
|
||||
shadow \
|
||||
openssl \
|
||||
openssh \
|
||||
openssh-server \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
gnupg \
|
||||
xz \
|
||||
gcc \
|
||||
musl-dev \
|
||||
libstdc++
|
||||
git
|
||||
|
||||
RUN addgroup -S opengist && \
|
||||
adduser -S -G opengist -s /bin/ash -g 'Opengist User' opengist
|
||||
|
||||
COPY --from=build --chown=opengist:opengist /opengist/config.yml config.yml
|
||||
|
||||
WORKDIR /app/opengist
|
||||
|
||||
COPY --from=build --chown=opengist:opengist /opengist/config.yml /config.yml
|
||||
COPY --from=build --chown=opengist:opengist /opengist/opengist .
|
||||
COPY --from=build --chown=opengist:opengist /opengist/docker ./docker
|
||||
|
||||
EXPOSE 6157 2222
|
||||
EXPOSE 6157 6158 2222
|
||||
VOLUME /opengist
|
||||
HEALTHCHECK --interval=60s --timeout=30s --start-period=15s --retries=3 CMD curl -f http://localhost:6157/healthcheck || exit 1
|
||||
ENTRYPOINT ["./docker/entrypoint.sh"]
|
||||
|
||||
11
Makefile
11
Makefile
@@ -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..."
|
||||
|
||||
10
README.md
10
README.md
@@ -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.12
|
||||
```
|
||||
|
||||
It can be used in a `docker-compose.yml` file :
|
||||
@@ -50,7 +50,7 @@ It can be used in a `docker-compose.yml` file :
|
||||
```yml
|
||||
services:
|
||||
opengist:
|
||||
image: ghcr.io/thomiceli/opengist:1.11
|
||||
image: ghcr.io/thomiceli/opengist:1.12
|
||||
container_name: opengist
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -77,9 +77,9 @@ Download the archive for your system from the release page [here](https://github
|
||||
|
||||
```shell
|
||||
# example for linux amd64
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.11.0/opengist1.11.0-linux-amd64.tar.gz
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.12.1/opengist1.12.1-linux-amd64.tar.gz
|
||||
|
||||
tar xzvf opengist1.11.0-linux-amd64.tar.gz
|
||||
tar xzvf opengist1.12.1-linux-amd64.tar.gz
|
||||
cd opengist
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
10
config.yml
10
config.yml
@@ -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
|
||||
@@ -55,9 +55,15 @@ http.git-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)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
},
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
logo: 'https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg',
|
||||
logo: 'https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg',
|
||||
logoLink: '/',
|
||||
nav: [
|
||||
{ text: 'Demo', link: 'https://demo.opengist.io' },
|
||||
@@ -55,6 +55,7 @@ export default defineConfig({
|
||||
text: 'Usage', base: '/docs/usage', items: [
|
||||
{text: 'Init via Git', link: '/init-via-git'},
|
||||
{text: 'Embed Gist', link: '/embed'},
|
||||
{text: 'Access Tokens', link: '/access-tokens'},
|
||||
{text: 'Gist as JSON', link: '/gist-json'},
|
||||
{text: 'Import Gists from Github', link: '/import-from-github-gist'},
|
||||
{text: 'Git push options', link: '/git-push-options'},
|
||||
|
||||
@@ -17,9 +17,9 @@ export default {
|
||||
<header class="hero">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div class="mx-auto lg:text-center">
|
||||
<img class="rotating h-36 mx-auto my-8 " src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg" alt="" >
|
||||
<img class="rotating h-36 mx-auto my-8 " src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg" alt="" >
|
||||
<a target="_blank" href="https://github.com/thomiceli/opengist/releases" class="inline-flex items-center rounded-full bg-indigo-100 hover:bg-indigo-200 px-4 py-1.5 text-lg font-medium text-indigo-700">
|
||||
<span class="pr-1">Released 1.11</span>
|
||||
<span class="pr-1">Released 1.12</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
|
||||
</svg>
|
||||
@@ -98,4 +98,4 @@ export default {
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,9 @@ aside: false
|
||||
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
|
||||
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
|
||||
| unix-socket-permissions | OG_UNIX_SOCKET_PERMISSIONS | `0666` | File permissions for Unix socket (octal format). |
|
||||
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics 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. |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.12.1/opengist1.12.1-linux-amd64.tar.gz
|
||||
|
||||
tar xzvf opengist1.11.0-linux-amd64.tar.gz
|
||||
tar xzvf opengist1.12.1-linux-amd64.tar.gz
|
||||
cd opengist
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
@@ -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.12.1 # optional, to checkout the latest release
|
||||
|
||||
make
|
||||
./opengist
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.12.1/opengist1.12.1-linux-amd64.tar.gz
|
||||
|
||||
tar xzvf opengist1.11.0-linux-amd64.tar.gz
|
||||
tar xzvf opengist1.12.1-linux-amd64.tar.gz
|
||||
cd opengist
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
26
docs/usage/access-tokens.md
Normal file
26
docs/usage/access-tokens.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Access tokens
|
||||
|
||||
Access tokens are used to access your private gists and their raw content. For now, it is the only use while a future API is being developed.
|
||||
|
||||
## Creating an access token
|
||||
|
||||
To create an access token, follow these steps:
|
||||
1. Go to Settings
|
||||
2. Select the "Access Tokens" menu
|
||||
3. Choose a name for your token, the scope and an expiration date (optional), then click "Create Access Token"
|
||||
|
||||
## Using an access token
|
||||
|
||||
Once you have created an access token, you can use it to access your private gists with it.
|
||||
|
||||
Replace `<token>` with your actual access token in the following examples.
|
||||
|
||||
```shell
|
||||
# Access raw content of a private gist, latest revision for "file.txt". Note: this URL is obtained from the "Raw" button on the gist page.
|
||||
curl -H "Authorization: Token <token>" \
|
||||
http://opengist.example.com/user/gist/raw/HEAD/file.txt
|
||||
|
||||
# Access the JSON representation of a private gist. See "Gist as JSON" documentation for more details.
|
||||
curl -H "Authorization: Token <token>" \
|
||||
http://opengist.example.com/user/gist.json
|
||||
```
|
||||
144
go.mod
144
go.mod
@@ -1,129 +1,125 @@
|
||||
module github.com/thomiceli/opengist
|
||||
|
||||
go 1.23.0
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/Kunde21/markdownfmt/v3 v3.1.0
|
||||
github.com/alecthomas/chroma/v2 v2.20.0
|
||||
github.com/blevesearch/bleve/v2 v2.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/blevesearch/bleve/v2 v2.5.7
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/gabriel-vasile/mimetype v1.4.8
|
||||
github.com/gabriel-vasile/mimetype v1.4.12
|
||||
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.12
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/go-webauthn/webauthn v0.15.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/schema v1.4.1
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/labstack/echo-contrib v0.17.3
|
||||
github.com/labstack/echo/v4 v4.13.3
|
||||
github.com/markbates/goth v1.81.0
|
||||
github.com/meilisearch/meilisearch-go v0.31.0
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/labstack/echo-contrib v0.17.4
|
||||
github.com/labstack/echo/v4 v4.15.0
|
||||
github.com/markbates/goth v1.82.0
|
||||
github.com/meilisearch/meilisearch-go v0.36.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/cli/v2 v2.27.6
|
||||
github.com/yuin/goldmark v1.7.8
|
||||
github.com/yuin/goldmark-emoji v1.0.5
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
github.com/yuin/goldmark-emoji v1.0.6
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
go.abhg.dev/goldmark/mermaid v0.5.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/text v0.23.0
|
||||
go.abhg.dev/goldmark/mermaid v0.6.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/text v0.33.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
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.22.0 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.2.7 // indirect
|
||||
github.com/blevesearch/geo v0.1.20 // indirect
|
||||
github.com/blevesearch/go-faiss v1.0.25 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.3.1 // indirect
|
||||
github.com/blevesearch/geo v0.2.4 // indirect
|
||||
github.com/blevesearch/go-faiss v1.0.27 // indirect
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
|
||||
github.com/blevesearch/gtreap v0.1.1 // indirect
|
||||
github.com/blevesearch/mmap-go v1.0.4 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 // indirect
|
||||
github.com/blevesearch/mmap-go v1.2.0 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.1 // indirect
|
||||
github.com/blevesearch/segment v0.9.1 // indirect
|
||||
github.com/blevesearch/snowballstem v0.9.0 // indirect
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
|
||||
github.com/blevesearch/vellum v1.1.0 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.4.1 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.4.1 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.4.1 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.4.1 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.4.1 // indirect
|
||||
github.com/blevesearch/zapx/v16 v16.2.2 // indirect
|
||||
github.com/boombuler/barcode v1.0.2 // indirect
|
||||
github.com/blevesearch/vellum v1.2.0 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v16 v16.3.0 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // 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.2.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.20 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.26 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/go-tpm v0.9.3 // indirect
|
||||
github.com/google/go-tpm v0.9.6 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
go.etcd.io/bbolt v1.4.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/oauth2 v0.29.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
modernc.org/libc v1.62.1 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.67.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.9.1 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.44.3 // indirect
|
||||
)
|
||||
|
||||
406
go.sum
406
go.sum
@@ -1,78 +1,81 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0=
|
||||
github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
github.com/alecthomas/chroma/v2 v2.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.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/alecthomas/repr v0.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.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
|
||||
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4=
|
||||
github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk=
|
||||
github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY=
|
||||
github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
|
||||
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
|
||||
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
|
||||
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
|
||||
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
|
||||
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
|
||||
github.com/blevesearch/bleve_index_api v1.3.1 h1:LdH3CQgBbIZ5UI/5Pykz87e0jfeQtVnrdZ2WUBrHHwU=
|
||||
github.com/blevesearch/bleve_index_api v1.3.1/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
|
||||
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
|
||||
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
|
||||
github.com/blevesearch/go-faiss v1.0.27 h1:7cBImYDDQ82WJd5RUZ1ie6zXztCsC73W94ZzwOjkatk=
|
||||
github.com/blevesearch/go-faiss v1.0.27/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
|
||||
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
|
||||
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
|
||||
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
|
||||
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 h1:X6nJXnNHl7nasXW+U6y2Ns2Aw8F9STszkYkyBfQ+p0o=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.9/go.mod h1:IrzspZlVjhf4X29oJiEhBxEteTqOY9RlYlk1lCmYHr4=
|
||||
github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
|
||||
github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.1 h1:os52/JeCSLZ0YUkOuLk/Z7pu0SKUMofDPUg+VnbrRD0=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.1/go.mod h1:zvilBm4BNfbnTRLW7KgCTNgk2R31JaWzwRc2BEcD7Is=
|
||||
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
|
||||
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
|
||||
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
|
||||
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
|
||||
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
|
||||
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
|
||||
github.com/blevesearch/zapx/v11 v11.4.1 h1:qFCPlFbsEdwbbckJkysptSQOsHn4s6ZOHL5GMAIAVHA=
|
||||
github.com/blevesearch/zapx/v11 v11.4.1/go.mod h1:qNOGxIqdPC1MXauJCD9HBG487PxviTUUbmChFOAosGs=
|
||||
github.com/blevesearch/zapx/v12 v12.4.1 h1:K77bhypII60a4v8mwvav7r4IxWA8qxhNjgF9xGdb9eQ=
|
||||
github.com/blevesearch/zapx/v12 v12.4.1/go.mod h1:QRPrlPOzAxBNMI0MkgdD+xsTqx65zbuPr3Ko4Re49II=
|
||||
github.com/blevesearch/zapx/v13 v13.4.1 h1:EnkEMZFUK0lsW/jOJJF2xOcp+W8TjEsyeN5BeAZEYYE=
|
||||
github.com/blevesearch/zapx/v13 v13.4.1/go.mod h1:e6duBMlCvgbH9rkzNMnUa9hRI9F7ri2BRcHfphcmGn8=
|
||||
github.com/blevesearch/zapx/v14 v14.4.1 h1:G47kGCshknBZzZAtjcnIAMn3oNx8XBLxp8DMq18ogyE=
|
||||
github.com/blevesearch/zapx/v14 v14.4.1/go.mod h1:O7sDxiaL2r2PnCXbhh1Bvm7b4sP+jp4unE9DDPWGoms=
|
||||
github.com/blevesearch/zapx/v15 v15.4.1 h1:B5IoTMUCEzFdc9FSQbhVOxAY+BO17c05866fNruiI7g=
|
||||
github.com/blevesearch/zapx/v15 v15.4.1/go.mod h1:b/MreHjYeQoLjyY2+UaM0hGZZUajEbE0xhnr1A2/Q6Y=
|
||||
github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc6r0Rzk=
|
||||
github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk=
|
||||
github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
|
||||
github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
|
||||
github.com/blevesearch/zapx/v16 v16.3.0 h1:hF6VlN15E9CB40RMPyqOIhlDw1OOo9RItumhKMQktxw=
|
||||
github.com/blevesearch/zapx/v16 v16.3.0/go.mod h1:zCFjv7McXWm1C8rROL+3mUoD5WYe2RKsZP3ufqcYpLY=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
|
||||
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 h1:wMSvdj3BswqfQOXp2R1bJOAE7xIQLt2dlMQDMf836VY=
|
||||
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
|
||||
github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0=
|
||||
github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
|
||||
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -83,57 +86,53 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/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.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
|
||||
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY=
|
||||
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw=
|
||||
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
|
||||
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3 h1:8COTSTFIIXnaD81+kfCw4dRANNAKuCp06EdYLqwX30g=
|
||||
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -145,23 +144,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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -180,8 +178,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,18 +188,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo-contrib v0.17.3 h1:hj+qXksKZG1scSe9ksUXMtv7fZYN+PtQT+bPcYA3/TY=
|
||||
github.com/labstack/echo-contrib v0.17.3/go.mod h1:TcRBrzW8jcC4JD+5Dc/pvOyAps0rtgzj7oBqoR3nYsc=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
|
||||
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE=
|
||||
github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k=
|
||||
github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
|
||||
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -211,12 +205,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/meilisearch/meilisearch-go v0.31.0 h1:yZRhY1qJqdH8h6GFZALGtkDLyj8f9v5aJpsNMyrUmnY=
|
||||
github.com/meilisearch/meilisearch-go v0.31.0/go.mod h1:aNtyuwurDg/ggxQIcKqWH6G9g2ptc8GyY7PLY4zMn/g=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/meilisearch/meilisearch-go v0.36.0 h1:N1etykTektXt5KPcSbhBO0d5Xx5NaKj4pJWEM7WA5dI=
|
||||
github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6Vk9IivcwKocWRuW7W78=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -226,26 +218,23 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
@@ -254,149 +243,102 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/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.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/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.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||
go.abhg.dev/goldmark/mermaid v0.5.0 h1:mDkykpSPJ+5wCQ8bSXgzJ2KQskjXkI5Ndxz7JYDHW38=
|
||||
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs=
|
||||
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
|
||||
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
|
||||
golang.org/x/crypto v0.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/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=
|
||||
go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY=
|
||||
go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.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/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/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
||||
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
|
||||
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
29
helm/opengist/CHANGELOG.md
Normal file
29
helm/opengist/CHANGELOG.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Helm Chart Changelog
|
||||
|
||||
## 0.6.0 - 2026-02-03
|
||||
|
||||
- Bump Opengist image to 1.12.1
|
||||
|
||||
## 0.5.0 - 2026-01-27
|
||||
|
||||
- Bump Opengist image to 1.12.0
|
||||
- Add StatefulSet support
|
||||
- Add Prometheus ServiceMonitor support if Opengist metrics are enabled
|
||||
- New service for metrics endpoint, dissociated from the main service
|
||||
- Use existing pvc claim of provided
|
||||
|
||||
## 0.4.0 - 2025-09-30
|
||||
|
||||
- Bump Opengist image to 1.11.1
|
||||
|
||||
## 0.3.0 - 2025-09-21
|
||||
|
||||
- Bump Opengist image to 1.11.0
|
||||
|
||||
## 0.2.0 - 2025-05-10
|
||||
|
||||
- Add `deployment.env[]` in values
|
||||
|
||||
## 0.1.0 - 2025-04-06
|
||||
|
||||
- Initial release, with Opengist image 1.10.0
|
||||
@@ -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.6.0
|
||||
appVersion: 1.12.1
|
||||
home: https://opengist.io
|
||||
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg
|
||||
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg
|
||||
sources:
|
||||
- https://github.com/thomiceli/opengist
|
||||
dependencies:
|
||||
|
||||
@@ -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:
|
||||
@@ -66,6 +67,11 @@ spec:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.http.port }}
|
||||
protocol: TCP
|
||||
{{- if index .Values.config "metrics.enabled" }}
|
||||
- name: metrics
|
||||
containerPort: {{ .Values.service.metrics.port }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
{{- if .Values.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}
|
||||
@@ -95,7 +101,11 @@ spec:
|
||||
- name: opengist-data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
{{- if .Values.persistence.existingClaim }}
|
||||
claimName: {{ .Values.persistence.existingClaim }}
|
||||
{{- else }}
|
||||
claimName: {{ include "opengist.fullname" . }}-data
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
@@ -120,3 +130,5 @@ spec:
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
{{- end }}
|
||||
|
||||
48
helm/opengist/templates/pvc-shared.yaml
Normal file
48
helm/opengist/templates/pvc-shared.yaml
Normal file
@@ -0,0 +1,48 @@
|
||||
{{- /*
|
||||
This template creates a standalone PersistentVolumeClaim for shared persistence mode.
|
||||
|
||||
Rendering conditions:
|
||||
- statefulSet.enabled=true
|
||||
- persistence.enabled=true
|
||||
- persistence.mode=shared
|
||||
- persistence.existingClaim is empty/unset
|
||||
- persistence.create.enabled=true
|
||||
|
||||
When rendered, this PVC is mounted by ALL replicas in the StatefulSet (typically with ReadWriteMany
|
||||
access mode for multi-replica deployments). This avoids per-replica volumeClaimTemplates and enables
|
||||
horizontal scaling with a single shared storage backend.
|
||||
|
||||
If persistence.existingClaim is set, this template does NOT render; the StatefulSet instead references
|
||||
the existing claim name directly.
|
||||
*/}}
|
||||
{{- if and .Values.statefulSet.enabled .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "shared") (ne (default "" .Values.persistence.existingClaim) "") | not }}{{- end }}
|
||||
{{- if and .Values.statefulSet.enabled .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "shared") (eq (default "" .Values.persistence.existingClaim) "") .Values.persistence.create.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "opengist.fullname" . }}-{{ default "shared" .Values.persistence.create.nameSuffix }}
|
||||
labels:
|
||||
{{- include "opengist.labels" . | nindent 4 }}
|
||||
{{- with .Values.persistence.create.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.persistence.create.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- if .Values.persistence.create.accessModes }}
|
||||
{{- toYaml .Values.persistence.create.accessModes | nindent 4 }}
|
||||
{{- else }}
|
||||
- ReadWriteMany
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ default .Values.persistence.size .Values.persistence.create.size }}
|
||||
volumeMode: Filesystem
|
||||
{{- $sc := default .Values.persistence.storageClass .Values.persistence.create.storageClass }}
|
||||
{{- if $sc }}
|
||||
storageClassName: {{ $sc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -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 := 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 }}
|
||||
41
helm/opengist/templates/servicemonitor.yaml
Normal file
41
helm/opengist/templates/servicemonitor.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
{{- if and (index .Values.config "metrics.enabled") .Values.service.metrics.serviceMonitor.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "opengist.fullname" . }}
|
||||
namespace: {{ .Values.namespace | default .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "opengist.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.metrics.serviceMonitor.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.service.metrics.serviceMonitor.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
endpoints:
|
||||
- port: metrics
|
||||
{{- with .Values.service.metrics.serviceMonitor.interval }}
|
||||
interval: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .Values.service.metrics.serviceMonitor.scrapeTimeout }}
|
||||
scrapeTimeout: {{ . }}
|
||||
{{- end }}
|
||||
path: /metrics
|
||||
{{- with .Values.service.metrics.serviceMonitor.relabelings }}
|
||||
relabelings:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.service.metrics.serviceMonitor.metricRelabelings }}
|
||||
metricRelabelings:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- {{ .Values.namespace | default .Release.Namespace }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "opengist.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: metrics
|
||||
{{- end }}
|
||||
267
helm/opengist/templates/statefulset.yaml
Normal file
267
helm/opengist/templates/statefulset.yaml
Normal file
@@ -0,0 +1,267 @@
|
||||
{{- if .Values.statefulSet.enabled }}
|
||||
{{- /*
|
||||
========================================
|
||||
VALIDATION BLOCK: Multi-replica requirements
|
||||
========================================
|
||||
Enforces constraints for scaling beyond 1 replica:
|
||||
1. Database: Must use PostgreSQL/MySQL (not SQLite)
|
||||
2. Persistence: Must be enabled
|
||||
3. Storage sharing: Must use either existingClaim or mode=shared with create.enabled
|
||||
4. Access mode: For mode=shared + create, must specify ReadWriteMany
|
||||
*/}}
|
||||
{{- $replicas := int .Values.replicaCount }}
|
||||
{{- $dburi := "" }}
|
||||
{{- if and .Values.config (hasKey .Values.config "dbUri") }}
|
||||
{{- $dburi = (index .Values.config "dbUri") }}
|
||||
{{- else if and .Values.config (hasKey .Values.config "db-uri") }}
|
||||
{{- $dburi = (index .Values.config "db-uri") }}
|
||||
{{- end }}
|
||||
{{- $scheme := "" }}
|
||||
{{- if ne $dburi "" }}
|
||||
{{- $parts := splitList "://" $dburi }}
|
||||
{{- if gt (len $parts) 0 }}
|
||||
{{- $scheme = lower (index $parts 0) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- $multiAllowed := or (eq $scheme "postgres") (eq $scheme "postgresql") (eq $scheme "mysql") (eq $scheme "mariadb") }}
|
||||
{{- $p := .Values.persistence }}
|
||||
{{- $mode := default "perReplica" $p.mode }}
|
||||
{{- $hasExisting := ne (default "" $p.existingClaim) "" }}
|
||||
{{- $isShared := eq $mode "shared" }}
|
||||
|
||||
{{- /* Fail fast: Database validation */}}
|
||||
{{- if and (gt $replicas 1) (not $multiAllowed) }}
|
||||
{{- fail (printf "replicaCount=%d requires PostgreSQL/MySQL config.db-uri; scheme '%s' unsupported" $replicas $scheme) }}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Fail fast: Persistence must be enabled */}}
|
||||
{{- if and (gt $replicas 1) (not $p.enabled) }}
|
||||
{{- fail (printf "replicaCount=%d requires persistence.enabled=true" $replicas) }}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Fail fast: Prevent per-replica PVC divergence */}}
|
||||
{{- if and (gt $replicas 1) (not (or $hasExisting $isShared)) }}
|
||||
{{- fail (printf "replicaCount=%d requires either persistence.existingClaim (shared RWX PVC) OR persistence.mode=shared to create one; perReplica PVCs would diverge" $replicas) }}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Fail fast: Shared mode requires PVC source */}}
|
||||
{{- if and (gt $replicas 1) $isShared (not $hasExisting) (hasKey $p "create") (not (get $p.create "enabled")) }}
|
||||
{{- fail (printf "persistence.mode=shared but neither existingClaim nor create.enabled=true provided") }}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Fail fast: Auto-created shared PVC must be RWX */}}
|
||||
{{- if and (gt $replicas 1) $isShared (not $hasExisting) $p.create.enabled }}
|
||||
{{- $am := list }}
|
||||
{{- if hasKey $p.create "accessModes" }}
|
||||
{{- $am = $p.create.accessModes }}
|
||||
{{- end }}
|
||||
{{- $rwxOk := false }}
|
||||
{{- range $am }}
|
||||
{{- if or (eq . "ReadWriteMany") (eq . "RWX") }}
|
||||
{{- $rwxOk = true }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if not $rwxOk }}
|
||||
{{- fail "persistence.mode=shared create.accessModes must include ReadWriteMany for multi-replica" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "opengist.fullname" . }}
|
||||
namespace: {{ .Values.namespace | default .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "opengist.labels" . | nindent 4 }}
|
||||
{{- if .Values.deployment.labels }}
|
||||
{{- toYaml .Values.deployment.labels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.deployment.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
serviceName: {{ include "opengist.fullname" . }}-http
|
||||
podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }}
|
||||
updateStrategy:
|
||||
{{- toYaml .Values.statefulSet.updateStrategy | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "opengist.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "opengist.labels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.deployment.terminationGracePeriodSeconds }}
|
||||
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "opengist.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
initContainers:
|
||||
- name: init-config
|
||||
image: busybox:1.37
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ['sh', '-c', 'cp /init/config/config.yml /config-volume/config.yml']
|
||||
volumeMounts:
|
||||
- name: config-secret
|
||||
mountPath: /init/config
|
||||
- name: config-volume
|
||||
mountPath: /config-volume
|
||||
{{- if .Values.deployment.env }}
|
||||
env:
|
||||
{{- toYaml .Values.deployment.env | nindent 12 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.http.port }}
|
||||
protocol: TCP
|
||||
{{- if .Values.service.ssh.enabled }}
|
||||
- name: ssh
|
||||
containerPort: {{ .Values.service.ssh.port }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
{{- if index .Values.config "metrics.enabled" }}
|
||||
- name: metrics
|
||||
containerPort: {{ .Values.service.metrics.port }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
{{- if .Values.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}
|
||||
httpGet:
|
||||
port: http
|
||||
path: /healthcheck
|
||||
{{- end }}
|
||||
{{- if .Values.readinessProbe.enabled }}
|
||||
readinessProbe:
|
||||
{{- toYaml (omit .Values.readinessProbe "enabled") | nindent 12 }}
|
||||
httpGet:
|
||||
port: http
|
||||
path: /healthcheck
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: config-volume
|
||||
mountPath: /config.yml
|
||||
subPath: config.yml
|
||||
- name: opengist-data
|
||||
mountPath: /opengist
|
||||
{{- if gt (len .Values.extraVolumeMounts) 0 }}
|
||||
{{- toYaml .Values.extraVolumeMounts | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: config-secret
|
||||
secret:
|
||||
secretName: {{ include "opengist.secretName" . }}
|
||||
defaultMode: 511
|
||||
- name: config-volume
|
||||
emptyDir: {}
|
||||
{{- /*
|
||||
========================================
|
||||
VOLUME MOUNTING DECISION TREE
|
||||
========================================
|
||||
Priority order:
|
||||
1. existingClaim (user-provided PVC) → mount directly
|
||||
2. mode=shared (chart-created PVC) → mount shared PVC
|
||||
3. mode=perReplica → use volumeClaimTemplates (defined below)
|
||||
4. persistence disabled → use emptyDir (ephemeral)
|
||||
*/}}
|
||||
{{- if .Values.persistence.enabled }}
|
||||
{{- if ne (default "" .Values.persistence.existingClaim) "" }}
|
||||
{{- /* User-provided existing claim: mount directly */}}
|
||||
- name: opengist-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.persistence.existingClaim }}
|
||||
{{- else if eq (default "perReplica" .Values.persistence.mode) "shared" }}
|
||||
{{- /* Chart creates shared PVC (via pvc-shared.yaml), reference by name */}}
|
||||
- name: opengist-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "opengist.fullname" . }}-{{ default "shared" .Values.persistence.create.nameSuffix }}
|
||||
{{- else if not .Values.persistence.enabled }}
|
||||
- name: opengist-data
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
- name: opengist-data
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if gt (len .Values.extraVolumes) 0 }}
|
||||
{{- toYaml .Values.extraVolumes | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- /*
|
||||
========================================
|
||||
VOLUMECLAIMTEMPLATES DECISION TREE
|
||||
========================================
|
||||
volumeClaimTemplates are ONLY used for perReplica mode when:
|
||||
- persistence.enabled=true
|
||||
- persistence.existingClaim is empty
|
||||
- persistence.mode=perReplica (default)
|
||||
|
||||
This creates one PVC per replica (RWO typically).
|
||||
|
||||
NOT used when:
|
||||
- existingClaim is set (PVC already exists, referenced in volumes above)
|
||||
- mode=shared (standalone PVC created via pvc-shared.yaml)
|
||||
- persistence disabled (emptyDir used)
|
||||
|
||||
WARNING: perReplica + replicaCount>1 causes data divergence. Use shared mode for multi-replica.
|
||||
*/}}
|
||||
{{- if and .Values.persistence.enabled (ne (default "" .Values.persistence.existingClaim) "") }}
|
||||
{{- /* existingClaim path: no volumeClaimTemplates, already mounted above */}}
|
||||
{{- else if and .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "shared") }}
|
||||
{{- /* shared mode: no volumeClaimTemplates, standalone PVC rendered via pvc-shared.yaml */}}
|
||||
{{- else if and .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "perReplica") }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: opengist-data
|
||||
labels:
|
||||
{{- include "opengist.labels" . | nindent 10 }}
|
||||
{{- with .Values.persistence.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- .Values.persistence.accessModes | toYaml | nindent 10 }}
|
||||
volumeMode: Filesystem
|
||||
{{- if .Values.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size | default "10Gi" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
32
helm/opengist/templates/svc-metrics.yaml
Normal file
32
helm/opengist/templates/svc-metrics.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
{{- if index .Values.config "metrics.enabled" }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "opengist.fullname" . }}-metrics
|
||||
namespace: {{ .Values.namespace | default .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "opengist.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: metrics
|
||||
{{- with .Values.service.metrics.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.service.metrics.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.metrics.type }}
|
||||
{{- if .Values.service.metrics.clusterIP }}
|
||||
clusterIP: {{ .Values.service.metrics.clusterIP }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- port: {{ .Values.service.metrics.port }}
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
name: metrics
|
||||
{{- if and (eq .Values.service.metrics.type "NodePort") .Values.service.metrics.nodePort }}
|
||||
nodePort: {{ .Values.service.metrics.nodePort }}
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "opengist.selectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -8,6 +8,7 @@ namespace: ""
|
||||
config:
|
||||
log-level: "warn"
|
||||
log-output: "stdout"
|
||||
metrics.enabled: false
|
||||
|
||||
## If defined, the existing secret will be used instead of creating a new one.
|
||||
## The secret must contain a key named `config.yml` with the YAML configuration.
|
||||
@@ -17,7 +18,7 @@ configExistingSecret: ""
|
||||
image:
|
||||
repository: ghcr.io/thomiceli/opengist
|
||||
pullPolicy: Always
|
||||
tag: "1.10.0"
|
||||
tag: "1.12.1"
|
||||
digest: ""
|
||||
imagePullSecrets: []
|
||||
# - name: "image-pull-secret"
|
||||
@@ -32,6 +33,34 @@ strategy:
|
||||
maxSurge: "100%"
|
||||
maxUnavailable: 0
|
||||
|
||||
## StatefulSet configuration
|
||||
## Enables StatefulSet workload instead of Deployment (required for volumeClaimTemplates or stable pod identities).
|
||||
##
|
||||
## Single-replica SQLite example (default behavior):
|
||||
## statefulSet.enabled: true
|
||||
## replicaCount: 1
|
||||
## persistence.mode: perReplica # or omit (default)
|
||||
## # Creates one PVC per pod via volumeClaimTemplates (RWO)
|
||||
##
|
||||
## Multi-replica requirements (replicaCount > 1):
|
||||
## 1. External database: config.db-uri must be postgres:// or mysql:// (SQLite NOT supported)
|
||||
## 2. Shared storage: Use ONE of:
|
||||
## a) Existing claim: persistence.existingClaim: "my-rwx-pvc"
|
||||
## b) Chart-created: persistence.mode: shared + persistence.create.enabled: true + accessModes: [ReadWriteMany]
|
||||
## 3. Chart will FAIL FAST if constraints are not met to prevent data divergence
|
||||
##
|
||||
## Persistence decision tree:
|
||||
## - persistence.existingClaim set → mount that PVC directly (no volumeClaimTemplates)
|
||||
## - persistence.mode=shared + create.* → chart creates single RWX PVC, all pods mount it
|
||||
## - persistence.mode=perReplica (default) → volumeClaimTemplates (one PVC/pod, RWO typically)
|
||||
## - persistence.enabled=false → emptyDir (ephemeral)
|
||||
|
||||
statefulSet:
|
||||
enabled: false
|
||||
podManagementPolicy: OrderedReady
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
|
||||
## Security Context settings
|
||||
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
|
||||
podSecurityContext:
|
||||
@@ -73,6 +102,26 @@ service:
|
||||
loadBalancerSourceRanges: []
|
||||
externalTrafficPolicy:
|
||||
|
||||
# A metrics K8S service on port 6158 is created when the Opengist config metrics.enabled: true
|
||||
metrics:
|
||||
type: ClusterIP
|
||||
clusterIP:
|
||||
port: 6158
|
||||
nodePort:
|
||||
labels: {}
|
||||
annotations: {}
|
||||
|
||||
# A service monitor can be used to work with your Prometheus setup.
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
labels: {}
|
||||
# release: kube-prom-stack
|
||||
interval:
|
||||
scrapeTimeout:
|
||||
annotations: {}
|
||||
relabelings: []
|
||||
metricRelabelings: []
|
||||
|
||||
## HTTP Ingress for Opengist
|
||||
## ref: https://kubernetes.io/docs/concepts/services-networking/ingress/
|
||||
ingress:
|
||||
@@ -99,20 +148,66 @@ serviceAccount:
|
||||
annotations: {}
|
||||
name: ""
|
||||
|
||||
## Set persistence using a Persistent Volume Claim
|
||||
## If more than 2 replicas are set, the access mode must be ReadWriteMany
|
||||
## Persistent storage for /opengist data directory
|
||||
## ref: https://kubernetes.io/docs/concepts/storage/persistent-volumes/
|
||||
persistence:
|
||||
enabled: true
|
||||
|
||||
## Persistence mode controls how storage is provisioned:
|
||||
##
|
||||
## perReplica (DEFAULT):
|
||||
## - StatefulSet creates one PVC per replica via volumeClaimTemplates
|
||||
## - Typically RWO (ReadWriteOnce) storage
|
||||
## - Safe ONLY for replicaCount=1 (multi-replica causes data divergence)
|
||||
## - Use when: single-node dev/test, no horizontal scaling needed
|
||||
##
|
||||
## shared:
|
||||
## - Single RWX (ReadWriteMany) PVC shared by all replicas
|
||||
## - Required for replicaCount > 1
|
||||
## - Two provisioning paths:
|
||||
## a) existingClaim: "my-rwx-pvc" (you manage the PVC lifecycle)
|
||||
## b) existingClaim: "" + create.enabled: true (chart creates PVC automatically)
|
||||
## - Use when: multi-replica HA, horizontal scaling, shared file access
|
||||
##
|
||||
## WARNING: Switching modes after initial deploy requires manual data migration:
|
||||
## 1. Scale down to 1 replica
|
||||
## 2. Create/provision RWX PVC and copy data
|
||||
## 3. Update values: mode=shared, existingClaim or create.enabled
|
||||
## 4. Scale up
|
||||
mode: perReplica
|
||||
|
||||
## Reference an existing PVC (takes precedence over create.*)
|
||||
## When set:
|
||||
## - Chart will NOT create a PVC
|
||||
## - StatefulSet mounts this claim directly (no volumeClaimTemplates)
|
||||
## - Must be RWX for replicaCount > 1
|
||||
## Example: existingClaim: "opengist-shared-rwx"
|
||||
existingClaim: ""
|
||||
storageClass: ""
|
||||
|
||||
## Common persistence parameters (apply to perReplica mode OR as defaults for create.*)
|
||||
storageClass: "" # Empty = cluster default
|
||||
labels: {}
|
||||
annotations:
|
||||
helm.sh/resource-policy: keep
|
||||
helm.sh/resource-policy: keep # Prevents PVC deletion on helm uninstall
|
||||
size: 5Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
subPath: ""
|
||||
- ReadWriteOnce # perReplica default; override to [ReadWriteMany] if using existingClaim
|
||||
subPath: "" # Optional subpath within volume
|
||||
|
||||
## Chart-managed PVC creation (ONLY for mode=shared when existingClaim is empty)
|
||||
## Renders templates/pvc-shared.yaml
|
||||
create:
|
||||
enabled: true
|
||||
nameSuffix: shared # PVC name: <release-name>-shared
|
||||
storageClass: "" # Empty = cluster default; override if you need specific storage class
|
||||
size: 5Gi # Override top-level persistence.size if needed
|
||||
accessModes:
|
||||
- ReadWriteMany # REQUIRED for multi-replica; NFS/CephFS/Longhorn RWX/etc.
|
||||
labels: {}
|
||||
annotations: {}
|
||||
## Example for specific storage:
|
||||
## storageClass: "nfs-client"
|
||||
## size: 20Gi
|
||||
|
||||
extraVolumes: []
|
||||
extraVolumeMounts: []
|
||||
|
||||
@@ -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,7 @@ func (p *GiteaCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
|
||||
|
||||
func (p *GiteaCallbackProvider) UpdateUserDB(user *db.User) {
|
||||
user.GiteaID = p.User.UserID
|
||||
|
||||
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)
|
||||
user.AvatarURL = p.User.AvatarURL
|
||||
}
|
||||
|
||||
func NewGiteaCallbackProvider(user *goth.User) CallbackProvider {
|
||||
|
||||
427
internal/auth/password/argon2id_test.go
Normal file
427
internal/auth/password/argon2id_test.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package password
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArgon2ID_Hash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
plain string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic password",
|
||||
plain: "password123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
plain: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "long password",
|
||||
plain: strings.Repeat("a", 10000),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unicode password",
|
||||
plain: "パスワード🔒",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
plain: "!@#$%^&*()_+-=[]{}|;:',.<>?/`~",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash, err := Argon2id.Hash(tt.plain)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Argon2id.Hash() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
// Verify the hash format
|
||||
if !strings.HasPrefix(hash, "$argon2id$") {
|
||||
t.Errorf("Hash does not start with $argon2id$: %v", hash)
|
||||
}
|
||||
|
||||
// Verify all parts are present
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 6 {
|
||||
t.Errorf("Hash has %d parts, expected 6: %v", len(parts), hash)
|
||||
}
|
||||
|
||||
// Verify salt is properly encoded
|
||||
if len(parts) >= 5 {
|
||||
_, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
t.Errorf("Salt is not properly base64 encoded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify hash is properly encoded
|
||||
if len(parts) >= 6 {
|
||||
_, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
t.Errorf("Hash is not properly base64 encoded: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgon2ID_Verify(t *testing.T) {
|
||||
// Generate a valid hash for testing
|
||||
testPassword := "correctpassword"
|
||||
validHash, err := Argon2id.Hash(testPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate test hash: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
plain string
|
||||
hash string
|
||||
wantMatch bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "correct password",
|
||||
plain: testPassword,
|
||||
hash: validHash,
|
||||
wantMatch: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "incorrect password",
|
||||
plain: "wrongpassword",
|
||||
hash: validHash,
|
||||
wantMatch: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
plain: "",
|
||||
hash: validHash,
|
||||
wantMatch: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty hash",
|
||||
plain: testPassword,
|
||||
hash: "",
|
||||
wantMatch: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid hash - too few parts",
|
||||
plain: testPassword,
|
||||
hash: "$argon2id$v=19$m=65536",
|
||||
wantMatch: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hash - too many parts",
|
||||
plain: testPassword,
|
||||
hash: "$argon2id$v=19$m=65536,t=1,p=4$salt$hash$extra",
|
||||
wantMatch: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hash - malformed parameters",
|
||||
plain: testPassword,
|
||||
hash: "$argon2id$v=19$invalid$salt$hash",
|
||||
wantMatch: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hash - bad base64 salt",
|
||||
plain: testPassword,
|
||||
hash: "$argon2id$v=19$m=65536,t=1,p=4$not-valid-base64!@#$hash",
|
||||
wantMatch: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hash - bad base64 hash",
|
||||
plain: testPassword,
|
||||
hash: "$argon2id$v=19$m=65536,t=1,p=4$dGVzdA$not-valid-base64!@#",
|
||||
wantMatch: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong algorithm prefix",
|
||||
plain: testPassword,
|
||||
hash: "$bcrypt$rounds=10$saltsaltsaltsaltsalt",
|
||||
wantMatch: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
match, err := Argon2id.Verify(tt.plain, tt.hash)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Argon2id.Verify() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if match != tt.wantMatch {
|
||||
t.Errorf("Argon2id.Verify() match = %v, wantMatch %v", match, tt.wantMatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgon2ID_SaltUniqueness(t *testing.T) {
|
||||
password := "testpassword"
|
||||
iterations := 10
|
||||
|
||||
hashes := make(map[string]bool)
|
||||
salts := make(map[string]bool)
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
hash, err := Argon2id.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash iteration %d failed: %v", i, err)
|
||||
}
|
||||
|
||||
// Check hash uniqueness
|
||||
if hashes[hash] {
|
||||
t.Errorf("Duplicate hash generated at iteration %d", i)
|
||||
}
|
||||
hashes[hash] = true
|
||||
|
||||
// Extract and check salt uniqueness
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) >= 5 {
|
||||
salt := parts[4]
|
||||
if salts[salt] {
|
||||
t.Errorf("Duplicate salt generated at iteration %d", i)
|
||||
}
|
||||
salts[salt] = true
|
||||
}
|
||||
|
||||
// Verify each hash works
|
||||
match, err := Argon2id.Verify(password, hash)
|
||||
if err != nil || !match {
|
||||
t.Errorf("Hash %d failed verification: err=%v, match=%v", i, err, match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgon2ID_HashFormat(t *testing.T) {
|
||||
password := "testformat"
|
||||
hash, err := Argon2id.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash failed: %v", err)
|
||||
}
|
||||
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 6 {
|
||||
t.Fatalf("Expected 6 parts, got %d: %v", len(parts), hash)
|
||||
}
|
||||
|
||||
// Part 0 should be empty (before first $)
|
||||
if parts[0] != "" {
|
||||
t.Errorf("Part 0 should be empty, got: %v", parts[0])
|
||||
}
|
||||
|
||||
// Part 1 should be "argon2id"
|
||||
if parts[1] != "argon2id" {
|
||||
t.Errorf("Part 1 should be 'argon2id', got: %v", parts[1])
|
||||
}
|
||||
|
||||
// Part 2 should be version
|
||||
if !strings.HasPrefix(parts[2], "v=") {
|
||||
t.Errorf("Part 2 should start with 'v=', got: %v", parts[2])
|
||||
}
|
||||
|
||||
// Part 3 should be parameters
|
||||
if !strings.Contains(parts[3], "m=") || !strings.Contains(parts[3], "t=") || !strings.Contains(parts[3], "p=") {
|
||||
t.Errorf("Part 3 should contain m=, t=, and p=, got: %v", parts[3])
|
||||
}
|
||||
|
||||
// Part 4 should be base64 encoded salt
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
t.Errorf("Salt (part 4) is not valid base64: %v", err)
|
||||
}
|
||||
if len(salt) != int(Argon2id.saltLen) {
|
||||
t.Errorf("Salt length is %d, expected %d", len(salt), Argon2id.saltLen)
|
||||
}
|
||||
|
||||
// Part 5 should be base64 encoded hash
|
||||
decodedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
t.Errorf("Hash (part 5) is not valid base64: %v", err)
|
||||
}
|
||||
if len(decodedHash) != int(Argon2id.keyLen) {
|
||||
t.Errorf("Hash length is %d, expected %d", len(decodedHash), Argon2id.keyLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgon2ID_CaseModification(t *testing.T) {
|
||||
// Passwords should be case-sensitive
|
||||
password := "TestPassword"
|
||||
hash, err := Argon2id.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash failed: %v", err)
|
||||
}
|
||||
|
||||
// Correct case should match
|
||||
match, err := Argon2id.Verify(password, hash)
|
||||
if err != nil || !match {
|
||||
t.Errorf("Correct password failed: err=%v, match=%v", err, match)
|
||||
}
|
||||
|
||||
// Wrong case should not match
|
||||
match, err = Argon2id.Verify("testpassword", hash)
|
||||
if err != nil {
|
||||
t.Errorf("Verify returned error: %v", err)
|
||||
}
|
||||
if match {
|
||||
t.Error("Password verification should be case-sensitive")
|
||||
}
|
||||
|
||||
match, err = Argon2id.Verify("TESTPASSWORD", hash)
|
||||
if err != nil {
|
||||
t.Errorf("Verify returned error: %v", err)
|
||||
}
|
||||
if match {
|
||||
t.Error("Password verification should be case-sensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgon2ID_InvalidParameters(t *testing.T) {
|
||||
password := "testpassword"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hash string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "negative memory parameter",
|
||||
hash: "$argon2id$v=19$m=-1,t=1,p=4$dGVzdHNhbHQ$testhash",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative time parameter",
|
||||
hash: "$argon2id$v=19$m=65536,t=-1,p=4$dGVzdHNhbHQ$testhash",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative parallelism parameter",
|
||||
hash: "$argon2id$v=19$m=65536,t=1,p=-4$dGVzdHNhbHQ$testhash",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero memory parameter",
|
||||
hash: "$argon2id$v=19$m=0,t=1,p=4$dGVzdHNhbHQ$testhash",
|
||||
wantErr: false, // argon2 may handle this, we just test parsing
|
||||
},
|
||||
{
|
||||
name: "missing parameter value",
|
||||
hash: "$argon2id$v=19$m=,t=1,p=4$dGVzdHNhbHQ$testhash",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-numeric parameter",
|
||||
hash: "$argon2id$v=19$m=abc,t=1,p=4$dGVzdHNhbHQ$testhash",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing parameters separator",
|
||||
hash: "$argon2id$v=19$m=65536 t=1 p=4$dGVzdHNhbHQ$testhash",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Argon2id.Verify(password, tt.hash)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Argon2id.Verify() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgon2ID_ConcurrentHashing(t *testing.T) {
|
||||
password := "testpassword"
|
||||
concurrency := 10
|
||||
|
||||
type result struct {
|
||||
hash string
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan result, concurrency)
|
||||
|
||||
// Generate hashes concurrently
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
hash, err := Argon2id.Hash(password)
|
||||
results <- result{hash: hash, err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
// Collect results
|
||||
hashes := make(map[string]bool)
|
||||
for i := 0; i < concurrency; i++ {
|
||||
res := <-results
|
||||
if res.err != nil {
|
||||
t.Errorf("Concurrent hash %d failed: %v", i, res.err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if hashes[res.hash] {
|
||||
t.Errorf("Duplicate hash generated in concurrent test")
|
||||
}
|
||||
hashes[res.hash] = true
|
||||
|
||||
// Verify each hash works
|
||||
match, err := Argon2id.Verify(password, res.hash)
|
||||
if err != nil || !match {
|
||||
t.Errorf("Hash %d failed verification: err=%v, match=%v", i, err, match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgon2ID_VeryLongPassword(t *testing.T) {
|
||||
// Test with extremely long password (100KB)
|
||||
password := strings.Repeat("a", 100*1024)
|
||||
|
||||
hash, err := Argon2id.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash very long password: %v", err)
|
||||
}
|
||||
|
||||
match, err := Argon2id.Verify(password, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to verify very long password: %v", err)
|
||||
}
|
||||
|
||||
if !match {
|
||||
t.Error("Very long password failed verification")
|
||||
}
|
||||
|
||||
// Verify wrong password still fails
|
||||
wrongPassword := strings.Repeat("b", 100*1024)
|
||||
match, err = Argon2id.Verify(wrongPassword, hash)
|
||||
if err != nil {
|
||||
t.Errorf("Verify returned error: %v", err)
|
||||
}
|
||||
if match {
|
||||
t.Error("Wrong very long password should not match")
|
||||
}
|
||||
}
|
||||
193
internal/auth/password/password_test.go
Normal file
193
internal/auth/password/password_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package password
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple password",
|
||||
password: "password123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
password: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "long password",
|
||||
password: strings.Repeat("a", 1000),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
password: "p@ssw0rd!#$%^&*()",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unicode characters",
|
||||
password: "パスワード123",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash, err := HashPassword(tt.password)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("HashPassword() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr {
|
||||
// Verify hash format
|
||||
if !strings.HasPrefix(hash, "$argon2id$") {
|
||||
t.Errorf("HashPassword() returned invalid hash format: %v", hash)
|
||||
}
|
||||
// Verify hash has correct number of parts
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 6 {
|
||||
t.Errorf("HashPassword() returned hash with incorrect number of parts: %v", len(parts))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword(t *testing.T) {
|
||||
// Pre-generate a known hash for testing
|
||||
testPassword := "testpassword123"
|
||||
testHash, err := HashPassword(testPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate test hash: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
hash string
|
||||
wantMatch bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "correct password",
|
||||
password: testPassword,
|
||||
hash: testHash,
|
||||
wantMatch: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "incorrect password",
|
||||
password: "wrongpassword",
|
||||
hash: testHash,
|
||||
wantMatch: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty password against valid hash",
|
||||
password: "",
|
||||
hash: testHash,
|
||||
wantMatch: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty hash",
|
||||
password: testPassword,
|
||||
hash: "",
|
||||
wantMatch: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid hash format",
|
||||
password: testPassword,
|
||||
hash: "invalid",
|
||||
wantMatch: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed hash - wrong prefix",
|
||||
password: testPassword,
|
||||
hash: "$bcrypt$invalid$hash",
|
||||
wantMatch: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
match, err := VerifyPassword(tt.password, tt.hash)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("VerifyPassword() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if match != tt.wantMatch {
|
||||
t.Errorf("VerifyPassword() match = %v, wantMatch %v", match, tt.wantMatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPasswordUniqueness(t *testing.T) {
|
||||
password := "testpassword"
|
||||
|
||||
// Generate multiple hashes of the same password
|
||||
hash1, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
hash2, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
// Hashes should be different due to different salts
|
||||
if hash1 == hash2 {
|
||||
t.Error("HashPassword() should generate unique hashes for the same password")
|
||||
}
|
||||
|
||||
// But both should verify correctly
|
||||
match1, err := VerifyPassword(password, hash1)
|
||||
if err != nil || !match1 {
|
||||
t.Errorf("Failed to verify first hash: err=%v, match=%v", err, match1)
|
||||
}
|
||||
|
||||
match2, err := VerifyPassword(password, hash2)
|
||||
if err != nil || !match2 {
|
||||
t.Errorf("Failed to verify second hash: err=%v, match=%v", err, match2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordRoundTrip(t *testing.T) {
|
||||
tests := []string{
|
||||
"simple",
|
||||
"with spaces and special chars !@#$%",
|
||||
"パスワード",
|
||||
strings.Repeat("long", 100),
|
||||
"",
|
||||
}
|
||||
|
||||
for _, password := range tests {
|
||||
t.Run(password, func(t *testing.T) {
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() failed: %v", err)
|
||||
}
|
||||
|
||||
match, err := VerifyPassword(password, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() failed: %v", err)
|
||||
}
|
||||
|
||||
if !match {
|
||||
t.Error("Password round trip failed: hashed password does not verify")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ func AESEncrypt(key, text []byte) ([]byte, error) {
|
||||
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: remove deprecated
|
||||
//nolint:staticcheck
|
||||
stream := cipher.NewCFBEncrypter(block, iv)
|
||||
stream.XORKeyStream(ciphertext[aes.BlockSize:], text)
|
||||
|
||||
@@ -38,7 +39,8 @@ func AESDecrypt(key, ciphertext []byte) ([]byte, error) {
|
||||
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
ciphertext = ciphertext[aes.BlockSize:]
|
||||
|
||||
// TODO: remove deprecated
|
||||
//nolint:staticcheck
|
||||
stream := cipher.NewCFBDecrypter(block, iv)
|
||||
stream.XORKeyStream(ciphertext, ciphertext)
|
||||
|
||||
|
||||
430
internal/auth/totp/aes_test.go
Normal file
430
internal/auth/totp/aes_test.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package totp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAESEncrypt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key []byte
|
||||
text []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic encryption with 16-byte key",
|
||||
key: []byte("1234567890123456"), // 16 bytes (AES-128)
|
||||
text: []byte("hello world"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "basic encryption with 24-byte key",
|
||||
key: []byte("123456789012345678901234"), // 24 bytes (AES-192)
|
||||
text: []byte("hello world"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "basic encryption with 32-byte key",
|
||||
key: []byte("12345678901234567890123456789012"), // 32 bytes (AES-256)
|
||||
text: []byte("hello world"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte(""),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "long text",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte("This is a much longer text that spans multiple blocks and should be encrypted properly without any issues"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "binary data",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid key length - too short",
|
||||
key: []byte("short"),
|
||||
text: []byte("hello world"),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid key length - 17 bytes",
|
||||
key: []byte("12345678901234567"), // 17 bytes (invalid)
|
||||
text: []byte("hello world"),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "nil key",
|
||||
key: nil,
|
||||
text: []byte("hello world"),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty key",
|
||||
key: []byte(""),
|
||||
text: []byte("hello world"),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ciphertext, err := AESEncrypt(tt.key, tt.text)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AESEncrypt() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
// Verify ciphertext is not empty
|
||||
if len(ciphertext) == 0 {
|
||||
t.Error("AESEncrypt() returned empty ciphertext")
|
||||
}
|
||||
|
||||
// Verify ciphertext length is correct (IV + encrypted text)
|
||||
expectedLen := aes.BlockSize + len(tt.text)
|
||||
if len(ciphertext) != expectedLen {
|
||||
t.Errorf("AESEncrypt() ciphertext length = %d, want %d", len(ciphertext), expectedLen)
|
||||
}
|
||||
|
||||
// Verify ciphertext is different from plaintext (unless text is empty)
|
||||
if len(tt.text) > 0 && bytes.Equal(ciphertext[aes.BlockSize:], tt.text) {
|
||||
t.Error("AESEncrypt() ciphertext matches plaintext")
|
||||
}
|
||||
|
||||
// Verify IV is present and non-zero
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
allZeros := true
|
||||
for _, b := range iv {
|
||||
if b != 0 {
|
||||
allZeros = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZeros {
|
||||
t.Error("AESEncrypt() IV is all zeros")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESDecrypt(t *testing.T) {
|
||||
validKey := []byte("1234567890123456")
|
||||
validText := []byte("hello world")
|
||||
|
||||
// Encrypt some data to use for valid test cases
|
||||
validCiphertext, err := AESEncrypt(validKey, validText)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create valid ciphertext: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key []byte
|
||||
ciphertext []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid decryption",
|
||||
key: validKey,
|
||||
ciphertext: validCiphertext,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ciphertext too short - empty",
|
||||
key: validKey,
|
||||
ciphertext: []byte(""),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ciphertext too short - less than block size",
|
||||
key: validKey,
|
||||
ciphertext: []byte("short"),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ciphertext exactly block size (IV only, no data)",
|
||||
key: validKey,
|
||||
ciphertext: make([]byte, aes.BlockSize),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid key length",
|
||||
key: []byte("short"),
|
||||
ciphertext: validCiphertext,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong key",
|
||||
key: []byte("6543210987654321"),
|
||||
ciphertext: validCiphertext,
|
||||
wantErr: false, // Decryption succeeds but produces garbage
|
||||
},
|
||||
{
|
||||
name: "nil key",
|
||||
key: nil,
|
||||
ciphertext: validCiphertext,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "nil ciphertext",
|
||||
key: validKey,
|
||||
ciphertext: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plaintext, err := AESDecrypt(tt.key, tt.ciphertext)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AESDecrypt() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
// For valid decryption with correct key, verify we get original text
|
||||
if tt.name == "valid decryption" && !bytes.Equal(plaintext, validText) {
|
||||
t.Errorf("AESDecrypt() plaintext = %v, want %v", plaintext, validText)
|
||||
}
|
||||
|
||||
// For ciphertext with only IV, plaintext should be empty
|
||||
if tt.name == "ciphertext exactly block size (IV only, no data)" && len(plaintext) != 0 {
|
||||
t.Errorf("AESDecrypt() plaintext length = %d, want 0", len(plaintext))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESEncryptDecrypt_RoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key []byte
|
||||
text []byte
|
||||
}{
|
||||
{
|
||||
name: "basic round trip",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte("hello world"),
|
||||
},
|
||||
{
|
||||
name: "empty text round trip",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte(""),
|
||||
},
|
||||
{
|
||||
name: "long text round trip",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte("This is a very long text that contains multiple blocks of data and should be encrypted and decrypted correctly without any data loss or corruption"),
|
||||
},
|
||||
{
|
||||
name: "binary data round trip",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD, 0xFC},
|
||||
},
|
||||
{
|
||||
name: "unicode text round trip",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte("Hello 世界! 🔐 Encryption"),
|
||||
},
|
||||
{
|
||||
name: "AES-192 round trip",
|
||||
key: []byte("123456789012345678901234"),
|
||||
text: []byte("testing AES-192"),
|
||||
},
|
||||
{
|
||||
name: "AES-256 round trip",
|
||||
key: []byte("12345678901234567890123456789012"),
|
||||
text: []byte("testing AES-256"),
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte("!@#$%^&*()_+-=[]{}|;':\",./<>?"),
|
||||
},
|
||||
{
|
||||
name: "newlines and tabs",
|
||||
key: []byte("1234567890123456"),
|
||||
text: []byte("line1\nline2\tline3\r\nline4"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Encrypt
|
||||
ciphertext, err := AESEncrypt(tt.key, tt.text)
|
||||
if err != nil {
|
||||
t.Fatalf("AESEncrypt() failed: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
plaintext, err := AESDecrypt(tt.key, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("AESDecrypt() failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify plaintext matches original
|
||||
if !bytes.Equal(plaintext, tt.text) {
|
||||
t.Errorf("Round trip failed: got %v, want %v", plaintext, tt.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESEncrypt_Uniqueness(t *testing.T) {
|
||||
key := []byte("1234567890123456")
|
||||
text := []byte("hello world")
|
||||
iterations := 10
|
||||
|
||||
ciphertexts := make(map[string]bool)
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
ciphertext, err := AESEncrypt(key, text)
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration %d failed: %v", i, err)
|
||||
}
|
||||
|
||||
// Each encryption should produce different ciphertext (due to random IV)
|
||||
ciphertextStr := string(ciphertext)
|
||||
if ciphertexts[ciphertextStr] {
|
||||
t.Errorf("Duplicate ciphertext generated at iteration %d", i)
|
||||
}
|
||||
ciphertexts[ciphertextStr] = true
|
||||
|
||||
// But all should decrypt to the same plaintext
|
||||
plaintext, err := AESDecrypt(key, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration %d decryption failed: %v", i, err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, text) {
|
||||
t.Errorf("Iteration %d: decrypted text doesn't match original", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESEncrypt_IVUniqueness(t *testing.T) {
|
||||
key := []byte("1234567890123456")
|
||||
text := []byte("test data")
|
||||
iterations := 20
|
||||
|
||||
ivs := make(map[string]bool)
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
ciphertext, err := AESEncrypt(key, text)
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration %d failed: %v", i, err)
|
||||
}
|
||||
|
||||
// Extract IV (first block)
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
ivStr := string(iv)
|
||||
|
||||
// Each IV should be unique
|
||||
if ivs[ivStr] {
|
||||
t.Errorf("Duplicate IV generated at iteration %d", i)
|
||||
}
|
||||
ivs[ivStr] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESDecrypt_WrongKey(t *testing.T) {
|
||||
originalKey := []byte("1234567890123456")
|
||||
wrongKey := []byte("6543210987654321")
|
||||
text := []byte("secret message")
|
||||
|
||||
// Encrypt with original key
|
||||
ciphertext, err := AESEncrypt(originalKey, text)
|
||||
if err != nil {
|
||||
t.Fatalf("AESEncrypt() failed: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt with wrong key - should not error but produce wrong plaintext
|
||||
plaintext, err := AESDecrypt(wrongKey, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("AESDecrypt() with wrong key failed: %v", err)
|
||||
}
|
||||
|
||||
// Plaintext should be different from original
|
||||
if bytes.Equal(plaintext, text) {
|
||||
t.Error("AESDecrypt() with wrong key produced correct plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESDecrypt_CorruptedCiphertext(t *testing.T) {
|
||||
key := []byte("1234567890123456")
|
||||
text := []byte("hello world")
|
||||
|
||||
// Encrypt
|
||||
ciphertext, err := AESEncrypt(key, text)
|
||||
if err != nil {
|
||||
t.Fatalf("AESEncrypt() failed: %v", err)
|
||||
}
|
||||
|
||||
// Corrupt the ciphertext (flip a bit in the encrypted data, not the IV)
|
||||
if len(ciphertext) > aes.BlockSize {
|
||||
corruptedCiphertext := make([]byte, len(ciphertext))
|
||||
copy(corruptedCiphertext, ciphertext)
|
||||
corruptedCiphertext[aes.BlockSize] ^= 0xFF
|
||||
|
||||
// Decrypt corrupted ciphertext - should not error but produce wrong plaintext
|
||||
plaintext, err := AESDecrypt(key, corruptedCiphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("AESDecrypt() with corrupted ciphertext failed: %v", err)
|
||||
}
|
||||
|
||||
// Plaintext should be different from original
|
||||
if bytes.Equal(plaintext, text) {
|
||||
t.Error("AESDecrypt() with corrupted ciphertext produced correct plaintext")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESEncryptDecrypt_DifferentKeySizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keySize int
|
||||
}{
|
||||
{"AES-128", 16},
|
||||
{"AES-192", 24},
|
||||
{"AES-256", 32},
|
||||
}
|
||||
|
||||
text := []byte("test message for different key sizes")
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Generate key of specified size
|
||||
key := make([]byte, tt.keySize)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
ciphertext, err := AESEncrypt(key, text)
|
||||
if err != nil {
|
||||
t.Fatalf("AESEncrypt() failed: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
plaintext, err := AESDecrypt(key, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("AESDecrypt() failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
if !bytes.Equal(plaintext, text) {
|
||||
t.Errorf("Round trip failed for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,21 @@ import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"html/template"
|
||||
"image/png"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
const secretSize = 16
|
||||
|
||||
func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.URL, error, []byte) {
|
||||
func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.URL, []byte, error) {
|
||||
var err error
|
||||
if secret == nil {
|
||||
secret, err = generateSecret()
|
||||
if err != nil {
|
||||
return "", "", err, nil
|
||||
return "", "", nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,22 +29,22 @@ func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.U
|
||||
Secret: secret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err, nil
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
qrcode, err := otpKey.Image(320, 240)
|
||||
if err != nil {
|
||||
return "", "", err, nil
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
var imgBytes bytes.Buffer
|
||||
if err = png.Encode(&imgBytes, qrcode); err != nil {
|
||||
return "", "", err, nil
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
qrcodeImage := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes()))
|
||||
|
||||
return otpKey.Secret(), qrcodeImage, nil, secret
|
||||
return otpKey.Secret(), qrcodeImage, secret, nil
|
||||
}
|
||||
|
||||
func Validate(passcode, secret string) bool {
|
||||
|
||||
431
internal/auth/totp/totp_test.go
Normal file
431
internal/auth/totp/totp_test.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package totp
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func TestGenerateQRCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
siteUrl string
|
||||
secret []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic generation with nil secret",
|
||||
username: "testuser",
|
||||
siteUrl: "opengist.io",
|
||||
secret: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "basic generation with provided secret",
|
||||
username: "testuser",
|
||||
siteUrl: "opengist.io",
|
||||
secret: []byte("1234567890123456"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "username with special characters",
|
||||
username: "test.user",
|
||||
siteUrl: "opengist.io",
|
||||
secret: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "site URL with protocol and port",
|
||||
username: "testuser",
|
||||
siteUrl: "https://opengist.io:6157",
|
||||
secret: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty username",
|
||||
username: "",
|
||||
siteUrl: "opengist.io",
|
||||
secret: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty site URL",
|
||||
username: "testuser",
|
||||
siteUrl: "",
|
||||
secret: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
secretStr, qrcode, secretBytes, err := GenerateQRCode(tt.username, tt.siteUrl, tt.secret)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GenerateQRCode() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
// Verify secret string is not empty
|
||||
if secretStr == "" {
|
||||
t.Error("GenerateQRCode() returned empty secret string")
|
||||
}
|
||||
|
||||
// Verify QR code image is generated
|
||||
if qrcode == "" {
|
||||
t.Error("GenerateQRCode() returned empty QR code")
|
||||
}
|
||||
|
||||
// Verify QR code has correct data URI prefix
|
||||
if !strings.HasPrefix(string(qrcode), "data:image/png;base64,") {
|
||||
t.Errorf("QR code does not have correct data URI prefix: %s", qrcode[:50])
|
||||
}
|
||||
|
||||
// Verify QR code is valid base64 after prefix
|
||||
base64Data := strings.TrimPrefix(string(qrcode), "data:image/png;base64,")
|
||||
_, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
t.Errorf("QR code base64 data is invalid: %v", err)
|
||||
}
|
||||
|
||||
// Verify secret bytes are returned
|
||||
if secretBytes == nil {
|
||||
t.Error("GenerateQRCode() returned nil secret bytes")
|
||||
}
|
||||
|
||||
// Verify secret bytes have correct length
|
||||
if len(secretBytes) != secretSize {
|
||||
t.Errorf("Secret bytes length = %d, want %d", len(secretBytes), secretSize)
|
||||
}
|
||||
|
||||
// If a secret was provided, verify it matches what was returned
|
||||
if tt.secret != nil && string(secretBytes) != string(tt.secret) {
|
||||
t.Error("Returned secret bytes do not match provided secret")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateQRCode_SecretUniqueness(t *testing.T) {
|
||||
username := "testuser"
|
||||
siteUrl := "opengist.io"
|
||||
iterations := 10
|
||||
|
||||
secrets := make(map[string]bool)
|
||||
secretBytes := make(map[string]bool)
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
secretStr, _, secret, err := GenerateQRCode(username, siteUrl, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration %d failed: %v", i, err)
|
||||
}
|
||||
|
||||
// Check secret string uniqueness
|
||||
if secrets[secretStr] {
|
||||
t.Errorf("Duplicate secret string generated at iteration %d", i)
|
||||
}
|
||||
secrets[secretStr] = true
|
||||
|
||||
// Check secret bytes uniqueness
|
||||
secretKey := string(secret)
|
||||
if secretBytes[secretKey] {
|
||||
t.Errorf("Duplicate secret bytes generated at iteration %d", i)
|
||||
}
|
||||
secretBytes[secretKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateQRCode_WithProvidedSecret(t *testing.T) {
|
||||
username := "testuser"
|
||||
siteUrl := "opengist.io"
|
||||
providedSecret := []byte("mysecret12345678")
|
||||
|
||||
// Generate QR code multiple times with the same secret
|
||||
secretStr1, _, secret1, err := GenerateQRCode(username, siteUrl, providedSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("First generation failed: %v", err)
|
||||
}
|
||||
|
||||
secretStr2, _, secret2, err := GenerateQRCode(username, siteUrl, providedSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Second generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Secret strings should be the same when using the same input secret
|
||||
if secretStr1 != secretStr2 {
|
||||
t.Error("Secret strings differ when using the same provided secret")
|
||||
}
|
||||
|
||||
// Secret bytes should match the provided secret
|
||||
if string(secret1) != string(providedSecret) {
|
||||
t.Error("Returned secret bytes do not match provided secret (first call)")
|
||||
}
|
||||
if string(secret2) != string(providedSecret) {
|
||||
t.Error("Returned secret bytes do not match provided secret (second call)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateQRCode_ConcurrentGeneration(t *testing.T) {
|
||||
username := "testuser"
|
||||
siteUrl := "opengist.io"
|
||||
concurrency := 10
|
||||
|
||||
type result struct {
|
||||
secretStr string
|
||||
secretBytes []byte
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan result, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
secretStr, _, secretBytes, err := GenerateQRCode(username, siteUrl, nil)
|
||||
results <- result{secretStr: secretStr, secretBytes: secretBytes, err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
secrets := make(map[string]bool)
|
||||
for res := range results {
|
||||
if res.err != nil {
|
||||
t.Errorf("Concurrent generation failed: %v", res.err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if secrets[res.secretStr] {
|
||||
t.Error("Duplicate secret generated in concurrent test")
|
||||
}
|
||||
secrets[res.secretStr] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
// Generate a valid secret for testing
|
||||
_, _, secret, err := GenerateQRCode("testuser", "opengist.io", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate secret: %v", err)
|
||||
}
|
||||
|
||||
// Convert secret bytes to base32 string for TOTP
|
||||
secretStr, _, _, err := GenerateQRCode("testuser", "opengist.io", secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate secret string: %v", err)
|
||||
}
|
||||
|
||||
// Generate a valid passcode for the current time
|
||||
validPasscode, err := totp.GenerateCode(secretStr, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate valid passcode: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
passcode string
|
||||
secret string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "valid passcode",
|
||||
passcode: validPasscode,
|
||||
secret: secretStr,
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "invalid passcode - wrong digits",
|
||||
passcode: "000000",
|
||||
secret: secretStr,
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid passcode - wrong length",
|
||||
passcode: "123",
|
||||
secret: secretStr,
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "empty passcode",
|
||||
passcode: "",
|
||||
secret: secretStr,
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "empty secret",
|
||||
passcode: validPasscode,
|
||||
secret: "",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid secret format",
|
||||
passcode: validPasscode,
|
||||
secret: "not-a-valid-base32-secret!@#",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "passcode with letters",
|
||||
passcode: "12345A",
|
||||
secret: secretStr,
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "passcode with spaces",
|
||||
passcode: "123 456",
|
||||
secret: secretStr,
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
valid := Validate(tt.passcode, tt.secret)
|
||||
if valid != tt.wantValid {
|
||||
t.Errorf("Validate() = %v, want %v", valid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_TimeDrift(t *testing.T) {
|
||||
// Generate a valid secret
|
||||
secretStr, _, _, err := GenerateQRCode("testuser", "opengist.io", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate secret: %v", err)
|
||||
}
|
||||
|
||||
// Test that passcodes from previous and next time windows are accepted
|
||||
// (TOTP typically accepts codes from ±1 time window for clock drift)
|
||||
pastTime := time.Now().Add(-30 * time.Second)
|
||||
futureTime := time.Now().Add(30 * time.Second)
|
||||
|
||||
pastPasscode, err := totp.GenerateCode(secretStr, pastTime)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate past passcode: %v", err)
|
||||
}
|
||||
|
||||
futurePasscode, err := totp.GenerateCode(secretStr, futureTime)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate future passcode: %v", err)
|
||||
}
|
||||
|
||||
// These should be valid due to time drift tolerance
|
||||
if !Validate(pastPasscode, secretStr) {
|
||||
t.Error("Validate() rejected passcode from previous time window")
|
||||
}
|
||||
|
||||
if !Validate(futurePasscode, secretStr) {
|
||||
t.Error("Validate() rejected passcode from next time window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ExpiredPasscode(t *testing.T) {
|
||||
// Generate a valid secret
|
||||
secretStr, _, _, err := GenerateQRCode("testuser", "opengist.io", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate secret: %v", err)
|
||||
}
|
||||
|
||||
// Generate a passcode from 2 minutes ago (should be expired)
|
||||
oldTime := time.Now().Add(-2 * time.Minute)
|
||||
oldPasscode, err := totp.GenerateCode(secretStr, oldTime)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate old passcode: %v", err)
|
||||
}
|
||||
|
||||
// This should be invalid
|
||||
if Validate(oldPasscode, secretStr) {
|
||||
t.Error("Validate() accepted expired passcode from 2 minutes ago")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RoundTrip(t *testing.T) {
|
||||
// Test full round trip: generate secret, generate code, validate code
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
siteUrl string
|
||||
}{
|
||||
{
|
||||
name: "basic round trip",
|
||||
username: "testuser",
|
||||
siteUrl: "opengist.io",
|
||||
},
|
||||
{
|
||||
name: "round trip with dot in username",
|
||||
username: "test.user",
|
||||
siteUrl: "opengist.io",
|
||||
},
|
||||
{
|
||||
name: "round trip with hyphen in username",
|
||||
username: "test-user",
|
||||
siteUrl: "opengist.io",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Generate QR code and secret
|
||||
secretStr, _, _, err := GenerateQRCode(tt.username, tt.siteUrl, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateQRCode() failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate a valid passcode
|
||||
passcode, err := totp.GenerateCode(secretStr, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode() failed: %v", err)
|
||||
}
|
||||
|
||||
// Validate the passcode
|
||||
if !Validate(passcode, secretStr) {
|
||||
t.Error("Validate() rejected valid passcode")
|
||||
}
|
||||
|
||||
// Validate wrong passcode fails
|
||||
wrongPasscode := "000000"
|
||||
if passcode == wrongPasscode {
|
||||
wrongPasscode = "111111"
|
||||
}
|
||||
if Validate(wrongPasscode, secretStr) {
|
||||
t.Error("Validate() accepted invalid passcode")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecret(t *testing.T) {
|
||||
// Test the internal generateSecret function behavior through GenerateQRCode
|
||||
for i := 0; i < 10; i++ {
|
||||
_, _, secret, err := GenerateQRCode("testuser", "opengist.io", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration %d: generateSecret() failed: %v", i, err)
|
||||
}
|
||||
|
||||
if len(secret) != secretSize {
|
||||
t.Errorf("Iteration %d: secret length = %d, want %d", i, len(secret), secretSize)
|
||||
}
|
||||
|
||||
// Verify secret is not all zeros (extremely unlikely with crypto/rand)
|
||||
allZeros := true
|
||||
for _, b := range secret {
|
||||
if b != 0 {
|
||||
allZeros = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZeros {
|
||||
t.Errorf("Iteration %d: secret is all zeros", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"github.com/thomiceli/opengist/internal/index"
|
||||
"github.com/thomiceli/opengist/internal/ssh"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/metrics"
|
||||
"github.com/thomiceli/opengist/internal/web/server"
|
||||
"github.com/urfave/cli/v2"
|
||||
"os"
|
||||
@@ -36,12 +37,18 @@ 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", path.Join(config.GetHomeDir(), "sessions"), false)
|
||||
go httpServer.Start()
|
||||
go ssh.Start()
|
||||
|
||||
var metricsServer *metrics.Server
|
||||
if config.C.MetricsEnabled {
|
||||
metricsServer = metrics.NewServer()
|
||||
go metricsServer.Start()
|
||||
}
|
||||
|
||||
<-stopCtx.Done()
|
||||
shutdown(server)
|
||||
shutdown(httpServer, metricsServer)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -131,7 +138,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 +149,11 @@ func shutdown(server *server.Server) {
|
||||
index.Close()
|
||||
}
|
||||
|
||||
server.Stop()
|
||||
httpServer.Stop()
|
||||
|
||||
if metricsServer != nil {
|
||||
metricsServer.Stop()
|
||||
}
|
||||
|
||||
log.Info().Msg("Shutdown complete")
|
||||
}
|
||||
|
||||
@@ -79,7 +79,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"`
|
||||
@@ -128,6 +130,8 @@ func configWithDefaults() (*config, error) {
|
||||
c.GiteaName = "Gitea"
|
||||
|
||||
c.MetricsEnabled = false
|
||||
c.MetricsHost = "0.0.0.0"
|
||||
c.MetricsPort = "6158"
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
125
internal/db/access_token.go
Normal file
125
internal/db/access_token.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
NoPermission = 0
|
||||
ReadPermission = 1
|
||||
ReadWritePermission = 2
|
||||
)
|
||||
|
||||
type AccessToken struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string
|
||||
TokenHash string `gorm:"uniqueIndex,size:64"` // SHA-256 hash of the token
|
||||
CreatedAt int64
|
||||
ExpiresAt int64 // 0 means no expiration
|
||||
LastUsedAt int64
|
||||
UserID uint
|
||||
User User `validate:"-"`
|
||||
|
||||
ScopeGist uint // 0 = none, 1 = read, 2 = read+write
|
||||
}
|
||||
|
||||
// GenerateToken creates a new random token and returns the plain text token.
|
||||
// The token hash is stored in the AccessToken struct.
|
||||
// The plain text token should be shown to the user once and never stored.
|
||||
func (t *AccessToken) GenerateToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
plainToken := "og_" + hex.EncodeToString(bytes)
|
||||
|
||||
hash := sha256.Sum256([]byte(plainToken))
|
||||
t.TokenHash = hex.EncodeToString(hash[:])
|
||||
|
||||
return plainToken, nil
|
||||
}
|
||||
|
||||
func GetAccessTokenByID(tokenID uint) (*AccessToken, error) {
|
||||
token := new(AccessToken)
|
||||
err := db.
|
||||
Where("id = ?", tokenID).
|
||||
First(&token).Error
|
||||
return token, err
|
||||
}
|
||||
|
||||
func GetAccessTokenByToken(plainToken string) (*AccessToken, error) {
|
||||
hash := sha256.Sum256([]byte(plainToken))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
|
||||
token := new(AccessToken)
|
||||
err := db.
|
||||
Preload("User").
|
||||
Where("token_hash = ?", tokenHash).
|
||||
First(&token).Error
|
||||
return token, err
|
||||
}
|
||||
|
||||
func GetAccessTokensByUserID(userID uint) ([]*AccessToken, error) {
|
||||
var tokens []*AccessToken
|
||||
err := db.
|
||||
Where("user_id = ?", userID).
|
||||
Order("created_at desc").
|
||||
Find(&tokens).Error
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
func (t *AccessToken) Create() error {
|
||||
t.CreatedAt = time.Now().Unix()
|
||||
return db.Create(t).Error
|
||||
}
|
||||
|
||||
func (t *AccessToken) Delete() error {
|
||||
return db.Delete(t).Error
|
||||
}
|
||||
|
||||
func (t *AccessToken) UpdateLastUsed() error {
|
||||
return db.Model(t).Update("last_used_at", time.Now().Unix()).Error
|
||||
}
|
||||
|
||||
func (t *AccessToken) IsExpired() bool {
|
||||
if t.ExpiresAt == 0 {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix() > t.ExpiresAt
|
||||
}
|
||||
|
||||
func (t *AccessToken) HasGistReadPermission() bool {
|
||||
return t.ScopeGist >= ReadPermission
|
||||
}
|
||||
|
||||
func (t *AccessToken) HasGistWritePermission() bool {
|
||||
return t.ScopeGist >= ReadWritePermission
|
||||
}
|
||||
|
||||
// -- DTO -- //
|
||||
|
||||
type AccessTokenDTO struct {
|
||||
Name string `form:"name" validate:"required,max=255"`
|
||||
ScopeGist uint `form:"scope_gist" validate:"min=0,max=2"`
|
||||
ExpiresAt string `form:"expires_at"` // empty means no expiration, otherwise date format (YYYY-MM-DD)
|
||||
}
|
||||
|
||||
func (dto *AccessTokenDTO) ToAccessToken() *AccessToken {
|
||||
var expiresAt int64
|
||||
if dto.ExpiresAt != "" {
|
||||
// date input format: 2006-01-02, expires at end of day
|
||||
if t, err := time.ParseInLocation("2006-01-02", dto.ExpiresAt, time.Local); err == nil {
|
||||
expiresAt = t.Add(24*time.Hour - time.Second).Unix()
|
||||
}
|
||||
}
|
||||
|
||||
return &AccessToken{
|
||||
Name: dto.Name,
|
||||
ScopeGist: dto.ScopeGist,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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{}); 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{})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ type Gist struct {
|
||||
URL string
|
||||
Preview string
|
||||
PreviewFilename string
|
||||
PreviewMimeType string
|
||||
Description string
|
||||
Private Visibility // 0: public, 1: unlisted, 2: private
|
||||
UserID uint
|
||||
@@ -395,7 +396,7 @@ func (gist *Gist) GetForks(currentUserId uint, offset int) ([]*Gist, error) {
|
||||
}
|
||||
|
||||
func (gist *Gist) CanWrite(user *User) bool {
|
||||
return !(user == nil) && (gist.UserID == user.ID)
|
||||
return user != nil && gist.UserID == user.ID
|
||||
}
|
||||
|
||||
func (gist *Gist) InitRepository() error {
|
||||
@@ -431,7 +432,7 @@ 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
|
||||
@@ -465,7 +466,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
|
||||
}
|
||||
|
||||
@@ -551,6 +552,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 +564,7 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
|
||||
}
|
||||
gist.Preview = ""
|
||||
gist.PreviewFilename = file.Filename
|
||||
gist.PreviewMimeType = file.MimeType.ContentType
|
||||
|
||||
if !file.MimeType.CanBeEdited() {
|
||||
continue
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -25,6 +25,7 @@ type User struct {
|
||||
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) BeforeDelete(tx *gorm.DB) error {
|
||||
@@ -72,6 +73,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
|
||||
@@ -268,6 +274,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,41 +2,45 @@ package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
type MimeType struct {
|
||||
ContentType string
|
||||
ContentType string
|
||||
extension string
|
||||
golangContentType string // json, m3u, etc. still renderable as text
|
||||
}
|
||||
|
||||
func (mt MimeType) IsText() bool {
|
||||
return strings.Contains(mt.ContentType, "text/")
|
||||
return strings.HasPrefix(mt.ContentType, "text/") || strings.HasPrefix(mt.golangContentType, "text/")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsCSV() bool {
|
||||
return strings.Contains(mt.ContentType, "text/csv")
|
||||
return strings.HasPrefix(mt.ContentType, "text/csv") &&
|
||||
(strings.HasSuffix(mt.extension, ".csv"))
|
||||
}
|
||||
|
||||
func (mt MimeType) IsImage() bool {
|
||||
return strings.Contains(mt.ContentType, "image/")
|
||||
return strings.HasPrefix(mt.ContentType, "image/")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsSVG() bool {
|
||||
return strings.Contains(mt.ContentType, "image/svg+xml")
|
||||
return strings.HasPrefix(mt.ContentType, "image/svg+xml")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsPDF() bool {
|
||||
return strings.Contains(mt.ContentType, "application/pdf")
|
||||
return strings.HasPrefix(mt.ContentType, "application/pdf")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsAudio() bool {
|
||||
return strings.Contains(mt.ContentType, "audio/")
|
||||
return strings.HasPrefix(mt.ContentType, "audio/")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsVideo() bool {
|
||||
return strings.Contains(mt.ContentType, "video/")
|
||||
return strings.HasPrefix(mt.ContentType, "video/")
|
||||
}
|
||||
|
||||
func (mt MimeType) CanBeHighlighted() bool {
|
||||
@@ -84,6 +88,6 @@ func (mt MimeType) RenderType() string {
|
||||
return "Binary"
|
||||
}
|
||||
|
||||
func DetectMimeType(data []byte) MimeType {
|
||||
return MimeType{mimetype.Detect(data).String()}
|
||||
func DetectMimeType(data []byte, extension string) MimeType {
|
||||
return MimeType{mimetype.Detect(data).String(), extension, http.DetectContentType(data)}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,10 @@ func (store *LocaleStore) loadLocaleFromYAML(localeCode, path string) error {
|
||||
}
|
||||
|
||||
name := display.Self.Name(tag)
|
||||
if tag == language.AmericanEnglish {
|
||||
switch tag {
|
||||
case language.AmericanEnglish:
|
||||
name = "English"
|
||||
} else if tag == language.EuropeanSpanish {
|
||||
case language.EuropeanSpanish:
|
||||
name = "Español"
|
||||
}
|
||||
|
||||
|
||||
@@ -303,3 +303,35 @@ auth.totp.scan-qr-code: Scanne den unten stehenden QR-Code mit deiner Authentifi
|
||||
auth.totp.enter-code: Gib den Code aus deiner Authentifizierungs-App ein
|
||||
auth.totp.save-recovery-codes: Speichere deine Wiederherstellungscodes an einem sicheren Ort. Du kannst diese Codes verwenden, um wieder Zugang zu deinem Account zu erlangen, wenn du den Zugriff auf deine Authentifizierungs-App verloren hast.
|
||||
error.not-in-mfa-session: Nutzer ist nicht in einer Zwei-Faktor-Sitzung
|
||||
gist.revision.binary-file-changes: Änderungen an Binärdateien werden nicht angezeigt
|
||||
error.no-file-uploaded: Keine Datei hochgeladen
|
||||
flash.admin.sync-gist-languages: Gist-Sprachen werden synchronisiert …
|
||||
validation.invalid-gist-topics: Ungültige Gist-Themen. Sie müssen mit einem Buchstaben oder einer Zahl beginnen, dürfen maximal 50 Zeichen lang sein und dürfen Bindestriche enthalten
|
||||
gist.new.topics: Themen (durch Leerzeichen getrennt)
|
||||
gist.preview-non-available: Vorschau nicht verfügbar
|
||||
gist.new.drop-files: Dateien hier ablegen oder zum Hochladen klicken
|
||||
gist.new.any-file-type: Laden Sie einen beliebigen Dateityp hoch
|
||||
gist.list.topic-results-topic: Alle Gists mit dem Thema %s
|
||||
gist.file-raw: Diese Datei kann nicht gerendert werden.
|
||||
gist.file-binary-edit: Diese Datei ist binär.
|
||||
gist.search.placeholder.title: Titel
|
||||
gist.search.placeholder.visibility: Sichtbarkeit
|
||||
gist.search.placeholder.public: Öffentlich
|
||||
gist.search.placeholder.unlisted: Nicht gelistet
|
||||
gist.search.placeholder.private: Privat
|
||||
gist.search.placeholder.language: Sprache
|
||||
gist.search.placeholder.all: Alle
|
||||
gist.search.placeholder.topics: Themen
|
||||
gist.search.placeholder.search: Suche
|
||||
gist.search.help.topic: gists zum gegebenen Thema
|
||||
settings.header.account: Konto
|
||||
settings.header.mfa: MFA
|
||||
settings.header.ssh: SSH
|
||||
settings.header.style: Stil
|
||||
settings.style.removed-lines-color: Farbe entfernter Linien
|
||||
settings.style.added-lines-color: Farbe hinzugefügter Linien
|
||||
settings.style.git-lines-color: Git Linien Farbe
|
||||
settings.style.save-style: Stil Speichern
|
||||
auth.totp.enter-recovery-key: oder einen Wiederherstellungsschlüssel, wenn Sie Ihr Gerät verloren haben
|
||||
error.cannot-open-file: Die hochgeladene Datei kann nicht geöffnet werden
|
||||
admin.actions.sync-gist-languages: Synchronisieren Sie alle Gists-Sprachen
|
||||
|
||||
@@ -157,6 +157,7 @@ settings.password-label-title: Password
|
||||
settings.header.account: Account
|
||||
settings.header.mfa: MFA
|
||||
settings.header.ssh: SSH
|
||||
settings.header.tokens: Access tokens
|
||||
settings.header.style: Style
|
||||
settings.style.gist-code: Gist code
|
||||
settings.style.no-soft-wrap: No Soft Wrap
|
||||
@@ -165,6 +166,30 @@ settings.style.removed-lines-color: Removed lines color
|
||||
settings.style.added-lines-color: Added lines color
|
||||
settings.style.git-lines-color: Git lines color
|
||||
settings.style.save-style: Save style
|
||||
settings.style.theme: Theme
|
||||
settings.style.theme-light: Light
|
||||
settings.style.theme-dark: Dark
|
||||
settings.style.theme-auto: Auto
|
||||
settings.create-token: Create access token
|
||||
settings.create-token-help: Access tokens can be used to access the API
|
||||
settings.token-name: Name
|
||||
settings.token-permissions: Permissions
|
||||
settings.token-gist-permission: Gists
|
||||
settings.token-permission-none: No access
|
||||
settings.token-permission-read: Read
|
||||
settings.token-permission-read-write: Read & Write
|
||||
settings.delete-token: Delete
|
||||
settings.delete-token-confirm: Confirm deletion of access token
|
||||
settings.token-created-at: Created
|
||||
settings.token-never-used: Never used
|
||||
settings.token-last-used: Last used
|
||||
settings.token-expiration: Expiration
|
||||
settings.token-expiration-help: Leave empty for no expiration
|
||||
settings.token-expires-at: Expires
|
||||
settings.token-no-expiration: No expiration
|
||||
settings.token-expired: expired
|
||||
settings.token-created: Token created, make sure to copy it now, you won't be able to see it again!
|
||||
settings.token-deleted: Access token deleted
|
||||
|
||||
auth.signup-disabled: Administrator has disabled signing up
|
||||
auth.login: Login
|
||||
@@ -340,4 +365,4 @@ validation.not-enough: Not enough %s
|
||||
validation.invalid: Invalid %s
|
||||
validation.invalid-gist-topics: Invalid gist topics, they must start with a letter or number, consist of 50 characters or less, and can include hyphens
|
||||
|
||||
html.title.admin-panel: Admin panel
|
||||
html.title.admin-panel: Admin panel
|
||||
|
||||
@@ -267,3 +267,77 @@ validation.invalid: '%s non valido'
|
||||
|
||||
html.title.admin-panel: 'Pannello amministratore'
|
||||
settings.ssh-key-exists: Questa chiave SSH esiste già
|
||||
gist.new.drop-files: Rilascia i file qui oppure fai click per caricarli
|
||||
gist.delete.confirm: Sei sicuro di voler eliminare questo gist?
|
||||
gist.list.topic-results-topic: Tutti i gist corrispondenti all'argomento %s
|
||||
gist.search.placeholder.language: Lingua
|
||||
error.no-file-uploaded: Nessun file caricato
|
||||
gist.revision.binary-file-changes: I cambiamenti al file binario non sono visualizzati
|
||||
error.cannot-open-file: Impossibile aprire il file caricato
|
||||
admin.invitations.delete_confirm: Vuoi davvero cancellare questo invito?
|
||||
gist.new.topics: Argomenti (da separare con uno spazio)
|
||||
gist.search.placeholder.visibility: Visibilità
|
||||
settings.style.theme: Tema
|
||||
settings.style.theme-light: Chiaro
|
||||
settings.style.theme-dark: Scuro
|
||||
settings.style.theme-auto: Automatico
|
||||
auth.mfa: Autenticazione a due fattori
|
||||
auth.mfa.passkey: Passkey
|
||||
auth.mfa.passkeys: Passkeys
|
||||
auth.mfa.passkeys-help: Aggiungi una passkey per accedere al tuo account e per usare l'autenticazione a due fattori.
|
||||
auth.mfa.passkey-name: Nome
|
||||
auth.mfa.delete-passkey: Elimina
|
||||
auth.mfa.passkey-added-at: Aggiunta
|
||||
auth.mfa.passkey-never-used: Mai usata
|
||||
auth.mfa.passkey-last-used: Ultima usata
|
||||
auth.mfa.delete-passkey-confirm: Conferma l'eliminazione della passkey
|
||||
auth.totp: Time based one-time password (TOTP)
|
||||
auth.totp.help: Il TOTP è un metodo di autenticazione a due fattori che usa una chiave segreta per generare una one-time password (OTP).
|
||||
error.not-in-mfa-session: Non stai usando l'autenticazione a due fattori
|
||||
admin.actions.sync-gist-languages: Sincronizza tutte le lingue dei gist
|
||||
flash.admin.sync-gist-languages: Sincronizzazione delle lingue gist...
|
||||
flash.auth.passkey-registred: Passkey %s registrata
|
||||
flash.auth.passkey-deleted: Passkey eliminata
|
||||
validation.invalid-gist-topics: 'Argomenti del gist non validi: devono iniziare con una lettera o un numero ed essere composti da al massimo 50 caratteri. Possono includere trattini'
|
||||
gist.file-raw: Questo file non può essere visualizzato.
|
||||
gist.file-binary-edit: Questo file è in formato binario.
|
||||
gist.preview-non-available: Anteprima non disponibile
|
||||
gist.new.any-file-type: Carica qualsiasi tipo di file
|
||||
gist.list.topic-results: Tutti i gist corrispondenti all'argomento
|
||||
gist.search.help.topic: Gist con l'argomento dato
|
||||
gist.search.placeholder.title: Titolo
|
||||
gist.search.placeholder.public: Pubblico
|
||||
gist.search.placeholder.unlisted: Non in elenco
|
||||
gist.search.placeholder.private: Privato
|
||||
gist.search.placeholder.all: Tutti
|
||||
gist.search.placeholder.topics: Argomenti
|
||||
gist.search.placeholder.search: Ricerca
|
||||
auth.mfa.use-passkey: Usa la passkey
|
||||
auth.mfa.bind-passkey: Associa passkey
|
||||
auth.mfa.login-with-passkey: Entra con passkey
|
||||
auth.mfa.waiting-for-passkey-input: In attesa di input dal browser...
|
||||
auth.mfa.use-passkey-to-finish: Usa una passkey per terminare l'autenticazione
|
||||
settings.header.account: Account
|
||||
settings.header.mfa: Autenticazione a due fattori
|
||||
settings.header.ssh: SSH
|
||||
settings.header.style: Stile
|
||||
settings.style.gist-code: Codice gist
|
||||
settings.style.removed-lines-color: Colore delle linee rimosse
|
||||
settings.style.added-lines-color: Colore delle linee aggiunte
|
||||
settings.style.git-lines-color: Colore delle linee di git
|
||||
settings.style.save-style: Salva stile
|
||||
auth.totp.scan-qr-code: Scannerizza il QR qua sotto con la tua app di autenticazione per abilitare l'autenticazione a due fattori, oppure inserisci la seguente stringa, conferma poi con il codice generato.
|
||||
auth.totp.use: Usa TOTP
|
||||
auth.totp.regenerate-recovery-codes: Rigenera i codici di recupero
|
||||
auth.totp.already-enabled: Il TOTP è gia attivo
|
||||
auth.totp.invalid-secret: Chiave TOTP non valida
|
||||
auth.totp.invalid-code: Codice TOTP non valido
|
||||
auth.totp.code-used: Il codice di recupero %s è già stato usato e ora non è più valido. Potresti voler disabilitare l'autenticazione a due fattori per ora o generare nuovi codici di sicurezza.
|
||||
auth.totp.disabled: Il TOTP è stato disabilitato con successo
|
||||
auth.totp.disable: Disabilita TOTP
|
||||
auth.totp.enter-code: Inserisci il codice dall'app Authenticator
|
||||
auth.totp.enter-recovery-key: oppure una chiave di recupero se hai perso il tuo dispositivo
|
||||
auth.totp.code: Codice
|
||||
auth.totp.submit: Invia
|
||||
auth.totp.proceed: Procedi
|
||||
auth.totp.save-recovery-codes: Salva i tuoi codici di recupero in un posto sicuro. Puoi usare questi codici per recuperare l'accesso al tuo account se non hai accesso alla tua app di autenticazione.
|
||||
|
||||
@@ -51,7 +51,7 @@ gist.edit.save: Сохранить
|
||||
|
||||
gist.list.joined: Зарегистрирован
|
||||
gist.list.all: Все фрагменты
|
||||
gist.list.search-results: Результаты поиска
|
||||
gist.list.search-results: Результаты поиска
|
||||
gist.list.sort: Сортировка
|
||||
gist.list.sort-by-created: по дате создания
|
||||
gist.list.sort-by-updated: по дате обновления
|
||||
@@ -159,19 +159,19 @@ admin.created_at: Создан
|
||||
admin.config-link: Эти настройки могут быть %s файлом конфигурации YAML и/или переменными окружения.
|
||||
admin.config-link-overriden: перекрыты
|
||||
admin.disable-signup: Запретить регистрацию
|
||||
admin.disable-signup_help: Запретить создание новых доступов
|
||||
admin.disable-signup_help: Запретить создание новых доступов.
|
||||
admin.require-login: Требовать авторизацию
|
||||
admin.require-login_help: Запретить просмотр фрагментов без авторизации.
|
||||
admin.disable-login: Запретить авторизацию по паролю
|
||||
admin.disable-login_help: Запретить авторизацию с вводом пароля, форсировать внешнюю авторизацию через Gitea/GitHub.
|
||||
admin.disable-gravatar: Запретить Gravatar
|
||||
admin.disable-gravatar_help: Запретить использование Gravatar как провайдера изображений профиля.
|
||||
admin.allow-gists-without-login:
|
||||
admin.allow-gists-without-login_help:
|
||||
admin.allow-gists-without-login: Разрешить доступ к отдельным фрагментам без авторизации
|
||||
admin.allow-gists-without-login_help: Разрешает просматривать и скачивать отдельные фрагменты без входа, но требует авторизацию для поиска фрагментов.
|
||||
admin.users.delete_confirm: Вы уверены что хотите удалить этого пользователя?
|
||||
|
||||
admin.gists.title: Название
|
||||
admin.gists.private: Приватный
|
||||
admin.gists.private: Приватный?
|
||||
admin.gists.nb-files: Файлов
|
||||
admin.gists.nb-likes: Понравилось
|
||||
admin.gists.delete_confirm: Вы уверены что хотите удалить этот фрагмент?
|
||||
@@ -183,77 +183,175 @@ gist.list.all-liked-by: 'Все фрагменты, понравившиеся %
|
||||
gist.list.all-forked-by: 'Все фрагменты, ответвлённые %s'
|
||||
gist.list.all-from: 'Все фрагменты от %s'
|
||||
gist.search.found: 'фрагментов найдено'
|
||||
gist.search.no-results: 'Не найден ни один фрагмент'
|
||||
gist.search.no-results: 'Фрагменты не найдены'
|
||||
gist.search.help.user: 'фрагментов создано пользователем'
|
||||
gist.search.help.title: ''
|
||||
gist.search.help.filename: ''
|
||||
gist.search.help.extension: ''
|
||||
gist.search.help.language: ''
|
||||
gist.forks.for: ''
|
||||
gist.likes.for: ''
|
||||
gist.revision-of: ''
|
||||
settings.link-gitlab-account: ''
|
||||
settings.unlink-gitlab-account: ''
|
||||
settings.change-username: ''
|
||||
settings.create-password: ''
|
||||
settings.create-password-help: ''
|
||||
settings.change-password: ''
|
||||
settings.change-password-help: ''
|
||||
settings.password-label-title: ''
|
||||
error.page-not-found: ''
|
||||
error.bad-request: ''
|
||||
error.signup-disabled: ''
|
||||
error.signup-disabled-form: ''
|
||||
error.login-disabled-form: ''
|
||||
error.complete-oauth-login: ''
|
||||
error.oauth-unsupported: ''
|
||||
error.cannot-bind-data: ''
|
||||
error.invalid-number: ''
|
||||
error.invalid-character-unescaped: ''
|
||||
admin.invitations: ''
|
||||
admin.invitations.create: ''
|
||||
admin.actions.sync-previews: ''
|
||||
admin.actions.reset-hooks: ''
|
||||
admin.actions.index-gists: ''
|
||||
validation.should-not-be-empty: ''
|
||||
admin.invitations.help: ''
|
||||
admin.invitations.max_uses: ''
|
||||
admin.invitations.expires_at: ''
|
||||
admin.invitations.code: ''
|
||||
admin.invitations.copy_link: ''
|
||||
admin.invitations.uses: ''
|
||||
admin.invitations.expired: ''
|
||||
flash.admin.user-deleted: ''
|
||||
flash.admin.gist-deleted: ''
|
||||
flash.admin.invitation-created: ''
|
||||
flash.admin.invitation-deleted: ''
|
||||
flash.admin.sync-fs: ''
|
||||
flash.admin.sync-db: ''
|
||||
flash.admin.git-gc: ''
|
||||
flash.admin.sync-previews: ''
|
||||
flash.admin.reset-hooks: ''
|
||||
flash.admin.index-gists: ''
|
||||
flash.auth.username-exists: ''
|
||||
flash.auth.invalid-credentials: ''
|
||||
flash.auth.account-linked-oauth: ''
|
||||
flash.auth.account-unlinked-oauth: ''
|
||||
flash.auth.user-sshkeys-not-retrievable: ''
|
||||
flash.auth.user-sshkeys-not-created: ''
|
||||
flash.auth.must-be-logged-in: ''
|
||||
flash.gist.visibility-changed: ''
|
||||
flash.gist.deleted: ''
|
||||
flash.gist.fork-own-gist: ''
|
||||
flash.gist.forked: ''
|
||||
flash.user.email-updated: ''
|
||||
flash.user.invalid-ssh-key: ''
|
||||
flash.user.ssh-key-added: ''
|
||||
flash.user.ssh-key-deleted: ''
|
||||
flash.user.password-updated: ''
|
||||
flash.user.username-updated: ''
|
||||
validation.is-too-long: ''
|
||||
validation.should-not-include-sub-directory: ''
|
||||
validation.should-only-contain-alphanumeric-characters: ''
|
||||
validation.should-only-contain-alphanumeric-characters-and-dashes: ''
|
||||
validation.not-enough: ''
|
||||
validation.invalid: ''
|
||||
html.title.admin-panel: ''
|
||||
gist.search.help.title: 'фрагментов с указанным заголовком'
|
||||
gist.search.help.filename: 'фрагменты содержащие файлы с указанным именем'
|
||||
gist.search.help.extension: 'фрагменты, содержащие файлы с указанным расширением'
|
||||
gist.search.help.language: 'фрагменты, содержащие файлы с указанным языком'
|
||||
gist.forks.for: 'Форки фрагмента %s'
|
||||
gist.likes.for: 'Лайки фрагмента %s'
|
||||
gist.revision-of: 'Ревизия фрагмента %s'
|
||||
settings.link-gitlab-account: 'Привязать учётную запись Gitlab'
|
||||
settings.unlink-gitlab-account: 'Отвязать учётную запись GitHub'
|
||||
settings.change-username: 'Сменить имя пользователя'
|
||||
settings.create-password: 'Создать пароль'
|
||||
settings.create-password-help: 'Создайте пароль для входа в Opengist по HTTP'
|
||||
settings.change-password: 'Сменить пароль'
|
||||
settings.change-password-help: 'Смените пароль для входа в Opengist по HTTP'
|
||||
settings.password-label-title: 'Пароль'
|
||||
error.page-not-found: 'Страница не найдена'
|
||||
error.bad-request: 'Неверный запрос'
|
||||
error.signup-disabled: 'Регистрация недоступна'
|
||||
error.signup-disabled-form: 'Регистрация через форму недоступна'
|
||||
error.login-disabled-form: 'Авторизация через форму недоступна'
|
||||
error.complete-oauth-login: 'Не удалось завершить авторизацию пользователя: %s'
|
||||
error.oauth-unsupported: 'Провайдер OAuth не поддерживается'
|
||||
error.cannot-bind-data: 'Не удалось обработать данные'
|
||||
error.invalid-number: 'Некорректное числовое значение'
|
||||
error.invalid-character-unescaped: 'Обнаружен неверный неэкранированный символ'
|
||||
admin.invitations: 'Инвайты'
|
||||
admin.invitations.create: 'Создать инвайт'
|
||||
admin.actions.sync-previews: 'Обновить предпросмотры всех фрагментов'
|
||||
admin.actions.reset-hooks: 'Сбросить хуки Git-сервера для всех репозиториев'
|
||||
admin.actions.index-gists: 'Проиндексировать все фрагменты'
|
||||
validation.should-not-be-empty: 'Поле %s не должно быть пустым'
|
||||
admin.invitations.help: 'Инвайты используются для создания аккаунта, даже когда регистрация запрещена.'
|
||||
admin.invitations.max_uses: 'Максимальное количество использований'
|
||||
admin.invitations.expires_at: 'Истекает'
|
||||
admin.invitations.code: 'Код'
|
||||
admin.invitations.copy_link: 'Скопировать ссылку'
|
||||
admin.invitations.uses: 'Количество использований'
|
||||
admin.invitations.expired: 'Истёк'
|
||||
flash.admin.user-deleted: 'Пользователь удалён'
|
||||
flash.admin.gist-deleted: 'Фрагмент удалён'
|
||||
flash.admin.invitation-created: 'Приглашение создано'
|
||||
flash.admin.invitation-deleted: 'Приглашение удалено'
|
||||
flash.admin.sync-fs: 'Выполняется синхронизация репозиториев с файловой системой…'
|
||||
flash.admin.sync-db: 'Выполняется синхронизация репозиториев с базой данных…'
|
||||
flash.admin.git-gc: 'Сборка мусора в репозиториях…'
|
||||
flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…'
|
||||
flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…'
|
||||
flash.admin.index-gists: 'Выполняется индексация фрагментов…'
|
||||
flash.auth.username-exists: 'Такое имя пользователя уже занято'
|
||||
flash.auth.invalid-credentials: 'Некорректные данные для входа'
|
||||
flash.auth.account-linked-oauth: 'Учётная запись связана с %s'
|
||||
flash.auth.account-unlinked-oauth: 'Учётная запись отключена от %s'
|
||||
flash.auth.user-sshkeys-not-retrievable: 'Не удалось получить SSH-ключи пользователя'
|
||||
flash.auth.user-sshkeys-not-created: 'Не удалось создать SSH-ключ'
|
||||
flash.auth.must-be-logged-in: 'Для доступа к фрагментам необходимо войти в аккаунт'
|
||||
flash.gist.visibility-changed: 'Видимость фрагмента изменена'
|
||||
flash.gist.deleted: 'Фрагмент удалён'
|
||||
flash.gist.fork-own-gist: 'Нельзя создать форк собственного фрагмента'
|
||||
flash.gist.forked: 'Фрагмент создан как форк'
|
||||
flash.user.email-updated: 'Адрес электронной почты обновлён'
|
||||
flash.user.invalid-ssh-key: 'Неверный SSH-ключ'
|
||||
flash.user.ssh-key-added: 'SSH-ключ добавлен'
|
||||
flash.user.ssh-key-deleted: 'SSH-ключ удалён'
|
||||
flash.user.password-updated: 'Пароль обновлён'
|
||||
flash.user.username-updated: 'Имя пользователя обновлено'
|
||||
validation.is-too-long: 'Поле %s слишком длинное'
|
||||
validation.should-not-include-sub-directory: 'Поле %s не должно содержать подкаталоги'
|
||||
validation.should-only-contain-alphanumeric-characters: 'Поле %s должно содержать только буквы и цифры'
|
||||
validation.should-only-contain-alphanumeric-characters-and-dashes: 'Поле %s должно содержать только буквы, цифры и дефисы'
|
||||
validation.not-enough: 'Недостаточно %s'
|
||||
validation.invalid: 'Неверный %s'
|
||||
html.title.admin-panel: 'Панель администратора'
|
||||
settings.ssh-key-exists: SSH-ключ уже существует
|
||||
gist.file-binary-edit: Этот файл является бинарным.
|
||||
gist.preview-non-available: Предпросмотр недоступен
|
||||
gist.file-raw: Не удалось отобразить файл.
|
||||
gist.new.topics: Темы (через пробел)
|
||||
gist.list.topic-results-topic: Все фрагменты с темой %s
|
||||
gist.search.help.topic: фрагменты с заданной темой
|
||||
gist.search.placeholder.title: Заголовок
|
||||
gist.search.placeholder.visibility: Видимость
|
||||
gist.search.placeholder.public: Публичный
|
||||
gist.search.placeholder.unlisted: Скрытый
|
||||
gist.search.placeholder.private: Приватный
|
||||
gist.search.placeholder.language: Язык
|
||||
gist.search.placeholder.all: Все
|
||||
gist.search.placeholder.topics: Темы
|
||||
gist.search.placeholder.search: Поиск
|
||||
gist.new.drop-files: Перетащите файлы сюда или нажмите для загрузки
|
||||
gist.new.any-file-type: Поддерживаются файлы любого типа
|
||||
gist.delete.confirm: Вы уверены, что хотите удалить этот gist?
|
||||
gist.list.topic-results: Все фрагменты с этой темой
|
||||
gist.revision.binary-file-changes: Изменения в бинарных файлах не отображаются
|
||||
admin.actions.sync-gist-languages: Обновить языки всех фрагментов
|
||||
admin.invitations.delete_confirm: Вы хотите удалить это приглашение?
|
||||
flash.auth.passkey-deleted: Ключ доступа удалён
|
||||
flash.auth.passkey-registred: Ключ доступа %s зарегистрирован
|
||||
validation.invalid-gist-topics: 'Некорректные темы фрагмента: они должны начинаться с буквы или цифры, быть не длиннее 50 символов и могут содержать дефисы'
|
||||
settings.header.tokens: Токены доступа
|
||||
settings.style.removed-lines-color: Цвет удалённых строк
|
||||
settings.style.added-lines-color: Цвет добавленных строк
|
||||
settings.style.git-lines-color: Цвет git-строк
|
||||
settings.style.save-style: Сохранить оформление
|
||||
settings.create-token: Создать токен доступа
|
||||
settings.create-token-help: Токены доступа используются для доступа к API
|
||||
settings.token-name: Название
|
||||
settings.delete-token-confirm: Подтвердите удаление токена доступа
|
||||
settings.token-permissions: Права доступа
|
||||
settings.token-gist-permission: Фрагменты
|
||||
settings.token-permission-none: Нет доступа
|
||||
settings.token-permission-read: Чтение
|
||||
settings.token-permission-read-write: Чтение и запись
|
||||
settings.delete-token: Удалить
|
||||
settings.token-created-at: Создан
|
||||
settings.token-never-used: Не использовался
|
||||
settings.token-expiration: Срок действия
|
||||
settings.token-expiration-help: Оставьте пустым, чтобы срок действия не ограничивался
|
||||
settings.token-expires-at: Истекает
|
||||
settings.token-expired: истёк
|
||||
settings.token-deleted: Токен доступа удалён
|
||||
auth.mfa.delete-passkey-confirm: Подтвердите удаление ключа доступа
|
||||
auth.mfa.use-passkey: Использовать ключ доступа
|
||||
auth.mfa.bind-passkey: Добавить ключ доступа
|
||||
auth.mfa.login-with-passkey: Войти с помощью ключа доступа
|
||||
auth.mfa.waiting-for-passkey-input: Ожидание подтверждения в браузере…
|
||||
auth.mfa.use-passkey-to-finish: Используйте ключ доступа для завершения аутентификации
|
||||
auth.mfa.passkeys-help: Добавьте ключ доступа для входа в аккаунт и использования в качестве MFA.
|
||||
auth.mfa.passkey-name: Название
|
||||
auth.mfa.delete-passkey: Удалить
|
||||
auth.mfa.passkey-added-at: Добавлен
|
||||
auth.mfa.passkey-never-used: Никогда не использовался
|
||||
auth.mfa.passkey-last-used: Последнее использование
|
||||
auth.totp.already-enabled: TOTP уже включён
|
||||
auth.totp.invalid-secret: Некорректный секретный ключ TOTP
|
||||
auth.totp.invalid-code: Некорректный одноразовый код
|
||||
auth.totp.code-used: Код восстановления %s уже был использован и больше недействителен. Вы можете отключить MFA или сгенерировать новые коды.
|
||||
auth.totp.disabled: Двухфакторная аутентификация TOTP отключена
|
||||
auth.totp.disable: Отключить TOTP
|
||||
auth.totp.enter-code: Введите код из приложения Authenticator
|
||||
auth.totp.submit: Подтвердить
|
||||
auth.totp.proceed: Продолжить
|
||||
auth.totp.scan-qr-code: Отсканируйте QR-код ниже в приложении-аутентификаторе для включения двухфакторной аутентификации или введите указанную строку и подтвердите кодом.
|
||||
error.not-in-mfa-session: Пользователь не находится в MFA-сессии
|
||||
error.no-file-uploaded: Файл не загружен
|
||||
error.cannot-open-file: Не удалось открыть загруженный файл
|
||||
auth.totp.help: TOTP — это метод двухфакторной аутентификации, использующий общий секрет для генерации одноразового пароля.
|
||||
auth.totp.use: Использовать TOTP
|
||||
auth.totp.regenerate-recovery-codes: Сгенерировать коды восстановления заново
|
||||
auth.totp: Одноразовый пароль по времени (TOTP)
|
||||
flash.admin.sync-gist-languages: Обновление языков фрагментов…
|
||||
settings.token-created: Токен создан, обязательно сохраните его, повторно он показан не будет!
|
||||
settings.token-last-used: Последнее использование
|
||||
settings.token-no-expiration: Бессрочно
|
||||
auth.totp.save-recovery-codes: Сохраните коды восстановления в безопасном месте. Они понадобятся для восстановления доступа к аккаунту при утере приложения-аутентификатора.
|
||||
auth.totp.enter-recovery-key: или код восстановления, если вы потеряли устройство
|
||||
settings.style.theme: Тема
|
||||
settings.style.theme-light: Светлая тема
|
||||
settings.style.theme-dark: Тёмная тема
|
||||
settings.style.theme-auto: Авто
|
||||
auth.mfa: Регистрация отключена администратором
|
||||
auth.mfa.passkey: Вход
|
||||
auth.mfa.passkeys: Ключи доступа
|
||||
auth.totp.code: Код
|
||||
settings.header.account: Аккаунт
|
||||
settings.header.mfa: Двухфакторная аутентификация (MFA)
|
||||
settings.header.ssh: SSH
|
||||
settings.header.style: Тема оформления
|
||||
settings.style.gist-code: Код фрагмента
|
||||
settings.style.no-soft-wrap: Без переносов строк
|
||||
settings.style.soft-wrap: Перенос строк
|
||||
|
||||
@@ -266,3 +266,67 @@ validation.invalid: Geçersiz %s
|
||||
|
||||
html.title.admin-panel: Yönetici paneli
|
||||
settings.ssh-key-exists: SSH anahtarı zaten mevcut
|
||||
gist.search.help.topic: Verilen konuyla ilgili gist'ler
|
||||
gist.search.placeholder.unlisted: Listelenmemiş
|
||||
settings.header.style: Stil
|
||||
auth.mfa.passkey: Parola Anahtarı
|
||||
auth.mfa.waiting-for-passkey-input: Tarayıcı etkileşiminden gelecek girdi bekleniyor...
|
||||
settings.header.account: Hesap
|
||||
settings.style.no-soft-wrap: Yumuşak Satır Kaydırma Yok
|
||||
auth.totp: Zamana Dayalı Tek Kullanımlık Parola (TOTP)
|
||||
flash.admin.sync-gist-languages: Gist dilleri senkronize ediliyor...
|
||||
auth.mfa.passkeys-help: Hesabınıza giriş yapmak ve çok faktörlü kimlik doğrulama yöntemi olarak kullanmak için bir geçiş anahtarı ekleyin.
|
||||
validation.invalid-gist-topics: Geçersiz gist konuları, harf veya rakamla başlamalı, 50 karakterden uzun olmamalı ve tire içerebilir.
|
||||
auth.totp.enter-recovery-key: veya cihazınızı kaybettiyseniz kurtarma anahtarını kullanın
|
||||
auth.totp.save-recovery-codes: Kurtarma kodlarınızı güvenli bir yerde saklayın. Bu kodları, kimlik doğrulayıcı uygulamanıza erişimi kaybetmeniz durumunda hesabınıza yeniden erişmek için kullanabilirsiniz.
|
||||
error.not-in-mfa-session: Kullanıcı çok faktörlü kimlik doğrulama oturumunda değil
|
||||
admin.invitations.delete_confirm: Bu daveti silmek istiyor musunuz?
|
||||
auth.totp.help: TOTP, paylaşılan bir gizli anahtarı kullanarak tek kullanımlık bir parola üreten, iki faktörlü kimlik doğrulama yöntemidir.
|
||||
auth.totp.use: TOTP kullan
|
||||
auth.totp.regenerate-recovery-codes: Kurtarma kodlarını yeniden oluştur
|
||||
auth.totp.already-enabled: TOTP zaten etkinleştirilmiş
|
||||
auth.totp.invalid-secret: Geçersiz TOTP gizli anahtarı
|
||||
auth.totp.invalid-code: Geçersiz TOTP kodu
|
||||
auth.totp.code-used: '%s kurtarma kodu kullanıldı, artık geçersiz. Şu anda çok faktörlü kimlik doğrulamayı devre dışı bırakmak veya kodlarınızı yeniden oluşturmak isteyebilirsiniz.'
|
||||
flash.auth.passkey-registred: '%s geçiş anahtarı kaydedildi'
|
||||
gist.new.topics: Konular (boşluklarla ayır)
|
||||
gist.list.topic-results-topic: Tüm %s konusuyla eşleşen gist'ler
|
||||
gist.list.topic-results: Konuyla eşleşen tüm gist'ler
|
||||
gist.search.placeholder.title: Başlık
|
||||
gist.search.placeholder.visibility: Görünürlük
|
||||
gist.search.placeholder.public: Halka açık
|
||||
gist.search.placeholder.private: Özel
|
||||
gist.search.placeholder.language: Lisan
|
||||
gist.search.placeholder.all: Tümü
|
||||
gist.search.placeholder.topics: Başlıklar
|
||||
gist.search.placeholder.search: Ara
|
||||
gist.delete.confirm: Bu Gist'i silmek istediğinizden emin misiniz?
|
||||
flash.auth.passkey-deleted: Geçiş anahtarı silindi
|
||||
settings.header.mfa: ÇFKD
|
||||
settings.header.ssh: SSH
|
||||
settings.style.gist-code: Gist kodu
|
||||
settings.style.soft-wrap: Yumuşak Satır Kaydırma
|
||||
settings.style.removed-lines-color: Silinen satırların rengi
|
||||
settings.style.added-lines-color: Eklenen satırların rengi
|
||||
settings.style.git-lines-color: Git satırların rengi
|
||||
settings.style.save-style: Stili kaydet
|
||||
auth.mfa: Çok Faktörlü Kimlik Doğrulama
|
||||
auth.mfa.passkeys: Parola Anahtarları
|
||||
auth.mfa.use-passkey: Parola Anahtarı kullan
|
||||
auth.mfa.bind-passkey: Parola Anahtarı bağla
|
||||
auth.mfa.login-with-passkey: Parola Anahtarı ile Giriş yap
|
||||
auth.mfa.use-passkey-to-finish: Kimlik doğrulamayı tamamlamak için bir geçiş anahtarı kullanın
|
||||
auth.mfa.passkey-name: İsim
|
||||
auth.mfa.delete-passkey: Sil
|
||||
auth.mfa.passkey-added-at: Eklendi
|
||||
auth.mfa.passkey-never-used: Hiç kullanılmadı
|
||||
auth.mfa.passkey-last-used: Son kullanım
|
||||
auth.mfa.delete-passkey-confirm: Geçiş Anahtarının silinmesini onaylayın
|
||||
auth.totp.disabled: TOTP başarıyla devre dışı bırakıldı
|
||||
auth.totp.disable: TOTP devre dışı bırak
|
||||
auth.totp.enter-code: Kimlik Doğrulayıcı uygulamasındaki kodu girin
|
||||
auth.totp.code: Kod
|
||||
auth.totp.submit: Kaydet
|
||||
auth.totp.proceed: Onayla
|
||||
auth.totp.scan-qr-code: İki faktörlü kimlik doğrulamayı etkinleştirmek için aşağıdaki QR kodunu kimlik doğrulayıcı uygulamanızla tarayın veya aşağıdaki metni girin, ardından oluşturulan kodla onaylayın.
|
||||
admin.actions.sync-gist-languages: Tüm gist dillerini senkronize et
|
||||
|
||||
@@ -2,6 +2,8 @@ package index
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
|
||||
@@ -10,7 +12,6 @@ import (
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
"github.com/rs/zerolog/log"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type BleveIndexer struct {
|
||||
@@ -53,6 +54,8 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
|
||||
|
||||
docMapping := bleve.NewDocumentMapping()
|
||||
docMapping.AddFieldMappingsAt("GistID", bleve.NewNumericFieldMapping())
|
||||
docMapping.AddFieldMappingsAt("UserID", bleve.NewNumericFieldMapping())
|
||||
docMapping.AddFieldMappingsAt("Visibility", bleve.NewNumericFieldMapping())
|
||||
docMapping.AddFieldMappingsAt("Content", bleve.NewTextFieldMapping())
|
||||
|
||||
mapping := bleve.NewIndexMapping()
|
||||
@@ -74,6 +77,7 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
|
||||
}
|
||||
|
||||
docMapping.DefaultAnalyzer = "gistAnalyser"
|
||||
mapping.DefaultMapping = docMapping
|
||||
|
||||
return bleve.New(i.path, mapping)
|
||||
}
|
||||
@@ -105,39 +109,72 @@ func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
|
||||
var err error
|
||||
var indexerQuery query.Query
|
||||
if queryStr != "" {
|
||||
contentQuery := bleve.NewMatchPhraseQuery(queryStr)
|
||||
contentQuery.FieldVal = "Content"
|
||||
// Use match query with fuzzy matching for more flexible content search
|
||||
contentQuery := bleve.NewMatchQuery(queryStr)
|
||||
contentQuery.SetField("Content")
|
||||
contentQuery.SetFuzziness(2)
|
||||
indexerQuery = contentQuery
|
||||
} else {
|
||||
contentQuery := bleve.NewMatchAllQuery()
|
||||
indexerQuery = contentQuery
|
||||
}
|
||||
|
||||
privateQuery := bleve.NewBoolFieldQuery(false)
|
||||
privateQuery.SetField("Private")
|
||||
// Visibility filtering: show public gists (Visibility=0) OR user's own gists
|
||||
visibilityZero := float64(0)
|
||||
truee := true
|
||||
publicQuery := bleve.NewNumericRangeInclusiveQuery(&visibilityZero, &visibilityZero, &truee, &truee)
|
||||
publicQuery.SetField("Visibility")
|
||||
|
||||
userIdMatch := float64(userId)
|
||||
truee := true
|
||||
userIdQuery := bleve.NewNumericRangeInclusiveQuery(&userIdMatch, &userIdMatch, &truee, &truee)
|
||||
userIdQuery.SetField("UserID")
|
||||
|
||||
accessQuery := bleve.NewDisjunctionQuery(privateQuery, userIdQuery)
|
||||
accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery)
|
||||
indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery)
|
||||
|
||||
addQuery := func(field, value string) {
|
||||
if value != "" && value != "." {
|
||||
q := bleve.NewMatchPhraseQuery(value)
|
||||
q.FieldVal = field
|
||||
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q)
|
||||
}
|
||||
}
|
||||
// Handle "All" field - search across all metadata fields with OR logic
|
||||
if queryMetadata.All != "" {
|
||||
allQueries := make([]query.Query, 0)
|
||||
|
||||
addQuery("Username", queryMetadata.Username)
|
||||
addQuery("Title", queryMetadata.Title)
|
||||
addQuery("Extensions", "."+queryMetadata.Extension)
|
||||
addQuery("Filenames", queryMetadata.Filename)
|
||||
addQuery("Languages", queryMetadata.Language)
|
||||
addQuery("Topics", queryMetadata.Topic)
|
||||
// Create match phrase queries for each field
|
||||
fields := []struct {
|
||||
field string
|
||||
value string
|
||||
}{
|
||||
{"Username", queryMetadata.All},
|
||||
{"Title", queryMetadata.All},
|
||||
{"Extensions", "." + queryMetadata.All},
|
||||
{"Filenames", queryMetadata.All},
|
||||
{"Languages", queryMetadata.All},
|
||||
{"Topics", queryMetadata.All},
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
q := bleve.NewMatchPhraseQuery(f.value)
|
||||
q.FieldVal = f.field
|
||||
allQueries = append(allQueries, q)
|
||||
}
|
||||
|
||||
// Combine all field queries with OR (disjunction)
|
||||
allDisjunction := bleve.NewDisjunctionQuery(allQueries...)
|
||||
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, allDisjunction)
|
||||
} else {
|
||||
// Original behavior: add each metadata field with AND logic
|
||||
addQuery := func(field, value string) {
|
||||
if value != "" && value != "." {
|
||||
q := bleve.NewMatchPhraseQuery(value)
|
||||
q.FieldVal = field
|
||||
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q)
|
||||
}
|
||||
}
|
||||
|
||||
addQuery("Username", queryMetadata.Username)
|
||||
addQuery("Title", queryMetadata.Title)
|
||||
addQuery("Extensions", "."+queryMetadata.Extension)
|
||||
addQuery("Filenames", queryMetadata.Filename)
|
||||
addQuery("Languages", queryMetadata.Language)
|
||||
addQuery("Topics", queryMetadata.Topic)
|
||||
}
|
||||
|
||||
languageFacet := bleve.NewFacetRequest("Languages", 10)
|
||||
|
||||
|
||||
162
internal/index/bleve_test.go
Normal file
162
internal/index/bleve_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupBleveIndexer creates a new BleveIndexer for testing
|
||||
func setupBleveIndexer(t *testing.T) (*BleveIndexer, func()) {
|
||||
t.Helper()
|
||||
|
||||
// Create a temporary directory for the test index
|
||||
tmpDir, err := os.MkdirTemp("", "bleve-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(tmpDir, "test.index")
|
||||
indexer := NewBleveIndexer(indexPath)
|
||||
|
||||
// Initialize the indexer
|
||||
err = indexer.Init()
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("Failed to initialize BleveIndexer: %v", err)
|
||||
}
|
||||
|
||||
// Store in the global atomicIndexer since Add/Remove use it
|
||||
var idx Indexer = indexer
|
||||
atomicIndexer.Store(&idx)
|
||||
|
||||
// Return cleanup function
|
||||
cleanup := func() {
|
||||
atomicIndexer.Store(nil)
|
||||
indexer.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return indexer, cleanup
|
||||
}
|
||||
|
||||
func TestBleveIndexerAddGist(t *testing.T) {
|
||||
indexer, cleanup := setupBleveIndexer(t)
|
||||
defer cleanup()
|
||||
|
||||
testIndexerAddGist(t, indexer)
|
||||
}
|
||||
|
||||
func TestBleveIndexerAllFieldSearch(t *testing.T) {
|
||||
indexer, cleanup := setupBleveIndexer(t)
|
||||
defer cleanup()
|
||||
|
||||
testIndexerAllFieldSearch(t, indexer)
|
||||
}
|
||||
|
||||
func TestBleveIndexerFuzzySearch(t *testing.T) {
|
||||
indexer, cleanup := setupBleveIndexer(t)
|
||||
defer cleanup()
|
||||
|
||||
testIndexerFuzzySearch(t, indexer)
|
||||
}
|
||||
|
||||
func TestBleveIndexerSearchBasic(t *testing.T) {
|
||||
indexer, cleanup := setupBleveIndexer(t)
|
||||
defer cleanup()
|
||||
|
||||
testIndexerSearchBasic(t, indexer)
|
||||
}
|
||||
|
||||
func TestBleveIndexerPagination(t *testing.T) {
|
||||
indexer, cleanup := setupBleveIndexer(t)
|
||||
defer cleanup()
|
||||
|
||||
testIndexerPagination(t, indexer)
|
||||
}
|
||||
|
||||
// TestBleveIndexerInitAndClose tests Bleve-specific initialization and closing
|
||||
func TestBleveIndexerInitAndClose(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bleve-init-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
indexPath := filepath.Join(tmpDir, "test.index")
|
||||
indexer := NewBleveIndexer(indexPath)
|
||||
|
||||
// Test initialization
|
||||
err = indexer.Init()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize BleveIndexer: %v", err)
|
||||
}
|
||||
|
||||
if indexer.index == nil {
|
||||
t.Fatal("Expected index to be initialized, got nil")
|
||||
}
|
||||
|
||||
// Test closing
|
||||
indexer.Close()
|
||||
|
||||
// Test reopening the same index
|
||||
indexer2 := NewBleveIndexer(indexPath)
|
||||
err = indexer2.Init()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reopen BleveIndexer: %v", err)
|
||||
}
|
||||
defer indexer2.Close()
|
||||
|
||||
if indexer2.index == nil {
|
||||
t.Fatal("Expected reopened index to be initialized, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBleveIndexerUnicodeSearch tests that Unicode content can be indexed and searched
|
||||
func TestBleveIndexerUnicodeSearch(t *testing.T) {
|
||||
indexer, cleanup := setupBleveIndexer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Add a gist with Unicode content
|
||||
gist := &Gist{
|
||||
GistID: 100,
|
||||
UserID: 100,
|
||||
Visibility: 0,
|
||||
Username: "testuser",
|
||||
Title: "Unicode Test",
|
||||
Content: "Hello world with unicode characters: café résumé naïve",
|
||||
Filenames: []string{"test.txt"},
|
||||
Extensions: []string{".txt"},
|
||||
Languages: []string{"Text"},
|
||||
Topics: []string{"unicode"},
|
||||
CreatedAt: 1234567890,
|
||||
UpdatedAt: 1234567890,
|
||||
}
|
||||
|
||||
err := indexer.Add(gist)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add gist: %v", err)
|
||||
}
|
||||
|
||||
// Search for unicode content
|
||||
gistIDs, total, _, err := indexer.Search("café", SearchGistMetadata{}, 100, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
t.Skip("Unicode search may require specific index configuration")
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, id := range gistIDs {
|
||||
if id == 100 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Log("Unicode gist not found in search results, but other results were returned")
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,5 @@ type SearchGistMetadata struct {
|
||||
Extension string
|
||||
Language string
|
||||
Topic string
|
||||
All string
|
||||
}
|
||||
|
||||
1596
internal/index/indexer_test.go
Normal file
1596
internal/index/indexer_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/rs/zerolog/log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type MeiliIndexer struct {
|
||||
@@ -82,12 +84,13 @@ func (i *MeiliIndexer) Add(gist *Gist) error {
|
||||
if gist == nil {
|
||||
return errors.New("failed to add nil gist to index")
|
||||
}
|
||||
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, "GistID")
|
||||
primaryKey := "GistID"
|
||||
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *MeiliIndexer) Remove(gistID uint) error {
|
||||
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.DeleteDocument(strconv.Itoa(int(gistID)))
|
||||
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.DeleteDocument(strconv.Itoa(int(gistID)), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -127,16 +130,20 @@ func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
|
||||
|
||||
gistIds := make([]uint, 0, len(response.Hits))
|
||||
for _, hit := range response.Hits {
|
||||
if gistID, ok := hit.(map[string]interface{})["GistID"].(float64); ok {
|
||||
gistIds = append(gistIds, uint(gistID))
|
||||
if gistIDRaw, ok := hit["GistID"]; ok {
|
||||
var gistID float64
|
||||
if err := json.Unmarshal(gistIDRaw, &gistID); err == nil {
|
||||
gistIds = append(gistIds, uint(gistID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
languageCounts := make(map[string]int)
|
||||
if facets, ok := response.FacetDistribution.(map[string]interface{})["Languages"]; ok {
|
||||
for language, count := range facets.(map[string]interface{}) {
|
||||
if countValue, ok := count.(float64); ok {
|
||||
languageCounts[language] = int(countValue)
|
||||
if len(response.FacetDistribution) > 0 {
|
||||
var facetDist map[string]map[string]int
|
||||
if err := json.Unmarshal(response.FacetDistribution, &facetDist); err == nil {
|
||||
if facets, ok := facetDist["Languages"]; ok {
|
||||
languageCounts = facets
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ type CSVFile struct {
|
||||
Rows [][]string `json:"-"`
|
||||
}
|
||||
|
||||
func (r CSVFile) getFile() *git.File {
|
||||
return r.File
|
||||
func (r CSVFile) InternalType() string {
|
||||
return "CSVFile"
|
||||
}
|
||||
|
||||
func renderCsvFile(file *git.File) (*CSVFile, error) {
|
||||
|
||||
@@ -21,14 +21,15 @@ type HighlightedFile struct {
|
||||
HTML string `json:"-"`
|
||||
}
|
||||
|
||||
func (r HighlightedFile) getFile() *git.File {
|
||||
return r.File
|
||||
func (r HighlightedFile) InternalType() string {
|
||||
return "HighlightedFile"
|
||||
}
|
||||
|
||||
type RenderedGist struct {
|
||||
*db.Gist
|
||||
Lines []string
|
||||
HTML string
|
||||
Lines []string
|
||||
HTML string
|
||||
PreviewMimeType *git.MimeType
|
||||
}
|
||||
|
||||
func highlightFile(file *git.File) (HighlightedFile, error) {
|
||||
@@ -76,6 +77,18 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
|
||||
Gist: gist,
|
||||
}
|
||||
|
||||
if gist.PreviewMimeType != "" {
|
||||
mt := &git.MimeType{ContentType: gist.PreviewMimeType}
|
||||
if mt.CanBeEmbedded() {
|
||||
rendered.PreviewMimeType = mt
|
||||
return rendered, nil
|
||||
}
|
||||
}
|
||||
|
||||
if gist.Preview == "" {
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
style := newStyle()
|
||||
lexer := newLexer(gist.PreviewFilename)
|
||||
if lexer.Config().Name == "markdown" {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type RenderedFile interface {
|
||||
getFile() *git.File
|
||||
InternalType() string
|
||||
}
|
||||
|
||||
type NonHighlightedFile struct {
|
||||
@@ -17,8 +17,8 @@ type NonHighlightedFile struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (r NonHighlightedFile) getFile() *git.File {
|
||||
return r.File
|
||||
func (r NonHighlightedFile) InternalType() string {
|
||||
return "NonHighlightedFile"
|
||||
}
|
||||
|
||||
func RenderFiles(files []*git.File) []RenderedFile {
|
||||
@@ -54,7 +54,11 @@ func processFile(file *git.File) RenderedFile {
|
||||
if mt.IsCSV() {
|
||||
rendered, err := renderCsvFile(file)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error parsing CSV file for " + file.Filename)
|
||||
rendered, err := highlightFile(file)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error rendering gist preview for " + file.Filename)
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
return rendered
|
||||
} else if mt.IsText() && filepath.Ext(file.Filename) == ".md" {
|
||||
|
||||
@@ -92,7 +92,7 @@ func validateGistTopics(fl validator.FieldLevel) bool {
|
||||
if len(tag) > 50 {
|
||||
return false
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(tag) {
|
||||
if !regexp.MustCompile(`^[\p{L}\p{N}-]+$`).MatchString(tag) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func (ctx *Context) ErrorRes(code int, message string, err error) error {
|
||||
}
|
||||
|
||||
func (ctx *Context) RedirectTo(location string) error {
|
||||
return ctx.Context.Redirect(302, config.C.ExternalUrl+location)
|
||||
return ctx.Redirect(302, config.C.ExternalUrl+location)
|
||||
}
|
||||
|
||||
func (ctx *Context) Html(template string) error {
|
||||
@@ -145,5 +145,6 @@ func (ctx *Context) Tr(key string, args ...any) string {
|
||||
var ManifestEntries map[string]Asset
|
||||
|
||||
type Asset struct {
|
||||
File string `json:"file"`
|
||||
File string `json:"file"`
|
||||
Css []string `json:"css"`
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/auth/totp"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func BeginTotp(ctx *context.Context) error {
|
||||
@@ -25,7 +26,7 @@ func BeginTotp(ctx *context.Context) error {
|
||||
sess := ctx.GetSession()
|
||||
generatedSecret, _ := sess.Values["generatedSecret"].([]byte)
|
||||
|
||||
totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(ctx.User.Username, ogUrl.Hostname(), generatedSecret)
|
||||
totpSecret, qrcode, generatedSecret, err := totp.GenerateQRCode(ctx.User.Username, ogUrl.Hostname(), generatedSecret)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot generate TOTP QR code", err)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package gist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/index"
|
||||
@@ -9,8 +12,6 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
"gorm.io/gorm"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func AllGists(ctx *context.Context) error {
|
||||
@@ -54,18 +55,19 @@ func AllGists(ctx *context.Context) error {
|
||||
|
||||
mode := ctx.GetData("mode")
|
||||
if fromUserStr == "" {
|
||||
if mode == "search" {
|
||||
switch mode {
|
||||
case "search":
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
|
||||
ctx.SetData("searchQuery", ctx.QueryParam("q"))
|
||||
pagination.Query = ctx.QueryParam("q")
|
||||
urlPage = "search"
|
||||
gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "")
|
||||
} else if mode == "topics" {
|
||||
case "topics":
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic")))
|
||||
ctx.SetData("topic", ctx.Param("topic"))
|
||||
urlPage = "topics/" + ctx.Param("topic")
|
||||
gists, err = db.GetAllGistsFromSearch(currentUserId, "", pageInt-1, sort, order, ctx.Param("topic"))
|
||||
} else if mode == "all" {
|
||||
case "all":
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all"))
|
||||
urlPage = "all"
|
||||
gists, err = db.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order)
|
||||
@@ -101,15 +103,16 @@ func AllGists(ctx *context.Context) error {
|
||||
ctx.SetData("countForked", countForked)
|
||||
}
|
||||
|
||||
if mode == "liked" {
|
||||
switch mode {
|
||||
case "liked":
|
||||
urlPage = fromUserStr + "/liked"
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-liked-by", fromUserStr))
|
||||
gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
|
||||
} else if mode == "forked" {
|
||||
case "forked":
|
||||
urlPage = fromUserStr + "/forked"
|
||||
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-forked-by", fromUserStr))
|
||||
gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
|
||||
} else if mode == "fromUser" {
|
||||
case "fromUser":
|
||||
urlPage = fromUserStr
|
||||
|
||||
if languages, err := db.GetGistLanguagesForUser(fromUser.ID, currentUserId); err != nil {
|
||||
@@ -186,6 +189,7 @@ func Search(ctx *context.Context) error {
|
||||
Extension: meta["extension"],
|
||||
Language: meta["language"],
|
||||
Topic: meta["topic"],
|
||||
All: meta["all"],
|
||||
}, currentUserId, pageInt)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error searching gists", err)
|
||||
|
||||
@@ -22,10 +22,7 @@ func Create(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
func ProcessCreate(ctx *context.Context) error {
|
||||
isCreate := false
|
||||
if ctx.Request().URL.Path == "/" {
|
||||
isCreate = true
|
||||
}
|
||||
isCreate := ctx.Request().URL.Path == "/"
|
||||
|
||||
err := ctx.Request().ParseForm()
|
||||
if err != nil {
|
||||
@@ -151,7 +148,7 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error creating an UUID", err)
|
||||
}
|
||||
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
|
||||
gist.Uuid = strings.ReplaceAll(uuidGist.String(), "-", "")
|
||||
|
||||
gist.UserID = user.ID
|
||||
gist.User = *user
|
||||
|
||||
@@ -3,6 +3,7 @@ package gist
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
@@ -19,8 +20,23 @@ func RawFile(ctx *context.Context) error {
|
||||
if file == nil {
|
||||
return ctx.NotFound("File not found")
|
||||
}
|
||||
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
|
||||
ctx.Response().Header().Set("Content-Disposition", "inline; filename=\""+file.Filename+"\"")
|
||||
|
||||
if file.MimeType.IsSVG() {
|
||||
ctx.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
} else if file.MimeType.IsPDF() {
|
||||
ctx.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
||||
}
|
||||
|
||||
if file.MimeType.CanBeEmbedded() {
|
||||
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
|
||||
} else if file.MimeType.IsText() {
|
||||
ctx.Response().Header().Set("Content-Type", "text/plain")
|
||||
} else {
|
||||
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
|
||||
ctx.Response().Header().Set("Content-Disposition", "inline; filename=\""+url.PathEscape(file.Filename)+"\"")
|
||||
ctx.Response().Header().Set("X-Content-Type-Options", "nosniff")
|
||||
return ctx.PlainText(200, file.Content)
|
||||
}
|
||||
|
||||
@@ -36,8 +52,9 @@ func DownloadFile(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
|
||||
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+file.Filename)
|
||||
ctx.Response().Header().Set("Content-Disposition", "attachment; filename=\""+url.PathEscape(file.Filename)+"\"")
|
||||
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
|
||||
ctx.Response().Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, err = ctx.Response().Write([]byte(file.Content))
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error downloading the file", err)
|
||||
|
||||
@@ -2,12 +2,13 @@ package gist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
"gorm.io/gorm"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Fork(ctx *context.Context) error {
|
||||
@@ -34,7 +35,7 @@ func Fork(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
newGist := &db.Gist{
|
||||
Uuid: strings.Replace(uuidGist.String(), "-", "", -1),
|
||||
Uuid: strings.ReplaceAll(uuidGist.String(), "-", ""),
|
||||
Title: gist.Title,
|
||||
Preview: gist.Preview,
|
||||
PreviewFilename: gist.PreviewFilename,
|
||||
|
||||
@@ -97,8 +97,10 @@ func GistJson(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
func GistJs(ctx *context.Context) error {
|
||||
theme := "light"
|
||||
if _, exists := ctx.QueryParams()["dark"]; exists {
|
||||
ctx.SetData("dark", "dark")
|
||||
theme = "dark"
|
||||
}
|
||||
|
||||
gist := ctx.GetData("gist").(*db.Gist)
|
||||
@@ -117,16 +119,21 @@ func GistJs(ctx *context.Context) error {
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
cssUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), context.ManifestEntries["embed.css"].File)
|
||||
cssUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), context.ManifestEntries["ts/embed.ts"].Css[0])
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error joining css url", err)
|
||||
}
|
||||
|
||||
js, err := escapeJavaScriptContent(htmlbuf.String(), cssUrl)
|
||||
themeUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), context.ManifestEntries["ts/"+theme+".ts"].Css[0])
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error joining theme url", err)
|
||||
}
|
||||
|
||||
js, err := escapeJavaScriptContent(htmlbuf.String(), cssUrl, themeUrl)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error escaping JavaScript content", err)
|
||||
}
|
||||
ctx.Response().Header().Set("Content-Type", "application/javascript")
|
||||
ctx.Response().Header().Set("Content-Type", "text/javascript")
|
||||
return ctx.PlainText(200, js)
|
||||
}
|
||||
|
||||
@@ -141,7 +148,7 @@ func Preview(ctx *context.Context) error {
|
||||
return ctx.PlainText(200, previewStr)
|
||||
}
|
||||
|
||||
func escapeJavaScriptContent(htmlContent, cssUrl string) (string, error) {
|
||||
func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string) (string, error) {
|
||||
jsonContent, err := gojson.Marshal(htmlContent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode content: %w", err)
|
||||
@@ -152,11 +159,18 @@ func escapeJavaScriptContent(htmlContent, cssUrl string) (string, error) {
|
||||
return "", fmt.Errorf("failed to encode CSS URL: %w", err)
|
||||
}
|
||||
|
||||
jsonThemeUrl, err := gojson.Marshal(themeUrl)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode Theme URL: %w", err)
|
||||
}
|
||||
|
||||
js := fmt.Sprintf(`
|
||||
document.write('<link rel="stylesheet" href=%s>');
|
||||
document.write('<link rel="stylesheet" href=%s>');
|
||||
document.write(%s);
|
||||
`,
|
||||
string(jsonCssUrl),
|
||||
string(jsonThemeUrl),
|
||||
string(jsonContent),
|
||||
)
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ func createGist(user *db.User, url string) (*db.Gist, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
|
||||
gist.Uuid = strings.ReplaceAll(uuidGist.String(), "-", "")
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
|
||||
if url != "" {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo-contrib/echoprometheus"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
var (
|
||||
// Using promauto to automatically register metrics with the default registry
|
||||
countUsersGauge prometheus.Gauge
|
||||
countGistsGauge prometheus.Gauge
|
||||
countSSHKeysGauge prometheus.Gauge
|
||||
@@ -18,84 +14,52 @@ var (
|
||||
metricsInitialized bool = false
|
||||
)
|
||||
|
||||
// initMetrics initializes metrics if they're not already initialized
|
||||
func initMetrics() {
|
||||
if metricsInitialized {
|
||||
return
|
||||
}
|
||||
|
||||
// Only initialize metrics if they're enabled
|
||||
if config.C.MetricsEnabled {
|
||||
countUsersGauge = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "opengist_users_total",
|
||||
Help: "Total number of users",
|
||||
},
|
||||
)
|
||||
countUsersGauge = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "opengist_users_total",
|
||||
Help: "Total number of users",
|
||||
},
|
||||
)
|
||||
|
||||
countGistsGauge = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "opengist_gists_total",
|
||||
Help: "Total number of gists",
|
||||
},
|
||||
)
|
||||
countGistsGauge = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "opengist_gists_total",
|
||||
Help: "Total number of gists",
|
||||
},
|
||||
)
|
||||
|
||||
countSSHKeysGauge = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "opengist_ssh_keys_total",
|
||||
Help: "Total number of SSH keys",
|
||||
},
|
||||
)
|
||||
countSSHKeysGauge = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "opengist_ssh_keys_total",
|
||||
Help: "Total number of SSH keys",
|
||||
},
|
||||
)
|
||||
|
||||
metricsInitialized = true
|
||||
}
|
||||
metricsInitialized = true
|
||||
}
|
||||
|
||||
// updateMetrics refreshes all metric values from the database
|
||||
func updateMetrics() {
|
||||
// Only update metrics if they're enabled
|
||||
if !config.C.MetricsEnabled || !metricsInitialized {
|
||||
if !metricsInitialized {
|
||||
return
|
||||
}
|
||||
|
||||
// Update users count
|
||||
countUsers, err := db.CountAll(&db.User{})
|
||||
if err == nil {
|
||||
countUsersGauge.Set(float64(countUsers))
|
||||
}
|
||||
|
||||
// Update gists count
|
||||
countGists, err := db.CountAll(&db.Gist{})
|
||||
if err == nil {
|
||||
countGistsGauge.Set(float64(countGists))
|
||||
}
|
||||
|
||||
// Update SSH keys count
|
||||
countKeys, err := db.CountAll(&db.SSHKey{})
|
||||
if err == nil {
|
||||
countSSHKeysGauge.Set(float64(countKeys))
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics handles prometheus metrics endpoint requests.
|
||||
func Metrics(ctx *context.Context) error {
|
||||
// If metrics are disabled, return 404
|
||||
if !config.C.MetricsEnabled {
|
||||
return ctx.NotFound("Metrics endpoint is disabled")
|
||||
}
|
||||
|
||||
// Initialize metrics if not already done
|
||||
initMetrics()
|
||||
|
||||
// Update metrics
|
||||
updateMetrics()
|
||||
|
||||
// Get the Echo context
|
||||
echoCtx := ctx.Context
|
||||
|
||||
// Use the Prometheus metrics handler
|
||||
handler := echoprometheus.NewHandler()
|
||||
|
||||
// Call the handler
|
||||
return handler(echoCtx)
|
||||
}
|
||||
|
||||
50
internal/web/handlers/metrics/server.go
Normal file
50
internal/web/handlers/metrics/server.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo-contrib/echoprometheus"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
echo *echo.Echo
|
||||
}
|
||||
|
||||
func NewServer() *Server {
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
s := &Server{echo: e}
|
||||
|
||||
initMetrics()
|
||||
|
||||
e.GET("/metrics", func(ctx echo.Context) error {
|
||||
updateMetrics()
|
||||
return echoprometheus.NewHandler()(ctx)
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Start() {
|
||||
addr := config.C.MetricsHost + ":" + config.C.MetricsPort
|
||||
log.Info().Msg("Starting metrics server on http://" + addr)
|
||||
if err := s.echo.Start(addr); err != nil && err != http.ErrServerClosed {
|
||||
log.Error().Err(err).Msg("Failed to start metrics server")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
log.Info().Msg("Stopping metrics server...")
|
||||
if err := s.echo.Close(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to stop metrics server")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.echo.ServeHTTP(w, r)
|
||||
}
|
||||
75
internal/web/handlers/settings/access_token.go
Normal file
75
internal/web/handlers/settings/access_token.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/i18n"
|
||||
"github.com/thomiceli/opengist/internal/validator"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
func AccessTokens(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
|
||||
tokens, err := db.GetAccessTokensByUserID(user.ID)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot get access tokens", err)
|
||||
}
|
||||
|
||||
ctx.SetData("accessTokens", tokens)
|
||||
ctx.SetData("settingsHeaderPage", "tokens")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("settings"))
|
||||
return ctx.Html("settings_tokens.html")
|
||||
}
|
||||
|
||||
func AccessTokensProcess(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
|
||||
dto := new(db.AccessTokenDTO)
|
||||
if err := ctx.Bind(dto); err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
|
||||
}
|
||||
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
}
|
||||
|
||||
token := dto.ToAccessToken()
|
||||
token.UserID = user.ID
|
||||
|
||||
plainToken, err := token.GenerateToken()
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot generate token", err)
|
||||
}
|
||||
|
||||
if err := token.Create(); err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot create access token", err)
|
||||
}
|
||||
|
||||
// Show the token once to the user
|
||||
ctx.AddFlash(ctx.Tr("settings.token-created"), "success")
|
||||
ctx.AddFlash(plainToken, "success")
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
}
|
||||
|
||||
func AccessTokensDelete(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
tokenID, err := strconv.Atoi(ctx.Param("id"))
|
||||
if err != nil {
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
}
|
||||
|
||||
token, err := db.GetAccessTokenByID(uint(tokenID))
|
||||
if err != nil || token.UserID != user.ID {
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
}
|
||||
|
||||
if err := token.Delete(); err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot delete access token", err)
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("settings.token-deleted"), "success")
|
||||
return ctx.RedirectTo("/settings/access-tokens")
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo-contrib/echoprometheus"
|
||||
@@ -37,8 +38,7 @@ func (s *Server) registerMiddlewares() {
|
||||
s.echo.Use(Middleware(dataInit).toEcho())
|
||||
s.echo.Use(Middleware(locale).toEcho())
|
||||
if config.C.MetricsEnabled {
|
||||
p := echoprometheus.NewMiddleware("opengist")
|
||||
s.echo.Use(p)
|
||||
s.echo.Use(echoprometheus.NewMiddleware("opengist"))
|
||||
}
|
||||
|
||||
s.echo.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{
|
||||
@@ -91,21 +91,33 @@ func (s *Server) errorHandler(err error, ctx echo.Context) {
|
||||
data["error"] = err
|
||||
if acceptJson {
|
||||
if err := ctx.JSON(httpErr.Code, httpErr); err != nil {
|
||||
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
|
||||
return
|
||||
}
|
||||
log.Fatal().Err(err).Send()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctx.Render(httpErr.Code, "error", data); err != nil {
|
||||
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
|
||||
return
|
||||
}
|
||||
log.Fatal().Err(err).Send()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
|
||||
return
|
||||
}
|
||||
log.Error().Err(err).Send()
|
||||
httpErr = echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
data["error"] = httpErr
|
||||
if err := ctx.Render(500, "error", data); err != nil {
|
||||
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
|
||||
return
|
||||
}
|
||||
log.Fatal().Err(err).Send()
|
||||
}
|
||||
}
|
||||
@@ -194,6 +206,9 @@ func makeCheckRequireLogin(isSingleGistAccess bool) Middleware {
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
if getUserByToken(ctx) != nil {
|
||||
return next(ctx)
|
||||
}
|
||||
allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, isSingleGistAccess)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to check if unauthenticated access is allowed")
|
||||
@@ -314,6 +329,39 @@ func loadSettings(ctx *context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUserByToken checks the Authorization header for token-based auth.
|
||||
// Expects format: Authorization: Token <token>
|
||||
// Returns the user if the token is valid and has gist read permission, nil otherwise.
|
||||
func getUserByToken(ctx *context.Context) *db.User {
|
||||
authHeader := ctx.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(authHeader, "Token ") {
|
||||
return nil
|
||||
}
|
||||
|
||||
plainToken := strings.TrimPrefix(authHeader, "Token ")
|
||||
|
||||
accessToken, err := db.GetAccessTokenByToken(plainToken)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if accessToken.IsExpired() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !accessToken.HasGistReadPermission() {
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = accessToken.UpdateLastUsed()
|
||||
|
||||
return &accessToken.User
|
||||
}
|
||||
|
||||
func gistInit(next Handler) Handler {
|
||||
return func(ctx *context.Context) error {
|
||||
currUser := ctx.User
|
||||
@@ -340,7 +388,12 @@ func gistInit(next Handler) Handler {
|
||||
|
||||
if gist.Private == db.PrivateVisibility {
|
||||
if currUser == nil || currUser.ID != gist.UserID {
|
||||
return ctx.NotFound("Gist not found")
|
||||
// Check for token-based auth via Authorization header
|
||||
if tokenUser := getUserByToken(ctx); tokenUser != nil && tokenUser.ID == gist.UserID {
|
||||
// Token is valid and belongs to gist owner, allow access
|
||||
} else {
|
||||
return ctx.NotFound("Gist not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,12 @@ func (s *Server) setFuncMap() {
|
||||
}
|
||||
return config.C.ExternalUrl + "/" + context.ManifestEntries[file].File
|
||||
},
|
||||
"assetCss": func(file string) string {
|
||||
if s.dev {
|
||||
return "http://localhost:16157/" + file
|
||||
}
|
||||
return config.C.ExternalUrl + "/" + context.ManifestEntries[file].Css[0]
|
||||
},
|
||||
"custom": func(file string) string {
|
||||
assetpath, err := url.JoinPath("/", "assets", file)
|
||||
if err != nil {
|
||||
@@ -186,6 +192,20 @@ func (s *Server) setFuncMap() {
|
||||
"humanDate": func(t int64) string {
|
||||
return time.Unix(t, 0).Format("02/01/2006 15:04")
|
||||
},
|
||||
"humanDateOnly": func(t int64) string {
|
||||
return time.Unix(t, 0).Format("02/01/2006")
|
||||
},
|
||||
"mainTheme": func(theme *db.UserStyleDTO) string {
|
||||
if theme == nil {
|
||||
return "auto"
|
||||
}
|
||||
|
||||
if theme.Theme == "" {
|
||||
return "auto"
|
||||
}
|
||||
|
||||
return theme.Theme
|
||||
},
|
||||
}
|
||||
|
||||
t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html"))
|
||||
@@ -206,7 +226,7 @@ func (s *Server) setFuncMap() {
|
||||
}
|
||||
|
||||
func (s *Server) parseManifestEntries() {
|
||||
file, err := public.Files.Open("manifest.json")
|
||||
file, err := public.Files.Open(".vite/manifest.json")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to open manifest.json")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/gist"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/git"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/health"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/metrics"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/settings"
|
||||
"github.com/thomiceli/opengist/public"
|
||||
)
|
||||
@@ -34,10 +33,6 @@ func (s *Server) registerRoutes() {
|
||||
|
||||
r.GET("/healthcheck", health.Healthcheck)
|
||||
|
||||
if config.C.MetricsEnabled {
|
||||
r.GET("/metrics", metrics.Metrics)
|
||||
}
|
||||
|
||||
r.GET("/register", auth.Register)
|
||||
r.POST("/register", auth.ProcessRegister)
|
||||
r.GET("/login", auth.Login)
|
||||
@@ -67,6 +62,9 @@ func (s *Server) registerRoutes() {
|
||||
sA.DELETE("/account", settings.AccountDeleteProcess)
|
||||
sA.POST("/ssh-keys", settings.SshKeysProcess)
|
||||
sA.DELETE("/ssh-keys/:id", settings.SshKeysDelete)
|
||||
sA.GET("/access-tokens", settings.AccessTokens)
|
||||
sA.POST("/access-tokens", settings.AccessTokensProcess)
|
||||
sA.DELETE("/access-tokens/:id", settings.AccessTokensDelete)
|
||||
sA.DELETE("/passkeys/:id", settings.PasskeyDelete)
|
||||
sA.PUT("/password", settings.PasswordProcess)
|
||||
sA.PUT("/username", settings.UsernameProcess)
|
||||
|
||||
448
internal/web/test/access_token_test.go
Normal file
448
internal/web/test/access_token_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
)
|
||||
|
||||
func TestAccessTokensCRUD(t *testing.T) {
|
||||
s := Setup(t)
|
||||
defer Teardown(t, s)
|
||||
|
||||
// Register and login
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
register(t, s, user1)
|
||||
|
||||
// Access tokens page requires login
|
||||
s.sessionCookie = ""
|
||||
err := s.Request("GET", "/settings/access-tokens", nil, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
login(t, s, user1)
|
||||
|
||||
// Access tokens page
|
||||
err = s.Request("GET", "/settings/access-tokens", nil, 200)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a token with read permission
|
||||
tokenDTO := db.AccessTokenDTO{
|
||||
Name: "test-token",
|
||||
ScopeGist: db.ReadPermission,
|
||||
}
|
||||
err = s.Request("POST", "/settings/access-tokens", tokenDTO, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify token was created in database
|
||||
tokens, err := db.GetAccessTokensByUserID(1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 1)
|
||||
require.Equal(t, "test-token", tokens[0].Name)
|
||||
require.Equal(t, uint(db.ReadPermission), tokens[0].ScopeGist)
|
||||
require.Equal(t, int64(0), tokens[0].ExpiresAt)
|
||||
|
||||
// Create another token with expiration
|
||||
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
||||
tokenDTO2 := db.AccessTokenDTO{
|
||||
Name: "expiring-token",
|
||||
ScopeGist: db.ReadWritePermission,
|
||||
ExpiresAt: tomorrow,
|
||||
}
|
||||
err = s.Request("POST", "/settings/access-tokens", tokenDTO2, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
tokens, err = db.GetAccessTokensByUserID(1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 2)
|
||||
|
||||
// Delete the first token
|
||||
err = s.Request("DELETE", "/settings/access-tokens/1", nil, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
tokens, err = db.GetAccessTokensByUserID(1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 1)
|
||||
require.Equal(t, "expiring-token", tokens[0].Name)
|
||||
}
|
||||
|
||||
func TestAccessTokenPrivateGistAccess(t *testing.T) {
|
||||
s := Setup(t)
|
||||
defer Teardown(t, s)
|
||||
|
||||
// Register user and create a private gist
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
register(t, s, user1)
|
||||
|
||||
gist1 := db.GistDTO{
|
||||
Title: "private-gist",
|
||||
Description: "my private gist",
|
||||
VisibilityDTO: db.VisibilityDTO{
|
||||
Private: db.PrivateVisibility,
|
||||
},
|
||||
Name: []string{"secret.txt"},
|
||||
Content: []string{"secret content"},
|
||||
}
|
||||
err := s.Request("POST", "/", gist1, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist1db, err := db.GetGistByID("1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create access token with read permission
|
||||
token := &db.AccessToken{
|
||||
Name: "read-token",
|
||||
UserID: 1,
|
||||
ScopeGist: db.ReadPermission,
|
||||
}
|
||||
plainToken, err := token.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = token.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clear session - simulate unauthenticated request
|
||||
s.sessionCookie = ""
|
||||
|
||||
// Without token, private gist should return 404
|
||||
err = s.Request("GET", "/thomas/"+gist1db.Uuid, nil, 404)
|
||||
require.NoError(t, err)
|
||||
|
||||
// With valid token, private gist should be accessible
|
||||
headers := map[string]string{"Authorization": "Token " + plainToken}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Raw content should also be accessible with token
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+"/raw/HEAD/secret.txt", nil, 200, headers)
|
||||
require.NoError(t, err)
|
||||
|
||||
// JSON endpoint should also be accessible with token
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+".json", nil, 200, headers)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Invalid token should not work
|
||||
invalidHeaders := map[string]string{"Authorization": "Token invalid_token"}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, invalidHeaders)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAccessTokenPermissions(t *testing.T) {
|
||||
s := Setup(t)
|
||||
defer Teardown(t, s)
|
||||
|
||||
// Register user and create a private gist
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
register(t, s, user1)
|
||||
|
||||
gist1 := db.GistDTO{
|
||||
Title: "private-gist",
|
||||
Description: "my private gist",
|
||||
VisibilityDTO: db.VisibilityDTO{
|
||||
Private: db.PrivateVisibility,
|
||||
},
|
||||
Name: []string{"file.txt"},
|
||||
Content: []string{"content"},
|
||||
}
|
||||
err := s.Request("POST", "/", gist1, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist1db, err := db.GetGistByID("1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create token with NO permission
|
||||
noPermToken := &db.AccessToken{
|
||||
Name: "no-perm-token",
|
||||
UserID: 1,
|
||||
ScopeGist: db.NoPermission,
|
||||
}
|
||||
noPermPlain, err := noPermToken.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = noPermToken.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create token with READ permission
|
||||
readToken := &db.AccessToken{
|
||||
Name: "read-token",
|
||||
UserID: 1,
|
||||
ScopeGist: db.ReadPermission,
|
||||
}
|
||||
readPlain, err := readToken.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = readToken.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
s.sessionCookie = ""
|
||||
|
||||
// No permission token should not grant access
|
||||
noPermHeaders := map[string]string{"Authorization": "Token " + noPermPlain}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, noPermHeaders)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read permission token should grant access
|
||||
readHeaders := map[string]string{"Authorization": "Token " + readPlain}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, readHeaders)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAccessTokenExpiration(t *testing.T) {
|
||||
s := Setup(t)
|
||||
defer Teardown(t, s)
|
||||
|
||||
// Register user and create a private gist
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
register(t, s, user1)
|
||||
|
||||
gist1 := db.GistDTO{
|
||||
Title: "private-gist",
|
||||
Description: "my private gist",
|
||||
VisibilityDTO: db.VisibilityDTO{
|
||||
Private: db.PrivateVisibility,
|
||||
},
|
||||
Name: []string{"file.txt"},
|
||||
Content: []string{"content"},
|
||||
}
|
||||
err := s.Request("POST", "/", gist1, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist1db, err := db.GetGistByID("1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an expired token
|
||||
expiredToken := &db.AccessToken{
|
||||
Name: "expired-token",
|
||||
UserID: 1,
|
||||
ScopeGist: db.ReadPermission,
|
||||
ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(), // Expired yesterday
|
||||
}
|
||||
expiredPlain, err := expiredToken.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = expiredToken.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a valid (non-expired) token
|
||||
validToken := &db.AccessToken{
|
||||
Name: "valid-token",
|
||||
UserID: 1,
|
||||
ScopeGist: db.ReadPermission,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), // Expires tomorrow
|
||||
}
|
||||
validPlain, err := validToken.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = validToken.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
s.sessionCookie = ""
|
||||
|
||||
// Expired token should not grant access
|
||||
expiredHeaders := map[string]string{"Authorization": "Token " + expiredPlain}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, expiredHeaders)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Valid token should grant access
|
||||
validHeaders := map[string]string{"Authorization": "Token " + validPlain}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, validHeaders)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAccessTokenWrongUser(t *testing.T) {
|
||||
s := Setup(t)
|
||||
defer Teardown(t, s)
|
||||
|
||||
// Register two users
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
register(t, s, user1)
|
||||
|
||||
// Create a private gist for user1
|
||||
gist1 := db.GistDTO{
|
||||
Title: "thomas-private-gist",
|
||||
Description: "thomas private gist",
|
||||
VisibilityDTO: db.VisibilityDTO{
|
||||
Private: db.PrivateVisibility,
|
||||
},
|
||||
Name: []string{"file.txt"},
|
||||
Content: []string{"content"},
|
||||
}
|
||||
err := s.Request("POST", "/", gist1, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist1db, err := db.GetGistByID("1")
|
||||
require.NoError(t, err)
|
||||
|
||||
s.sessionCookie = ""
|
||||
user2 := db.UserDTO{Username: "kaguya", Password: "kaguya"}
|
||||
register(t, s, user2)
|
||||
|
||||
// Create token for user2
|
||||
user2Token := &db.AccessToken{
|
||||
Name: "kaguya-token",
|
||||
UserID: 2,
|
||||
ScopeGist: db.ReadPermission,
|
||||
}
|
||||
user2Plain, err := user2Token.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = user2Token.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
s.sessionCookie = ""
|
||||
|
||||
// User2's token should NOT grant access to user1's private gist
|
||||
user2Headers := map[string]string{"Authorization": "Token " + user2Plain}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, user2Headers)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create token for user1
|
||||
user1Token := &db.AccessToken{
|
||||
Name: "thomas-token",
|
||||
UserID: 1,
|
||||
ScopeGist: db.ReadPermission,
|
||||
}
|
||||
user1Plain, err := user1Token.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = user1Token.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
// User1's token SHOULD grant access to user1's private gist
|
||||
user1Headers := map[string]string{"Authorization": "Token " + user1Plain}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, user1Headers)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAccessTokenLastUsedUpdate(t *testing.T) {
|
||||
s := Setup(t)
|
||||
defer Teardown(t, s)
|
||||
|
||||
// Register user and create a private gist
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
register(t, s, user1)
|
||||
|
||||
gist1 := db.GistDTO{
|
||||
Title: "private-gist",
|
||||
Description: "my private gist",
|
||||
VisibilityDTO: db.VisibilityDTO{
|
||||
Private: db.PrivateVisibility,
|
||||
},
|
||||
Name: []string{"file.txt"},
|
||||
Content: []string{"content"},
|
||||
}
|
||||
err := s.Request("POST", "/", gist1, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist1db, err := db.GetGistByID("1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create token
|
||||
token := &db.AccessToken{
|
||||
Name: "test-token",
|
||||
UserID: 1,
|
||||
ScopeGist: db.ReadPermission,
|
||||
}
|
||||
plainToken, err := token.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = token.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify LastUsedAt is 0 initially
|
||||
tokenFromDB, err := db.GetAccessTokenByID(token.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), tokenFromDB.LastUsedAt)
|
||||
|
||||
s.sessionCookie = ""
|
||||
|
||||
// Use the token
|
||||
headers := map[string]string{"Authorization": "Token " + plainToken}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify LastUsedAt was updated
|
||||
tokenFromDB, err = db.GetAccessTokenByID(token.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, int64(0), tokenFromDB.LastUsedAt)
|
||||
}
|
||||
|
||||
func TestAccessTokenWithRequireLogin(t *testing.T) {
|
||||
s := Setup(t)
|
||||
defer Teardown(t, s)
|
||||
|
||||
admin := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
register(t, s, admin)
|
||||
|
||||
gist1 := db.GistDTO{
|
||||
Title: "private-gist",
|
||||
Description: "my private gist",
|
||||
VisibilityDTO: db.VisibilityDTO{
|
||||
Private: db.PrivateVisibility,
|
||||
},
|
||||
Name: []string{"file.txt"},
|
||||
Content: []string{"content"},
|
||||
}
|
||||
err := s.Request("POST", "/", gist1, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist1db, err := db.GetGistByID("1")
|
||||
require.NoError(t, err)
|
||||
|
||||
gist2 := db.GistDTO{
|
||||
Title: "public-gist",
|
||||
Description: "my public gist",
|
||||
VisibilityDTO: db.VisibilityDTO{
|
||||
Private: db.PublicVisibility,
|
||||
},
|
||||
Name: []string{"public.txt"},
|
||||
Content: []string{"public content"},
|
||||
}
|
||||
err = s.Request("POST", "/", gist2, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist2db, err := db.GetGistByID("2")
|
||||
require.NoError(t, err)
|
||||
|
||||
token := &db.AccessToken{
|
||||
Name: "read-token",
|
||||
UserID: 1,
|
||||
ScopeGist: db.ReadPermission,
|
||||
}
|
||||
plainToken, err := token.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = token.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.Request("PUT", "/admin-panel/set-config", settingSet{"require-login", "1"}, 200)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.sessionCookie = ""
|
||||
|
||||
err = s.Request("GET", "/thomas/"+gist1db.Uuid, nil, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.Request("GET", "/thomas/"+gist2db.Uuid, nil, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
headers := map[string]string{"Authorization": "Token " + plainToken}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist2db.Uuid, nil, 200, headers)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+"/raw/HEAD/file.txt", nil, 200, headers)
|
||||
require.NoError(t, err)
|
||||
|
||||
invalidHeaders := map[string]string{"Authorization": "Token invalid_token"}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 302, invalidHeaders)
|
||||
require.NoError(t, err)
|
||||
|
||||
noPermToken := &db.AccessToken{
|
||||
Name: "no-perm-token",
|
||||
UserID: 1,
|
||||
ScopeGist: db.NoPermission,
|
||||
}
|
||||
noPermPlain, err := noPermToken.GenerateToken()
|
||||
require.NoError(t, err)
|
||||
err = noPermToken.Create()
|
||||
require.NoError(t, err)
|
||||
|
||||
noPermHeaders := map[string]string{"Authorization": "Token " + noPermPlain}
|
||||
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 302, noPermHeaders)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -41,22 +41,12 @@ var (
|
||||
// - Total number of SSH keys
|
||||
//
|
||||
// The test follows these steps:
|
||||
// 1. Enables metrics via environment variable
|
||||
// 2. Sets up test environment
|
||||
// 3. Registers and logs in an admin user
|
||||
// 4. Creates a gist and adds an SSH key
|
||||
// 5. Queries the metrics endpoint
|
||||
// 6. Verifies the reported metrics match expected values
|
||||
//
|
||||
// Environment variables:
|
||||
// - OG_METRICS_ENABLED: Set to "true" for this test
|
||||
// 1. Sets up test environment
|
||||
// 2. Registers and logs in an admin user
|
||||
// 3. Creates a gist and adds an SSH key
|
||||
// 4. Creates a metrics server and queries the /metrics endpoint
|
||||
// 5. Verifies the reported metrics match expected values
|
||||
func TestMetrics(t *testing.T) {
|
||||
originalValue := os.Getenv("OG_METRICS_ENABLED")
|
||||
|
||||
os.Setenv("OG_METRICS_ENABLED", "true")
|
||||
|
||||
defer os.Setenv("OG_METRICS_ENABLED", originalValue)
|
||||
|
||||
s := Setup(t)
|
||||
defer Teardown(t, s)
|
||||
|
||||
@@ -72,12 +62,16 @@ func TestMetrics(t *testing.T) {
|
||||
err = s.Request("POST", "/settings/ssh-keys", SSHKey, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
var metricsRes http.Response
|
||||
err = s.Request("GET", "/metrics", nil, 200, &metricsRes)
|
||||
require.NoError(t, err)
|
||||
// Create a metrics server and query it
|
||||
metricsServer := NewTestMetricsServer()
|
||||
|
||||
body, err := io.ReadAll(metricsRes.Body)
|
||||
defer metricsRes.Body.Close()
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
metricsServer.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, 200, w.Code)
|
||||
|
||||
body, err := io.ReadAll(w.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
lines := strings.Split(string(body), "\n")
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers/metrics"
|
||||
"github.com/thomiceli/opengist/internal/web/server"
|
||||
)
|
||||
|
||||
@@ -50,6 +51,10 @@ func (s *TestServer) stop() {
|
||||
}
|
||||
|
||||
func (s *TestServer) Request(method, uri string, data interface{}, expectedCode int, responsePtr ...*http.Response) error {
|
||||
return s.RequestWithHeaders(method, uri, data, expectedCode, nil, responsePtr...)
|
||||
}
|
||||
|
||||
func (s *TestServer) RequestWithHeaders(method, uri string, data interface{}, expectedCode int, headers map[string]string, responsePtr ...*http.Response) error {
|
||||
var bodyReader io.Reader
|
||||
if method == http.MethodPost || method == http.MethodPut {
|
||||
values := structToURLValues(data)
|
||||
@@ -63,6 +68,10 @@ func (s *TestServer) Request(method, uri string, data interface{}, expectedCode
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
if s.sessionCookie != "" {
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: s.sessionCookie})
|
||||
}
|
||||
@@ -119,6 +128,9 @@ func structToURLValues(s interface{}) url.Values {
|
||||
if field.Type.Kind() == reflect.Int {
|
||||
fieldValue := rValue.Field(i).Int()
|
||||
v.Add(tag, strconv.FormatInt(fieldValue, 10))
|
||||
} else if field.Type.Kind() == reflect.Uint {
|
||||
fieldValue := rValue.Field(i).Uint()
|
||||
v.Add(tag, strconv.FormatUint(fieldValue, 10))
|
||||
} else if field.Type.Kind() == reflect.Slice {
|
||||
fieldValue := rValue.Field(i).Interface().([]string)
|
||||
for _, va := range fieldValue {
|
||||
@@ -240,3 +252,7 @@ type invitationAdmin struct {
|
||||
nbMax string `form:"nbMax"`
|
||||
expiredAtUnix string `form:"expiredAtUnix"`
|
||||
}
|
||||
|
||||
func NewTestMetricsServer() *metrics.Server {
|
||||
return metrics.NewServer()
|
||||
}
|
||||
|
||||
6708
package-lock.json
generated
6708
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -9,34 +9,27 @@
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@codemirror/commands": "^6.2.2",
|
||||
"@codemirror/lang-javascript": "^6.1.4",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/state": "^6.2.0",
|
||||
"@catppuccin/highlightjs": "^1.0.1",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/text": "^0.19.6",
|
||||
"@codemirror/view": "^6.9.3",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"codemirror": "^6.0.1",
|
||||
"cssnano": "^5.1.15",
|
||||
"github-markdown-css": "^5.5.0",
|
||||
"@codemirror/view": "^6.39.11",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"codemirror": "^6.0.2",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jdenticon": "^3.3.0",
|
||||
"katex": "^0.16.22",
|
||||
"nodemon": "^2.0.22",
|
||||
"katex": "^0.16.28",
|
||||
"marked": "^17.0.1",
|
||||
"nodemon": "^3.1.11",
|
||||
"pdfobject": "^2.3.1",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-cssnext": "^3.1.1",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-loader": "^7.1.0",
|
||||
"postcss-selector-namespace": "^3.0.1",
|
||||
"sass": "^1.62.1",
|
||||
"showdown": "^2.1.0",
|
||||
"sugarss": "^4.0.1",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vite": "^4.5.3"
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
74
public/catppuccin-latte.css
vendored
74
public/catppuccin-latte.css
vendored
@@ -1,74 +0,0 @@
|
||||
.chroma:not(.markdown) { color: #4c4f69 }
|
||||
/* Error */ .chroma .err { color: #d20f39 }
|
||||
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
|
||||
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
|
||||
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
|
||||
/* LineHighlight */ .chroma .hl { color: #bcc0cc }
|
||||
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 }
|
||||
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 }
|
||||
/* Line */ .chroma .line { display: flex; }
|
||||
/* Keyword */ .chroma .k { color: #8839ef }
|
||||
/* KeywordConstant */ .chroma .kc { color: #fe640b }
|
||||
/* KeywordDeclaration */ .chroma .kd { color: #d20f39 }
|
||||
/* KeywordNamespace */ .chroma .kn { color: #179299 }
|
||||
/* KeywordPseudo */ .chroma .kp { color: #8839ef }
|
||||
/* KeywordReserved */ .chroma .kr { color: #8839ef }
|
||||
/* KeywordType */ .chroma .kt { color: #d20f39 }
|
||||
/* NameAttribute */ .chroma .na { color: #1e66f5 }
|
||||
/* NameBuiltin */ .chroma .nb { color: #04a5e5 }
|
||||
/* NameBuiltinPseudo */ .chroma .bp { color: #04a5e5 }
|
||||
/* NameClass */ .chroma .nc { color: #df8e1d }
|
||||
/* NameConstant */ .chroma .no { color: #df8e1d }
|
||||
/* NameDecorator */ .chroma .nd { color: #1e66f5; font-weight: bold }
|
||||
/* NameEntity */ .chroma .ni { color: #179299 }
|
||||
/* NameException */ .chroma .ne { color: #fe640b }
|
||||
/* NameFunction */ .chroma .nf { color: #1e66f5 }
|
||||
/* NameFunctionMagic */ .chroma .fm { color: #1e66f5 }
|
||||
/* NameLabel */ .chroma .nl { color: #04a5e5 }
|
||||
/* NameNamespace */ .chroma .nn { color: #fe640b }
|
||||
/* NameProperty */ .chroma .py { color: #fe640b }
|
||||
/* NameTag */ .chroma .nt { color: #8839ef }
|
||||
/* NameVariable */ .chroma .nv { color: #dc8a78 }
|
||||
/* NameVariableClass */ .chroma .vc { color: #dc8a78 }
|
||||
/* NameVariableGlobal */ .chroma .vg { color: #dc8a78 }
|
||||
/* NameVariableInstance */ .chroma .vi { color: #dc8a78 }
|
||||
/* NameVariableMagic */ .chroma .vm { color: #dc8a78 }
|
||||
/* LiteralString */ .chroma .s { color: #40a02b }
|
||||
/* LiteralStringAffix */ .chroma .sa { color: #d20f39 }
|
||||
/* LiteralStringBacktick */ .chroma .sb { color: #40a02b }
|
||||
/* LiteralStringChar */ .chroma .sc { color: #40a02b }
|
||||
/* LiteralStringDelimiter */ .chroma .dl { color: #1e66f5 }
|
||||
/* LiteralStringDoc */ .chroma .sd { color: #9ca0b0 }
|
||||
/* LiteralStringDouble */ .chroma .s2 { color: #40a02b }
|
||||
/* LiteralStringEscape */ .chroma .se { color: #1e66f5 }
|
||||
/* LiteralStringHeredoc */ .chroma .sh { color: #9ca0b0 }
|
||||
/* LiteralStringInterpol */ .chroma .si { color: #40a02b }
|
||||
/* LiteralStringOther */ .chroma .sx { color: #40a02b }
|
||||
/* LiteralStringRegex */ .chroma .sr { color: #179299 }
|
||||
/* LiteralStringSingle */ .chroma .s1 { color: #40a02b }
|
||||
/* LiteralStringSymbol */ .chroma .ss { color: #40a02b }
|
||||
/* LiteralNumber */ .chroma .m { color: #fe640b }
|
||||
/* LiteralNumberBin */ .chroma .mb { color: #fe640b }
|
||||
/* LiteralNumberFloat */ .chroma .mf { color: #fe640b }
|
||||
/* LiteralNumberHex */ .chroma .mh { color: #fe640b }
|
||||
/* LiteralNumberInteger */ .chroma .mi { color: #fe640b }
|
||||
/* LiteralNumberIntegerLong */ .chroma .il { color: #fe640b }
|
||||
/* LiteralNumberOct */ .chroma .mo { color: #fe640b }
|
||||
/* Operator */ .chroma .o { color: #04a5e5; font-weight: bold }
|
||||
/* OperatorWord */ .chroma .ow { color: #04a5e5; font-weight: bold }
|
||||
/* Comment */ .chroma .c { color: #9ca0b0; font-style: italic }
|
||||
/* CommentHashbang */ .chroma .ch { color: #9ca0b0; font-style: italic }
|
||||
/* CommentMultiline */ .chroma .cm { color: #9ca0b0; font-style: italic }
|
||||
/* CommentSingle */ .chroma .c1 { color: #9ca0b0; font-style: italic }
|
||||
/* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic }
|
||||
/* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic }
|
||||
/* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic }
|
||||
/* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: #ccd0da }
|
||||
/* GenericEmph */ .chroma .ge { font-style: italic }
|
||||
/* GenericError */ .chroma .gr { color: #d20f39 }
|
||||
/* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold }
|
||||
/* GenericInserted */ .chroma .gi { color: #40a02b; background-color: #ccd0da }
|
||||
/* GenericStrong */ .chroma .gs { font-weight: bold }
|
||||
/* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold }
|
||||
/* GenericTraceback */ .chroma .gt { color: #d20f39 }
|
||||
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
|
||||
74
public/catppuccin-macchiato.css
vendored
74
public/catppuccin-macchiato.css
vendored
@@ -1,74 +0,0 @@
|
||||
.chroma:not(.markdown) { color: #cad3f5 }
|
||||
/* Error */ .chroma .err { color: #f38ba8 }
|
||||
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
|
||||
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
|
||||
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
|
||||
/* LineHighlight */ .chroma .hl { color: #45475a }
|
||||
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f849c }
|
||||
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f849c }
|
||||
/* Line */ .chroma .line { display: flex; }
|
||||
/* Keyword */ .chroma .k { color: #cba6f7 }
|
||||
/* KeywordConstant */ .chroma .kc { color: #fab387 }
|
||||
/* KeywordDeclaration */ .chroma .kd { color: #f38ba8 }
|
||||
/* KeywordNamespace */ .chroma .kn { color: #94e2d5 }
|
||||
/* KeywordPseudo */ .chroma .kp { color: #cba6f7 }
|
||||
/* KeywordReserved */ .chroma .kr { color: #cba6f7 }
|
||||
/* KeywordType */ .chroma .kt { color: #f38ba8 }
|
||||
/* NameAttribute */ .chroma .na { color: #89b4fa }
|
||||
/* NameBuiltin */ .chroma .nb { color: #89dceb }
|
||||
/* NameBuiltinPseudo */ .chroma .bp { color: #89dceb }
|
||||
/* NameClass */ .chroma .nc { color: #f9e2af }
|
||||
/* NameConstant */ .chroma .no { color: #f9e2af }
|
||||
/* NameDecorator */ .chroma .nd { color: #89b4fa; font-weight: bold }
|
||||
/* NameEntity */ .chroma .ni { color: #94e2d5 }
|
||||
/* NameException */ .chroma .ne { color: #fab387 }
|
||||
/* NameFunction */ .chroma .nf { color: #89b4fa }
|
||||
/* NameFunctionMagic */ .chroma .fm { color: #89b4fa }
|
||||
/* NameLabel */ .chroma .nl { color: #89dceb }
|
||||
/* NameNamespace */ .chroma .nn { color: #fab387 }
|
||||
/* NameProperty */ .chroma .py { color: #fab387 }
|
||||
/* NameTag */ .chroma .nt { color: #cba6f7 }
|
||||
/* NameVariable */ .chroma .nv { color: #f5e0dc }
|
||||
/* NameVariableClass */ .chroma .vc { color: #f5e0dc }
|
||||
/* NameVariableGlobal */ .chroma .vg { color: #f5e0dc }
|
||||
/* NameVariableInstance */ .chroma .vi { color: #f5e0dc }
|
||||
/* NameVariableMagic */ .chroma .vm { color: #f5e0dc }
|
||||
/* LiteralString */ .chroma .s { color: #a6e3a1 }
|
||||
/* LiteralStringAffix */ .chroma .sa { color: #f38ba8 }
|
||||
/* LiteralStringBacktick */ .chroma .sb { color: #a6e3a1 }
|
||||
/* LiteralStringChar */ .chroma .sc { color: #a6e3a1 }
|
||||
/* LiteralStringDelimiter */ .chroma .dl { color: #89b4fa }
|
||||
/* LiteralStringDoc */ .chroma .sd { color: #6c7086 }
|
||||
/* LiteralStringDouble */ .chroma .s2 { color: #a6e3a1 }
|
||||
/* LiteralStringEscape */ .chroma .se { color: #89b4fa }
|
||||
/* LiteralStringHeredoc */ .chroma .sh { color: #6c7086 }
|
||||
/* LiteralStringInterpol */ .chroma .si { color: #a6e3a1 }
|
||||
/* LiteralStringOther */ .chroma .sx { color: #a6e3a1 }
|
||||
/* LiteralStringRegex */ .chroma .sr { color: #94e2d5 }
|
||||
/* LiteralStringSingle */ .chroma .s1 { color: #a6e3a1 }
|
||||
/* LiteralStringSymbol */ .chroma .ss { color: #a6e3a1 }
|
||||
/* LiteralNumber */ .chroma .m { color: #fab387 }
|
||||
/* LiteralNumberBin */ .chroma .mb { color: #fab387 }
|
||||
/* LiteralNumberFloat */ .chroma .mf { color: #fab387 }
|
||||
/* LiteralNumberHex */ .chroma .mh { color: #fab387 }
|
||||
/* LiteralNumberInteger */ .chroma .mi { color: #fab387 }
|
||||
/* LiteralNumberIntegerLong */ .chroma .il { color: #fab387 }
|
||||
/* LiteralNumberOct */ .chroma .mo { color: #fab387 }
|
||||
/* Operator */ .chroma .o { color: #89dceb; font-weight: bold }
|
||||
/* OperatorWord */ .chroma .ow { color: #89dceb; font-weight: bold }
|
||||
/* Comment */ .chroma .c { color: #6c7086; font-style: italic }
|
||||
/* CommentHashbang */ .chroma .ch { color: #6c7086; font-style: italic }
|
||||
/* CommentMultiline */ .chroma .cm { color: #6c7086; font-style: italic }
|
||||
/* CommentSingle */ .chroma .c1 { color: #6c7086; font-style: italic }
|
||||
/* CommentSpecial */ .chroma .cs { color: #6c7086; font-style: italic }
|
||||
/* CommentPreproc */ .chroma .cp { color: #6c7086; font-style: italic }
|
||||
/* CommentPreprocFile */ .chroma .cpf { color: #6c7086; font-weight: bold; font-style: italic }
|
||||
/* GenericDeleted */ .chroma .gd { color: #f38ba8; background-color: #313244 }
|
||||
/* GenericEmph */ .chroma .ge { font-style: italic }
|
||||
/* GenericError */ .chroma .gr { color: #f38ba8 }
|
||||
/* GenericHeading */ .chroma .gh { color: #fab387; font-weight: bold }
|
||||
/* GenericInserted */ .chroma .gi { color: #a6e3a1; background-color: #313244 }
|
||||
/* GenericStrong */ .chroma .gs { font-weight: bold }
|
||||
/* GenericSubheading */ .chroma .gu { color: #fab387; font-weight: bold }
|
||||
/* GenericTraceback */ .chroma .gt { color: #f38ba8 }
|
||||
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
|
||||
2
public/css/auto.css
vendored
Normal file
2
public/css/auto.css
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "./light.css" (prefers-color-scheme: light);
|
||||
@import "./dark.css" (prefers-color-scheme: dark);
|
||||
251
public/css/catppuccin-latte.css
vendored
Normal file
251
public/css/catppuccin-latte.css
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
.chroma .cl {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.chroma .err {
|
||||
color: #d20f39;
|
||||
}
|
||||
.chroma .x {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.chroma .hl {
|
||||
background-color: #bcc0cc;
|
||||
}
|
||||
.chroma .lnt {
|
||||
color: #8c8fa1;
|
||||
}
|
||||
.chroma .ln {
|
||||
color: #8c8fa1;
|
||||
}
|
||||
.chroma .k {
|
||||
color: #8839ef;
|
||||
}
|
||||
.chroma .kr {
|
||||
color: #8839ef;
|
||||
}
|
||||
.chroma .kp {
|
||||
color: #8839ef;
|
||||
}
|
||||
.chroma .kc {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .kd {
|
||||
color: #d20f39;
|
||||
}
|
||||
.chroma .kn {
|
||||
color: #179299;
|
||||
}
|
||||
.chroma .kt {
|
||||
color: #d20f39;
|
||||
}
|
||||
.chroma .n {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.chroma .nc {
|
||||
color: #df8e1d;
|
||||
}
|
||||
.chroma .no {
|
||||
color: #df8e1d;
|
||||
}
|
||||
.chroma .nd {
|
||||
color: #1e66f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .ni {
|
||||
color: #179299;
|
||||
}
|
||||
.chroma .ne {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .nf {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.chroma .fm {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.chroma .nl {
|
||||
color: #04a5e5;
|
||||
}
|
||||
.chroma .nn {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .py {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .nt {
|
||||
color: #8839ef;
|
||||
}
|
||||
.chroma .nv {
|
||||
color: #dc8a78;
|
||||
}
|
||||
.chroma .vc {
|
||||
color: #dc8a78;
|
||||
}
|
||||
.chroma .vg {
|
||||
color: #dc8a78;
|
||||
}
|
||||
.chroma .vi {
|
||||
color: #dc8a78;
|
||||
}
|
||||
.chroma .vm {
|
||||
color: #dc8a78;
|
||||
}
|
||||
.chroma .na {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.chroma .nb {
|
||||
color: #04a5e5;
|
||||
}
|
||||
.chroma .bp {
|
||||
color: #04a5e5;
|
||||
}
|
||||
.chroma .nx {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.chroma .l {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.chroma .ld {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.chroma .s {
|
||||
color: #40a02b;
|
||||
}
|
||||
.chroma .sc {
|
||||
color: #40a02b;
|
||||
}
|
||||
.chroma .s1 {
|
||||
color: #40a02b;
|
||||
}
|
||||
.chroma .s2 {
|
||||
color: #40a02b;
|
||||
}
|
||||
.chroma .sb {
|
||||
color: #40a02b;
|
||||
}
|
||||
.chroma .sx {
|
||||
color: #40a02b;
|
||||
}
|
||||
.chroma .ss {
|
||||
color: #40a02b;
|
||||
}
|
||||
.chroma .si {
|
||||
color: #40a02b;
|
||||
}
|
||||
.chroma .sa {
|
||||
color: #d20f39;
|
||||
}
|
||||
.chroma .dl {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.chroma .se {
|
||||
color: #1e66f5;
|
||||
}
|
||||
.chroma .sr {
|
||||
color: #179299;
|
||||
}
|
||||
.chroma .sd {
|
||||
color: #9ca0b0;
|
||||
}
|
||||
.chroma .sh {
|
||||
color: #9ca0b0;
|
||||
}
|
||||
.chroma .m {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .mb {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .mh {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .mi {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .mf {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .il {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .mo {
|
||||
color: #fe640b;
|
||||
}
|
||||
.chroma .o {
|
||||
color: #04a5e5;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .ow {
|
||||
color: #04a5e5;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .c {
|
||||
color: #9ca0b0;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .c1 {
|
||||
color: #9ca0b0;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .cm {
|
||||
color: #9ca0b0;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .cs {
|
||||
color: #9ca0b0;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .ch {
|
||||
color: #acb0be;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .cp {
|
||||
color: #9ca0b0;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .cpf {
|
||||
color: #9ca0b0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .g {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.chroma .gi {
|
||||
color: #40a02b;
|
||||
background-color: #ccd0da;
|
||||
}
|
||||
.chroma .gd {
|
||||
color: #d20f39;
|
||||
background-color: #ccd0da;
|
||||
}
|
||||
.chroma .ge {
|
||||
color: #4c4f69;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .gs {
|
||||
color: #4c4f69;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .gl {
|
||||
color: #4c4f69;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chroma .gh {
|
||||
color: #fe640b;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .gu {
|
||||
color: #fe640b;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .go {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.chroma .gp {
|
||||
color: #4c4f69;
|
||||
}
|
||||
.chroma .gr {
|
||||
color: #d20f39;
|
||||
}
|
||||
.chroma .gt {
|
||||
color: #d20f39;
|
||||
}
|
||||
251
public/css/catppuccin-macchiato.css
vendored
Normal file
251
public/css/catppuccin-macchiato.css
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
.chroma .cl {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.chroma .err {
|
||||
color: #ed8796;
|
||||
}
|
||||
.chroma .x {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.chroma .hl {
|
||||
background-color: #494d64;
|
||||
}
|
||||
.chroma .lnt {
|
||||
color: #8087a2;
|
||||
}
|
||||
.chroma .ln {
|
||||
color: #8087a2;
|
||||
}
|
||||
.chroma .k {
|
||||
color: #c6a0f6;
|
||||
}
|
||||
.chroma .kr {
|
||||
color: #c6a0f6;
|
||||
}
|
||||
.chroma .kp {
|
||||
color: #c6a0f6;
|
||||
}
|
||||
.chroma .kc {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .kd {
|
||||
color: #ed8796;
|
||||
}
|
||||
.chroma .kn {
|
||||
color: #8bd5ca;
|
||||
}
|
||||
.chroma .kt {
|
||||
color: #ed8796;
|
||||
}
|
||||
.chroma .n {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.chroma .nc {
|
||||
color: #eed49f;
|
||||
}
|
||||
.chroma .no {
|
||||
color: #eed49f;
|
||||
}
|
||||
.chroma .nd {
|
||||
color: #8aadf4;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .ni {
|
||||
color: #8bd5ca;
|
||||
}
|
||||
.chroma .ne {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .nf {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.chroma .fm {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.chroma .nl {
|
||||
color: #91d7e3;
|
||||
}
|
||||
.chroma .nn {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .py {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .nt {
|
||||
color: #c6a0f6;
|
||||
}
|
||||
.chroma .nv {
|
||||
color: #f4dbd6;
|
||||
}
|
||||
.chroma .vc {
|
||||
color: #f4dbd6;
|
||||
}
|
||||
.chroma .vg {
|
||||
color: #f4dbd6;
|
||||
}
|
||||
.chroma .vi {
|
||||
color: #f4dbd6;
|
||||
}
|
||||
.chroma .vm {
|
||||
color: #f4dbd6;
|
||||
}
|
||||
.chroma .na {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.chroma .nb {
|
||||
color: #91d7e3;
|
||||
}
|
||||
.chroma .bp {
|
||||
color: #91d7e3;
|
||||
}
|
||||
.chroma .nx {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.chroma .l {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.chroma .ld {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.chroma .s {
|
||||
color: #a6da95;
|
||||
}
|
||||
.chroma .sc {
|
||||
color: #a6da95;
|
||||
}
|
||||
.chroma .s1 {
|
||||
color: #a6da95;
|
||||
}
|
||||
.chroma .s2 {
|
||||
color: #a6da95;
|
||||
}
|
||||
.chroma .sb {
|
||||
color: #a6da95;
|
||||
}
|
||||
.chroma .sx {
|
||||
color: #a6da95;
|
||||
}
|
||||
.chroma .ss {
|
||||
color: #a6da95;
|
||||
}
|
||||
.chroma .si {
|
||||
color: #a6da95;
|
||||
}
|
||||
.chroma .sa {
|
||||
color: #ed8796;
|
||||
}
|
||||
.chroma .dl {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.chroma .se {
|
||||
color: #8aadf4;
|
||||
}
|
||||
.chroma .sr {
|
||||
color: #8bd5ca;
|
||||
}
|
||||
.chroma .sd {
|
||||
color: #6e738d;
|
||||
}
|
||||
.chroma .sh {
|
||||
color: #6e738d;
|
||||
}
|
||||
.chroma .m {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .mb {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .mh {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .mi {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .mf {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .il {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .mo {
|
||||
color: #f5a97f;
|
||||
}
|
||||
.chroma .o {
|
||||
color: #91d7e3;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .ow {
|
||||
color: #91d7e3;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .c {
|
||||
color: #6e738d;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .c1 {
|
||||
color: #6e738d;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .cm {
|
||||
color: #6e738d;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .cs {
|
||||
color: #6e738d;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .ch {
|
||||
color: #5b6078;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .cp {
|
||||
color: #6e738d;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .cpf {
|
||||
color: #6e738d;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .g {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.chroma .gi {
|
||||
color: #a6da95;
|
||||
background-color: #363a4f;
|
||||
}
|
||||
.chroma .gd {
|
||||
color: #ed8796;
|
||||
background-color: #363a4f;
|
||||
}
|
||||
.chroma .ge {
|
||||
color: #cad3f5;
|
||||
font-style: italic;
|
||||
}
|
||||
.chroma .gs {
|
||||
color: #cad3f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .gl {
|
||||
color: #cad3f5;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chroma .gh {
|
||||
color: #f5a97f;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .gu {
|
||||
color: #f5a97f;
|
||||
font-weight: bold;
|
||||
}
|
||||
.chroma .go {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.chroma .gp {
|
||||
color: #cad3f5;
|
||||
}
|
||||
.chroma .gr {
|
||||
color: #ed8796;
|
||||
}
|
||||
.chroma .gt {
|
||||
color: #ed8796;
|
||||
}
|
||||
4
public/css/dark.css
vendored
Normal file
4
public/css/dark.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "highlight.js/styles/github-dark.css";
|
||||
@import "@catppuccin/highlightjs/css/catppuccin-macchiato.css";
|
||||
@import "github-markdown-css/github-markdown-dark.css";
|
||||
@import './catppuccin-macchiato.css';
|
||||
53
public/css/embed.css
vendored
Normal file
53
public/css/embed.css
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@source "../../templates/pages/gist_embed.html";
|
||||
|
||||
@theme {
|
||||
/* Custom gray palette */
|
||||
--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;
|
||||
|
||||
/* Primary color palette */
|
||||
--color-primary-50: #d6e1ff;
|
||||
--color-primary-100: #d1dfff;
|
||||
--color-primary-200: #b9d2fe;
|
||||
--color-primary-300: #84b1fb;
|
||||
--color-primary-400: #74a4f6;
|
||||
--color-primary-500: #588fee;
|
||||
--color-primary-600: #3c79e2;
|
||||
--color-primary-700: #356fc0;
|
||||
--color-primary-800: #2b5da3;
|
||||
--color-primary-900: #1f4b8c;
|
||||
--color-primary-950: #192b57;
|
||||
|
||||
/* Border width extension */
|
||||
--border-width-1: 1px;
|
||||
}
|
||||
|
||||
.opengist-embed {
|
||||
@import "tailwindcss";
|
||||
@layer base {
|
||||
ul, ol {
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@import './ipynb.css';
|
||||
@import "./style.css";
|
||||
}
|
||||
}
|
||||
2
public/ipynb.css → public/css/ipynb.css
vendored
2
public/ipynb.css → public/css/ipynb.css
vendored
@@ -1,3 +1,5 @@
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
.jupyter.notebook {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
4
public/css/light.css
vendored
Normal file
4
public/css/light.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "highlight.js/styles/github.min.css";
|
||||
@import "@catppuccin/highlightjs/css/catppuccin-latte.css";
|
||||
@import "github-markdown-css/github-markdown.css";
|
||||
@import './catppuccin-latte.css';
|
||||
62
public/style.css → public/css/style.css
vendored
62
public/style.css → public/css/style.css
vendored
@@ -1,15 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@config "./tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
ul, ol {
|
||||
list-style: revert;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--red-diff: rgba(255, 0, 0, .1);
|
||||
--green-diff: rgba(0, 255, 128, .1);
|
||||
@@ -20,10 +8,6 @@ html {
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-primary-500;
|
||||
}
|
||||
|
||||
p a:hover, h1 a:hover, h2 a:hover, h3 a:hover, h4 a:hover, h5 a:hover, h6 a:hover {
|
||||
@apply underline;
|
||||
}
|
||||
@@ -33,7 +17,7 @@ input {
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"], pre[class*="language-"] {
|
||||
@apply bg-white dark:bg-gray-900 mt-1 pt-1 !important;
|
||||
@apply bg-white dark:bg-gray-900 mt-1 pt-1;
|
||||
}
|
||||
|
||||
pre {
|
||||
@@ -50,6 +34,11 @@ pre {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
|
||||
.red-diff {
|
||||
background-color: var(--red-diff);
|
||||
}
|
||||
@@ -60,24 +49,23 @@ pre {
|
||||
|
||||
.gray-diff {
|
||||
background-color: var(--git-diff);
|
||||
@apply py-4 !important
|
||||
@apply py-4;
|
||||
}
|
||||
|
||||
#logged-button:hover .username {
|
||||
@apply hidden !important
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
#logged-button:hover .logout {
|
||||
@apply block !important
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.cm-line, .cm-gutter {
|
||||
@apply bg-white dark:bg-gray-900 dark:caret-white caret-slate-700 !important;
|
||||
padding: 0 !important;
|
||||
@apply bg-white dark:bg-gray-900 dark:caret-white caret-slate-700 px-1;
|
||||
}
|
||||
|
||||
.cm-activeLine, .cm-activeLineGutter {
|
||||
@apply bg-gray-50 dark:bg-gray-800 !important;
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
@@ -85,7 +73,7 @@ pre {
|
||||
}
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply text-gray-700 dark:text-gray-300 px-4 !important
|
||||
@apply text-gray-700 dark:text-gray-300 px-4;
|
||||
}
|
||||
|
||||
.code td {
|
||||
@@ -104,7 +92,10 @@ pre {
|
||||
|
||||
.cm-editor {
|
||||
height: 337px;
|
||||
max-height: 337px;
|
||||
min-height: 100px;
|
||||
max-height: none;
|
||||
resize: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-code.selected {
|
||||
@@ -118,7 +109,7 @@ pre {
|
||||
}
|
||||
|
||||
.line-code {
|
||||
@apply pl-2;
|
||||
@apply pl-2 dark:text-slate-300 text-slate-700;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
@@ -159,27 +150,27 @@ dl.dl-config dd {
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
@apply dark:bg-gray-900 !important;
|
||||
@apply dark:bg-gray-900;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
@apply flex relative items-start p-0 !important;
|
||||
@apply flex relative items-start p-0;
|
||||
}
|
||||
|
||||
.markdown-body .code-div {
|
||||
@apply p-4 max-w-full overflow-x-auto !important;
|
||||
@apply p-4 max-w-full overflow-x-auto;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
@apply overflow-auto whitespace-pre !important;
|
||||
@apply overflow-auto whitespace-pre;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
@apply bg-transparent dark:bg-transparent !important;
|
||||
@apply bg-transparent dark:bg-transparent;
|
||||
}
|
||||
|
||||
.chroma.preview.markdown pre code {
|
||||
@apply p-4 !important;
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.mermaid {
|
||||
@@ -187,7 +178,7 @@ dl.dl-config dd {
|
||||
}
|
||||
|
||||
.hidden-important {
|
||||
@apply hidden !important;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -196,10 +187,9 @@ dl.dl-config dd {
|
||||
* Jupyter usually outputs images with transparent or light backgrounds.
|
||||
*/
|
||||
.dark .jupyter-output img {
|
||||
background-color: #888;
|
||||
background-color: #888;
|
||||
}
|
||||
|
||||
|
||||
.pdfobject-container {
|
||||
@apply min-h-[700px] h-[700px] !important;
|
||||
@apply min-h-[700px] h-[700px];
|
||||
}
|
||||
57
public/css/tailwind.css
vendored
Normal file
57
public/css/tailwind.css
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
@import "tailwindcss";
|
||||
@import './ipynb.css';
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@source "../../templates/**/*.html";
|
||||
|
||||
@theme {
|
||||
/* Custom gray palette */
|
||||
--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;
|
||||
|
||||
/* Primary color palette */
|
||||
--color-primary-50: #d6e1ff;
|
||||
--color-primary-100: #d1dfff;
|
||||
--color-primary-200: #b9d2fe;
|
||||
--color-primary-300: #84b1fb;
|
||||
--color-primary-400: #74a4f6;
|
||||
--color-primary-500: #588fee;
|
||||
--color-primary-600: #3c79e2;
|
||||
--color-primary-700: #356fc0;
|
||||
--color-primary-800: #2b5da3;
|
||||
--color-primary-900: #1f4b8c;
|
||||
--color-primary-950: #192b57;
|
||||
|
||||
/* Border width extension */
|
||||
--border-width-1: 1px;
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
ul, ol {
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-primary-500;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@import "./style.css";
|
||||
}
|
||||
116
public/embed.scss
vendored
116
public/embed.scss
vendored
@@ -1,116 +0,0 @@
|
||||
@import "github-markdown-css/github-markdown-light";
|
||||
@import './catppuccin-latte';
|
||||
|
||||
|
||||
.dark {
|
||||
@import "github-markdown-css/github-markdown-dark";
|
||||
@import './catppuccin-macchiato';
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@config "./tailwind-embed.config.js";
|
||||
|
||||
.html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-feature-settings: normal;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||
font-variation-settings: normal;
|
||||
line-height: 1.5;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: Menlo, Consolas, Liberation Mono, monospace;
|
||||
}
|
||||
|
||||
.code .line-num {
|
||||
width: 4%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.code td {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.code tbody {
|
||||
line-height: 18.2px;
|
||||
}
|
||||
|
||||
.line-code {
|
||||
@apply pl-2;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.line-num {
|
||||
@apply cursor-pointer text-slate-600 dark:text-slate-400 hover:text-black dark:hover:text-white;
|
||||
}
|
||||
|
||||
table.csv-table {
|
||||
@apply w-full whitespace-pre text-xs text-slate-300;
|
||||
}
|
||||
|
||||
table.csv-table thead {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table.csv-table thead tr {
|
||||
@apply bg-slate-100 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
table.csv-table tbody tr {
|
||||
@apply bg-gray-500 dark:bg-gray-900;
|
||||
}
|
||||
|
||||
table.csv-table thead tr th {
|
||||
@apply border py-2 px-1 border-slate-300 dark:border-slate-700;
|
||||
}
|
||||
|
||||
table.csv-table tbody td {
|
||||
@apply border py-1.5 px-1 border-slate-200 dark:border-slate-800;
|
||||
}
|
||||
|
||||
dl.dl-config {
|
||||
@apply grid grid-cols-3 text-sm;
|
||||
}
|
||||
|
||||
dl.dl-config dt {
|
||||
@apply col-span-1 text-gray-700 dark:text-slate-300 font-bold;
|
||||
}
|
||||
|
||||
dl.dl-config dd {
|
||||
@apply ml-1 col-span-2 break-words;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
@apply dark:bg-gray-900;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
@apply flex relative items-start p-0;
|
||||
}
|
||||
|
||||
.markdown-body .code-div {
|
||||
@apply p-4 max-w-full overflow-x-auto;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
@apply overflow-auto whitespace-pre;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
@apply bg-transparent dark:bg-transparent;
|
||||
}
|
||||
|
||||
.chroma.preview.markdown pre code {
|
||||
@apply p-4;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import "./embed.scss"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user