Compare commits

..

1 Commits

Author SHA1 Message Date
Thomas Miceli
59440faedb Specify log path 2025-06-05 11:18:03 +02:00
165 changed files with 7564 additions and 10582 deletions

View File

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

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20'
@@ -28,16 +28,20 @@ jobs:
npx tailwindcss -i .vitepress/theme/style.css -o .vitepress/theme/theme.css -c .vitepress/tailwind.config.js
npm run docs:build
- 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
- 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 }}

View File

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

View File

@@ -8,10 +8,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
uses: azure/setup-helm@v4.3.0
with:
version: 'latest'
@@ -34,16 +34,20 @@ jobs:
helm repo index --url https://helm.opengist.io --merge index.yaml .
fi
- 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
- 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 }}

View File

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

1
.gitignore vendored
View File

@@ -6,7 +6,6 @@ gist.db
/**/.DS_Store
public/assets/*
public/manifest.json
public/.vite/*
./opengist
opengist
build/

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# Opengist
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg" alt="Opengist" align="right" />
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/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.12
docker pull ghcr.io/thomiceli/opengist:1.10
```
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.12
image: ghcr.io/thomiceli/opengist:1.10
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.12.1/opengist1.12.1-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.10.0/opengist1.10.0-linux-amd64.tar.gz
tar xzvf opengist1.12.1-linux-amd64.tar.gz
tar xzvf opengist1.10.0-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`

View File

@@ -8,6 +8,9 @@ log-level: warn
# Set the log output to one or more of the following: `stdout`, `file`. Default: stdout,file
log-output: stdout,file
# Set the path to the log file.
log-path: /tmp/logaaa
# Public URL to access to Opengist
external-url:
@@ -43,7 +46,6 @@ sqlite.journal-mode: WAL
# HTTP server configuration
# Host to bind to. Default: 0.0.0.0
# Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock)
http.host: 0.0.0.0
# Port to bind to. Default: 6157
@@ -52,18 +54,9 @@ http.port: 6157
# Enable or disable git operations (clone, pull, push) via HTTP (either `true` or `false`). Default: true
http.git-enabled: true
# File permissions for Unix socket (octal format). Default: 0666
unix-socket-permissions: 0666
# Enable or disable the Prometheus metrics server (either `true` or `false`). Default: false
# Enable or disable the metrics endpoint (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)

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,10 @@ Opengist offers built-in support for Prometheus metrics to help you monitor the
## Enabling metrics
By default, the metrics server 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 endpoint 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,25 +16,7 @@ Alternatively, you can use the environment variable:
OG_METRICS_ENABLED=true
```
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
```
Once enabled, metrics are available at the /metrics endpoint.
## Available metrics
@@ -54,6 +36,14 @@ These standard metrics follow the Prometheus naming convention and include label
## Security Considerations
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.
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.
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.
Example with Nginx:
```shell
location /metrics {
auth_basic "Metrics";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://localhost:6157/metrics;
}
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

144
go.mod
View File

@@ -1,125 +1,129 @@
module github.com/thomiceli/opengist
go 1.25.5
go 1.23.0
require (
github.com/Kunde21/markdownfmt/v3 v3.1.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/blevesearch/bleve/v2 v2.5.7
github.com/alecthomas/chroma/v2 v2.16.0
github.com/blevesearch/bleve/v2 v2.5.0
github.com/dustin/go-humanize v1.0.1
github.com/gabriel-vasile/mimetype v1.4.12
github.com/glebarez/sqlite v1.11.0
github.com/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/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/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.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/labstack/echo-contrib v0.17.3
github.com/labstack/echo/v4 v4.13.3
github.com/markbates/goth v1.81.0
github.com/meilisearch/meilisearch-go v0.31.0
github.com/pquerna/otp v1.4.0
github.com/prometheus/client_golang v1.21.1
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.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/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/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/mermaid v0.6.0
golang.org/x/crypto v0.47.0
golang.org/x/text v0.33.0
go.abhg.dev/goldmark/mermaid v0.5.0
golang.org/x/crypto v0.36.0
golang.org/x/text v0.23.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
)
require (
filippo.io/edwards25519 v1.1.0 // 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/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/beorn7/perks v1.0.1 // 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/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/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.2.0 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.4.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/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.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/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/cespare/xxhash/v2 v2.3.0 // 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/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/glebarez/go-sqlite v1.22.0 // 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-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-chi/chi/v5 v5.2.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.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/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/golang/snappy v1.0.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/google/go-tpm v0.9.3 // 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.8.0 // indirect
github.com/jackc/pgx/v5 v5.7.4 // 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.19 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // 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 v1.0.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.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/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/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-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
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
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
modernc.org/memory v1.9.1 // indirect
modernc.org/sqlite v1.37.0 // indirect
)

406
go.sum
View File

@@ -1,81 +1,78 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/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/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.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
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/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.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA=
github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
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/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/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/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.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/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/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.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/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/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.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/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/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/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/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/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-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/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 h1:wMSvdj3BswqfQOXp2R1bJOAE7xIQLt2dlMQDMf836VY=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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/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=
@@ -86,53 +83,57 @@ 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.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/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/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.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-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-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.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/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/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.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/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.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
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/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=
@@ -144,22 +145,23 @@ 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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
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/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=
@@ -178,6 +180,8 @@ 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=
@@ -188,16 +192,18 @@ 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.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/echo-contrib v0.17.3 h1:hj+qXksKZG1scSe9ksUXMtv7fZYN+PtQT+bPcYA3/TY=
github.com/labstack/echo-contrib v0.17.3/go.mod h1:TcRBrzW8jcC4JD+5Dc/pvOyAps0rtgzj7oBqoR3nYsc=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/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/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE=
github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -205,10 +211,12 @@ 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.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/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/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=
@@ -218,23 +226,26 @@ 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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/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.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/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/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=
@@ -243,102 +254,149 @@ 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.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/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/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-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
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/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.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 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-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.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=
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=
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.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=
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=
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.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=
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=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
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/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,11 @@
# Opengist Helm Chart
![Version: 0.6.0](https://img.shields.io/badge/Version-0.6.0-informational?style=flat-square) ![AppVersion: 1.12.1](https://img.shields.io/badge/AppVersion-1.12.1-informational?style=flat-square)
![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![AppVersion: 1.10.0](https://img.shields.io/badge/AppVersion-1.10.0-informational?style=flat-square)
Opengist Helm chart for Kubernetes. Check [CHANGELOG.md](CHANGELOG.md) for release notes.
Opengist Helm chart for Kubernetes.
* [Install](#install)
* [Configuration](#configuration)
* [Metrics & Monitoring](#metrics--monitoring)
* [Dependencies](#dependencies)
* [Meilisearch Indexer](#meilisearch-indexer)
* [PostgreSQL Database](#postgresql-database)
@@ -48,76 +47,6 @@ 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
@@ -137,40 +66,6 @@ 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.
@@ -184,268 +79,3 @@ Then define the connection string in your Opengist config:
db-uri: postgres://user:password@opengist-postgresql:5432/opengist
```
Note: `opengist-postgresql` is the name of the K8S Service deployed by this chart.
### Database Configuration
You can supply an externally managed database connection explicitly via `config.db-uri` (PostgreSQL/MySQL) or enable the bundled PostgreSQL subchart.
Behavior:
* If `postgresql.enabled: true` and `config.db-uri` is omitted, the chart auto-generates:
`postgres://<username>:<password>@<release-name>-postgresql:<port>/<database>` using values under `postgresql.global.postgresql.auth.*`.
* If any of username/password/database are missing, templating fails fast with an error message.
* If you prefer an external database or a different Postgres distribution, set `postgresql.enabled: false` and provide `config.db-uri` yourself.
**Licensing note**: Bitnami's PostgreSQL distribution may have licensing constraints. For strictly open alternatives use an external managed PostgreSQL/MySQL service and disable the subchart.
### Multi-Replica Requirements
Running more than one Opengist replica (Deployment or StatefulSet) requires:
1. Non-SQLite database (`config.db-uri` must start with `postgres://` or `mysql://`).
2. Shared RWX storage if using StatefulSet with `replicaCount > 1` (provide `persistence.existingClaim`). The chart now fails fast if you attempt `replicaCount > 1` without an explicit shared claim to prevent silent data divergence across perpod PVCs.
The chart will fail fast during templating if these conditions are not met when scaling above 1 replica.
Examples:
* External PostgreSQL:
```yaml
postgresql:
enabled: false
config:
db-uri: postgres://user:pass@db-host:5432/opengist
index: meilisearch
statefulSet:
enabled: true
replicaCount: 2
persistence:
existingClaim: opengist-shared-rwx
```
Bundled PostgreSQL (auto db-uri):
```yaml
postgresql:
enabled: true
config:
index: meilisearch
statefulSet:
enabled: true
replicaCount: 2
persistence:
existingClaim: opengist-shared-rwx
```
#### Recovering from an initial misconfiguration
If you previously scaled a StatefulSet above 1 replica **without** an `existingClaim`, each pod received its own PVC and only one held the authoritative `/opengist` data. To consolidate:
1. Scale down to 1 replica (keep the pod with the desired data):
```bash
kubectl scale sts/opengist --replicas=1
```
1. (Optional) Inspect other PVCs and manually copy any missing files by temporarily attaching them to a debug pod.
1. Create or provision a ReadWriteMany (NFS / CephFS / Longhorn RWX / etc.) PersistentVolumeClaim named (for example) `opengist-shared-rwx`.
1. Update values with `persistence.existingClaim: opengist-shared-rwx` and redeploy.
1. Scale back up:
```bash
kubectl scale sts/opengist --replicas=2
```
Going forward, all replicas mount the same shared volume and data remains consistent.
### Quick Start Examples
Common deployment scenarios with copy-paste configurations:
#### Scenario 1: Single replica with SQLite (default)
Minimal local development setup with ephemeral or persistent storage:
```yaml
# Ephemeral (emptyDir)
statefulSet:
enabled: true
replicaCount: 1
persistence:
enabled: false
# OR with persistent RWO storage
statefulSet:
enabled: true
replicaCount: 1
persistence:
enabled: true
mode: perReplica # default
```
#### Scenario 2: Multi-replica with external PostgreSQL + existing RWX PVC
Production HA setup with your own database and storage:
```yaml
statefulSet:
enabled: true
replicaCount: 2
postgresql:
enabled: false
config:
db-uri: "postgres://user:pass@db-host:5432/opengist"
index: meilisearch # required for multi-replica
persistence:
enabled: true
mode: shared
existingClaim: "opengist-shared-rwx" # pre-created RWX PVC
meilisearch:
enabled: true
```
#### Scenario 3: Multi-replica with bundled PostgreSQL + auto-created RWX PVC
Chart manages both database and storage:
```yaml
statefulSet:
enabled: true
replicaCount: 2
postgresql:
enabled: true
global:
postgresql:
auth:
username: opengist
password: changeme
database: opengist
config:
index: meilisearch
persistence:
enabled: true
mode: shared
existingClaim: "" # empty to trigger auto-creation
create:
enabled: true
accessModes: [ReadWriteMany]
storageClass: "nfs-client" # your RWX-capable storage class
size: 20Gi
meilisearch:
enabled: true
```
### Persistence Modes
The chart supports two persistence strategies controlled by `persistence.mode`:
| Mode | Behavior | Scaling | Storage Objects | Recommended Use |
|-------------|----------|---------|-----------------|-----------------|
| `perReplica` (default) | One PVC per pod via StatefulSet `volumeClaimTemplates` (RWO) when no `existingClaim` | Safe only at `replicaCount=1` unless you supply `existingClaim` | One PVC per replica | Local dev, quick single-node trials |
| `shared` | Single RWX PVC (existing or auto-created) mounted by all pods | Horizontally scalable | One shared PVC | Production / HA |
Configuration examples:
Per-replica (single node):
```yaml
statefulSet:
enabled: true
persistence:
mode: perReplica
enabled: true
accessModes:
- ReadWriteOnce
```
Shared (scale ready) with an existing RWX claim:
```yaml
statefulSet:
enabled: true
replicaCount: 2
persistence:
mode: shared
existingClaim: opengist-shared-rwx
```
Shared with chart-created RWX PVC:
```yaml
statefulSet:
enabled: true
replicaCount: 2
persistence:
mode: shared
existingClaim: "" # leave empty
create:
enabled: true
accessModes: [ReadWriteMany]
size: 10Gi
```
When `mode=shared` and `existingClaim` is empty, the chart creates a single PVC named `<release>-shared` (suffix configurable via `persistence.create.nameSuffix`).
Fail-fast conditions:
* `replicaCount>1` & missing external DB (still enforced).
* `replicaCount>1` & persistence disabled.
* `replicaCount>1` & neither `existingClaim` nor `mode=shared`.
* `mode=shared` & create.enabled=true but `accessModes` lacks `ReadWriteMany`.
Migration (perReplica → shared): scale down to 1, create RWX claim (or rely on create.enabled), copy data, switch mode to shared, scale up.
### Troubleshooting
#### Common Errors and Solutions
##### Error: "replicaCount=2 requires PostgreSQL/MySQL config.db-uri; scheme 'sqlite' unsupported"
* **Cause**: Multi-replica with SQLite database
* **Solution**: Either scale down to `replicaCount: 1` or configure external database:
```yaml
config:
db-uri: "postgres://user:pass@host:5432/opengist"
```
##### Error: "replicaCount=2 requires either persistence.existingClaim OR persistence.mode=shared"
* **Cause**: Multi-replica without shared storage
* **Solution**: Choose one approach:
```yaml
# Option A: Use existing PVC
persistence:
existingClaim: "my-rwx-pvc"
# Option B: Let chart create PVC
persistence:
mode: shared
create:
enabled: true
accessModes: [ReadWriteMany]
```
##### Error: "persistence.mode=shared create.accessModes must include ReadWriteMany for multi-replica"
* **Cause**: Chart-created PVC lacks RWX access mode
* **Solution**: Ensure RWX is specified:
```yaml
persistence:
create:
accessModes:
- ReadWriteMany
```
##### Pods mount different data (data divergence)
* **Cause**: Previously scaled with `perReplica` mode and `replicaCount > 1`
* **Solution**: Follow recovery steps in "Recovering from an initial misconfiguration" section above
##### PVC creation fails: "no storage class available with ReadWriteMany"
* **Cause**: Cluster lacks RWX-capable storage provisioner
* **Solution**: Install a storage provider (NFS, CephFS, Longhorn) or use external managed storage and provide `existingClaim`

View File

@@ -1,4 +1,3 @@
{{- if not .Values.statefulSet.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -67,11 +66,6 @@ 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 }}
@@ -101,11 +95,7 @@ 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 }}
@@ -130,5 +120,3 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View File

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

View File

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

View File

@@ -1,53 +1,4 @@
{{- 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:
@@ -58,5 +9,5 @@ metadata:
type: Opaque
stringData:
config.yml: |-
{{- $cfg | toYaml | nindent 4 }}
{{- .Values.config | toYaml | nindent 4 }}
{{- end }}

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ 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.
@@ -18,7 +17,7 @@ configExistingSecret: ""
image:
repository: ghcr.io/thomiceli/opengist
pullPolicy: Always
tag: "1.12.1"
tag: "1.10.0"
digest: ""
imagePullSecrets: []
# - name: "image-pull-secret"
@@ -33,34 +32,6 @@ 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:
@@ -102,26 +73,6 @@ 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:
@@ -148,66 +99,20 @@ serviceAccount:
annotations: {}
name: ""
## Persistent storage for /opengist data directory
## Set persistence using a Persistent Volume Claim
## If more than 2 replicas are set, the access mode must be ReadWriteMany
## 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: ""
## Common persistence parameters (apply to perReplica mode OR as defaults for create.*)
storageClass: "" # Empty = cluster default
storageClass: ""
labels: {}
annotations:
helm.sh/resource-policy: keep # Prevents PVC deletion on helm uninstall
helm.sh/resource-policy: keep
size: 5Gi
accessModes:
- 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
- ReadWriteOnce
subPath: ""
extraVolumes: []
extraVolumeMounts: []

View File

@@ -1,4 +1,4 @@
package totp
package auth
import (
"crypto/aes"
@@ -19,8 +19,7 @@ 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)
@@ -39,8 +38,7 @@ func AESDecrypt(key, ciphertext []byte) ([]byte, error) {
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
// TODO: remove deprecated
//nolint:staticcheck
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(ciphertext, ciphertext)

View File

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

View File

@@ -2,15 +2,16 @@ package oauth
import (
gocontext "context"
"net/http"
gojson "encoding/json"
"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 {
@@ -79,7 +80,34 @@ func (p *GiteaCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
func (p *GiteaCallbackProvider) UpdateUserDB(user *db.User) {
user.GiteaID = p.User.UserID
user.AvatarURL = p.User.AvatarURL
resp, err := http.Get(urlJoin(config.C.GiteaUrl, "/api/v1/users/", p.User.UserID))
if err != nil {
log.Error().Err(err).Msg("Cannot get user from Gitea")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("Cannot read Gitea response body")
return
}
var result map[string]interface{}
err = gojson.Unmarshal(body, &result)
if err != nil {
log.Error().Err(err).Msg("Cannot unmarshal Gitea response body")
return
}
field, ok := result["avatar_url"]
if !ok {
log.Error().Msg("Field 'avatar_url' not found in Gitea JSON response")
return
}
user.AvatarURL = field.(string)
}
func NewGiteaCallbackProvider(user *goth.User) CallbackProvider {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,21 +4,20 @@ 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, []byte, error) {
func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.URL, error, []byte) {
var err error
if secret == nil {
secret, err = generateSecret()
if err != nil {
return "", "", nil, err
return "", "", err, nil
}
}
@@ -29,22 +28,22 @@ func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.U
Secret: secret,
})
if err != nil {
return "", "", nil, err
return "", "", err, nil
}
qrcode, err := otpKey.Image(320, 240)
if err != nil {
return "", "", nil, err
return "", "", err, nil
}
var imgBytes bytes.Buffer
if err = png.Encode(&imgBytes, qrcode); err != nil {
return "", "", nil, err
return "", "", err, nil
}
qrcodeImage := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes()))
return otpKey.Secret(), qrcodeImage, secret, nil
return otpKey.Secret(), qrcodeImage, nil, secret
}
func Validate(passcode, secret string) bool {

View File

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

View File

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

View File

@@ -2,14 +2,13 @@ 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
@@ -102,7 +101,7 @@ func FinishDiscoverableLogin(jsonSession []byte, response *http.Request) (uint,
return 0, err
}
return waUser.(*user).ID, nil
return waUser.(*user).User.ID, nil
}
func BeginLogin(dbUser *db.User) (credCreation *protocol.CredentialAssertion, jsonSession []byte, err error) {

View File

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

View File

@@ -31,6 +31,7 @@ type config struct {
LogLevel string `yaml:"log-level" env:"OG_LOG_LEVEL"`
LogOutput string `yaml:"log-output" env:"OG_LOG_OUTPUT"`
LogPath string `yaml:"log-path" env:"OG_LOG_PATH"`
ExternalUrl string `yaml:"external-url" env:"OG_EXTERNAL_URL"`
OpengistHome string `yaml:"opengist-home" env:"OG_OPENGIST_HOME"`
@@ -51,8 +52,6 @@ type config struct {
HttpPort string `yaml:"http.port" env:"OG_HTTP_PORT"`
HttpGit bool `yaml:"http.git-enabled" env:"OG_HTTP_GIT_ENABLED"`
UnixSocketPermissions string `yaml:"unix-socket-permissions" env:"OG_UNIX_SOCKET_PERMISSIONS"`
SshGit bool `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"`
SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"`
SshPort string `yaml:"ssh.port" env:"OG_SSH_PORT"`
@@ -79,9 +78,7 @@ 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"`
MetricsHost string `yaml:"metrics.host" env:"OG_METRICS_HOST"`
MetricsPort string `yaml:"metrics.port" env:"OG_METRICS_PORT"`
MetricsEnabled bool `yaml:"metrics.enabled" env:"OG_METRICS_ENABLED"`
LDAPUrl string `yaml:"ldap.url" env:"OG_LDAP_URL"`
LDAPBindDn string `yaml:"ldap.bind-dn" env:"OG_LDAP_BIND_DN"`
@@ -117,8 +114,6 @@ func configWithDefaults() (*config, error) {
c.HttpPort = "6157"
c.HttpGit = true
c.UnixSocketPermissions = "0666"
c.SshGit = true
c.SshHost = "0.0.0.0"
c.SshPort = "2222"
@@ -130,8 +125,6 @@ func configWithDefaults() (*config, error) {
c.GiteaName = "Gitea"
c.MetricsEnabled = false
c.MetricsHost = "0.0.0.0"
c.MetricsPort = "6158"
return c, nil
}
@@ -160,6 +153,10 @@ func InitConfig(configPath string, out io.Writer) error {
c.OpengistHome = filepath.Join(homeDir, ".opengist")
}
if c.LogPath == "" {
c.LogPath = filepath.Join(GetHomeDir(), "log")
}
if err = checks(c); err != nil {
return err
}
@@ -177,7 +174,7 @@ func InitConfig(configPath string, out io.Writer) error {
}
func InitLog() {
if err := os.MkdirAll(filepath.Join(GetHomeDir(), "log"), 0755); err != nil {
if err := os.MkdirAll(C.LogPath, 0755); err != nil {
panic(err)
}
@@ -218,7 +215,7 @@ func InitLog() {
logWriters = append(logWriters, consoleWriter)
defer func() { log.Debug().Msg("Logging to stdout") }()
case "file":
file, err := os.OpenFile(filepath.Join(GetHomeDir(), "log", "opengist.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
file, err := os.OpenFile(filepath.Join(C.LogPath, "opengist.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
panic(err)
}

View File

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

View File

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

View File

@@ -3,17 +3,16 @@ package db
import (
"errors"
"fmt"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm/logger"
"net/url"
"path/filepath"
"slices"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm/logger"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"gorm.io/gorm"
@@ -155,7 +154,7 @@ func Setup(dbUri string) error {
return err
}
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}); err != nil {
return err
}
@@ -269,5 +268,5 @@ func DeprecationDBFilename() {
}
func TruncateDatabase() error {
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{})
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{})
}

View File

@@ -1,6 +1,8 @@
package db
import (
"bytes"
"encoding/gob"
"fmt"
"os/exec"
"path/filepath"
@@ -73,7 +75,6 @@ type Gist struct {
URL string
Preview string
PreviewFilename string
PreviewMimeType string
Description string
Private Visibility // 0: public, 1: unlisted, 2: private
UserID uint
@@ -396,7 +397,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 {
@@ -419,20 +420,12 @@ func (gist *Gist) Files(revision string, truncate bool) ([]*git.File, error) {
var files []*git.File
for _, fileCat := range filesCat {
var shortContent string
if len(fileCat.Content) > 512 {
shortContent = fileCat.Content[:512]
} else {
shortContent = fileCat.Content
}
files = append(files, &git.File{
Filename: fileCat.Name,
Size: fileCat.Size,
HumanSize: humanize.IBytes(fileCat.Size),
Content: fileCat.Content,
Truncated: fileCat.Truncated,
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(fileCat.Name)),
})
}
return files, err
@@ -453,20 +446,12 @@ func (gist *Gist) File(revision string, filename string, truncate bool) (*git.Fi
return nil, err
}
var shortContent string
if len(content) > 512 {
shortContent = content[:512]
} else {
shortContent = content
}
return &git.File{
Filename: filename,
Size: size,
HumanSize: humanize.IBytes(size),
Content: content,
Truncated: truncated,
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(filename)),
}, err
}
@@ -488,14 +473,8 @@ func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error {
}
for _, file := range *files {
if file.SourcePath != "" { // if it's an uploaded file
if err := git.MoveFileToRepository(gist.Uuid, file.Filename, file.SourcePath); err != nil {
return err
}
} else { // else it's a text editor file
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
return err
}
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
return err
}
}
@@ -552,31 +531,20 @@ 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)
if err != nil {
return err
}
if file == nil {
continue
}
gist.Preview = ""
gist.PreviewFilename = file.Filename
gist.PreviewMimeType = file.MimeType.ContentType
if !file.MimeType.CanBeEdited() {
continue
}
split := strings.Split(file.Content, "\n")
if len(split) > 10 {
gist.Preview = strings.Join(split[:10], "\n")
} else {
gist.Preview = file.Content
}
file, err := gist.File("HEAD", filesStr[0], true)
if err != nil {
return err
}
split := strings.Split(file.Content, "\n")
if len(split) > 10 {
gist.Preview = strings.Join(split[:10], "\n")
} else {
gist.Preview = file.Content
}
gist.PreviewFilename = file.Filename
}
if withTimestampUpdate {
@@ -645,6 +613,30 @@ func (gist *Gist) TopicsSlice() []string {
return topics
}
func (gist *Gist) SerialiseInitRepository() error {
var gobBuffer bytes.Buffer
encoder := gob.NewEncoder(&gobBuffer)
if err := encoder.Encode(gist); err != nil {
return fmt.Errorf("gob encoding error: %v", err)
}
return git.SerialiseInitRepository(gist.User.Username, gobBuffer.Bytes())
}
func DeserialiseInitRepository(user string) (*Gist, error) {
data, err := git.DeserialiseInitRepository(user)
if err != nil {
return nil, err
}
var gist Gist
decoder := gob.NewDecoder(bytes.NewReader(data))
if err := decoder.Decode(&gist); err != nil {
return nil, fmt.Errorf("gob decoding error: %v", err)
}
return &gist, nil
}
func (gist *Gist) UpdateLanguages() {
languages, err := gist.GetLanguagesFromFiles()
if err != nil {
@@ -694,15 +686,10 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
fileDTOs := make([]FileDTO, 0, len(files))
for _, file := range files {
f := FileDTO{
fileDTOs = append(fileDTOs, FileDTO{
Filename: file.Filename,
}
if file.MimeType.CanBeEdited() {
f.Content = file.Content
} else {
f.Binary = true
}
fileDTOs = append(fileDTOs, f)
Content: file.Content,
})
}
return &GistDTO{
@@ -739,10 +726,8 @@ type VisibilityDTO struct {
}
type FileDTO struct {
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
Content string
Binary bool
SourcePath string // Path to uploaded file, used instead of Content when present
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
Content string `validate:"required"`
}
func (dto *GistDTO) ToGist() *Gist {

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,6 @@ 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 {
@@ -73,11 +72,6 @@ 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
@@ -274,7 +268,6 @@ 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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -303,35 +303,3 @@ auth.totp.scan-qr-code: Scanne den unten stehenden QR-Code mit deiner Authentifi
auth.totp.enter-code: Gib den Code aus deiner Authentifizierungs-App ein
auth.totp.save-recovery-codes: Speichere deine Wiederherstellungscodes an einem sicheren Ort. Du kannst diese Codes verwenden, um wieder Zugang zu deinem Account zu erlangen, wenn du den Zugriff auf deine Authentifizierungs-App verloren hast.
error.not-in-mfa-session: Nutzer ist nicht in einer Zwei-Faktor-Sitzung
gist.revision.binary-file-changes: Änderungen an Binärdateien werden nicht angezeigt
error.no-file-uploaded: Keine Datei hochgeladen
flash.admin.sync-gist-languages: Gist-Sprachen werden synchronisiert …
validation.invalid-gist-topics: Ungültige Gist-Themen. Sie müssen mit einem Buchstaben oder einer Zahl beginnen, dürfen maximal 50 Zeichen lang sein und dürfen Bindestriche enthalten
gist.new.topics: Themen (durch Leerzeichen getrennt)
gist.preview-non-available: Vorschau nicht verfügbar
gist.new.drop-files: Dateien hier ablegen oder zum Hochladen klicken
gist.new.any-file-type: Laden Sie einen beliebigen Dateityp hoch
gist.list.topic-results-topic: Alle Gists mit dem Thema %s
gist.file-raw: Diese Datei kann nicht gerendert werden.
gist.file-binary-edit: Diese Datei ist binär.
gist.search.placeholder.title: Titel
gist.search.placeholder.visibility: Sichtbarkeit
gist.search.placeholder.public: Öffentlich
gist.search.placeholder.unlisted: Nicht gelistet
gist.search.placeholder.private: Privat
gist.search.placeholder.language: Sprache
gist.search.placeholder.all: Alle
gist.search.placeholder.topics: Themen
gist.search.placeholder.search: Suche
gist.search.help.topic: gists zum gegebenen Thema
settings.header.account: Konto
settings.header.mfa: MFA
settings.header.ssh: SSH
settings.header.style: Stil
settings.style.removed-lines-color: Farbe entfernter Linien
settings.style.added-lines-color: Farbe hinzugefügter Linien
settings.style.git-lines-color: Git Linien Farbe
settings.style.save-style: Stil Speichern
auth.totp.enter-recovery-key: oder einen Wiederherstellungsschlüssel, wenn Sie Ihr Gerät verloren haben
error.cannot-open-file: Die hochgeladene Datei kann nicht geöffnet werden
admin.actions.sync-gist-languages: Synchronisieren Sie alle Gists-Sprachen

View File

@@ -23,12 +23,9 @@ gist.header.download-zip: Download ZIP
gist.raw: Raw
gist.file-truncated: This file has been truncated.
gist.file-raw: This file can't be rendered.
gist.file-binary-edit: This file is binary.
gist.watch-full-file: View the full file.
gist.file-not-valid: This file is not a valid CSV file.
gist.no-content: No files found
gist.preview-non-available: Preview not available
gist.new.new_gist: New gist
gist.new.title: Title
@@ -49,8 +46,6 @@ gist.new.create-private-button: Create private gist
gist.new.preview: Preview
gist.new.create-a-new-gist: Create a new gist
gist.new.topics: Topics (separate with spaces)
gist.new.drop-files: Drop files here or click to upload
gist.new.any-file-type: Upload any file type
gist.edit.editing: Editing
gist.edit.edit-gist: Edit %s
@@ -120,7 +115,6 @@ gist.revision.file-renamed: renamed to
gist.revision.diff-truncated: Diff is too large to be shown
gist.revision.file-renamed-no-changes: File renamed without changes
gist.revision.empty-file: Empty file
gist.revision.binary-file-changes: Binary file changes are not shown
gist.revision.no-changes: No changes
gist.revision.no-revisions: No revisions to show
gist.revision-of: Revision of %s
@@ -157,7 +151,6 @@ 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
@@ -166,30 +159,6 @@ 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
@@ -246,8 +215,6 @@ error.cannot-bind-data: Cannot bind data
error.invalid-number: Invalid number
error.invalid-character-unescaped: Invalid character unescaped
error.not-in-mfa-session: User is not in a MFA session
error.no-file-uploaded: No file uploaded
error.cannot-open-file: Cannot open uploaded file
header.menu.all: All
header.menu.new: New
@@ -365,4 +332,4 @@ validation.not-enough: Not enough %s
validation.invalid: Invalid %s
validation.invalid-gist-topics: Invalid gist topics, they must start with a letter or number, consist of 50 characters or less, and can include hyphens
html.title.admin-panel: Admin panel
html.title.admin-panel: Admin panel

View File

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

View File

@@ -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,175 +183,77 @@ 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: 'Форки фрагмента %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: Перенос строк
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: ''

View File

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

View File

@@ -2,8 +2,6 @@ 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"
@@ -12,6 +10,7 @@ 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 {
@@ -54,8 +53,6 @@ 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()
@@ -77,7 +74,6 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
}
docMapping.DefaultAnalyzer = "gistAnalyser"
mapping.DefaultMapping = docMapping
return bleve.New(i.path, mapping)
}
@@ -109,73 +105,40 @@ func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
var err error
var indexerQuery query.Query
if queryStr != "" {
// Use match query with fuzzy matching for more flexible content search
contentQuery := bleve.NewMatchQuery(queryStr)
contentQuery.SetField("Content")
contentQuery.SetFuzziness(2)
contentQuery := bleve.NewMatchPhraseQuery(queryStr)
contentQuery.FieldVal = "Content"
indexerQuery = contentQuery
} else {
contentQuery := bleve.NewMatchAllQuery()
indexerQuery = contentQuery
}
// 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")
privateQuery := bleve.NewBoolFieldQuery(false)
privateQuery.SetField("Private")
userIdMatch := float64(userId)
truee := true
userIdQuery := bleve.NewNumericRangeInclusiveQuery(&userIdMatch, &userIdMatch, &truee, &truee)
userIdQuery.SetField("UserID")
accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery)
accessQuery := bleve.NewDisjunctionQuery(privateQuery, userIdQuery)
indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery)
// Handle "All" field - search across all metadata fields with OR logic
if queryMetadata.All != "" {
allQueries := make([]query.Query, 0)
// 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},
addQuery := func(field, value string) {
if value != "" && value != "." {
q := bleve.NewMatchPhraseQuery(value)
q.FieldVal = field
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q)
}
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)
}
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)
perPage := 10

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,12 @@
package index
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log"
"strconv"
"strings"
)
type MeiliIndexer struct {
@@ -84,13 +82,12 @@ func (i *MeiliIndexer) Add(gist *Gist) error {
if gist == nil {
return errors.New("failed to add nil gist to index")
}
primaryKey := "GistID"
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, "GistID")
return err
}
func (i *MeiliIndexer) Remove(gistID uint) error {
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.DeleteDocument(strconv.Itoa(int(gistID)), nil)
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.DeleteDocument(strconv.Itoa(int(gistID)))
return err
}
@@ -130,20 +127,16 @@ func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata,
gistIds := make([]uint, 0, len(response.Hits))
for _, hit := range response.Hits {
if gistIDRaw, ok := hit["GistID"]; ok {
var gistID float64
if err := json.Unmarshal(gistIDRaw, &gistID); err == nil {
gistIds = append(gistIds, uint(gistID))
}
if gistID, ok := hit.(map[string]interface{})["GistID"].(float64); ok {
gistIds = append(gistIds, uint(gistID))
}
}
languageCounts := make(map[string]int)
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
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)
}
}
}

View File

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

View File

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

View File

@@ -2,8 +2,6 @@ package render
import (
"bytes"
"regexp"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
@@ -14,6 +12,7 @@ import (
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
"go.abhg.dev/goldmark/mermaid"
"regexp"
)
func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
@@ -28,11 +27,11 @@ func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
}, err
}
func renderMarkdownFile(file *git.File) (HighlightedFile, error) {
func MarkdownFile(file *git.File) (RenderedFile, error) {
var buf bytes.Buffer
err := newMarkdownWithSvgExtension().Convert([]byte(file.Content), &buf)
return HighlightedFile{
return RenderedFile{
File: file,
HTML: buf.String(),
Type: "Markdown",

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,8 @@ package auth
import (
"errors"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth"
"github.com/thomiceli/opengist/internal/auth/ldap"
passwordpkg "github.com/thomiceli/opengist/internal/auth/password"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
@@ -125,15 +124,15 @@ func ProcessLogin(ctx *context.Context) error {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
}
user, err = auth.TryAuthentication(dto.Username, dto.Password)
if err != nil {
var authErr auth.AuthError
if errors.As(err, &authErr) {
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
return ctx.RedirectTo("/login")
if ldap.Enabled() {
if user, err = tryLdapLogin(ctx, dto.Username, dto.Password); err != nil {
return err
}
}
if user == nil {
if user, err = tryDbLogin(ctx, dto.Username, dto.Password); user == nil {
return err
}
return ctx.ErrorRes(500, "Authentication system error", nil)
}
// handle MFA
@@ -161,3 +160,59 @@ func Logout(ctx *context.Context) error {
ctx.DeleteCsrfCookie()
return ctx.RedirectTo("/all")
}
func tryDbLogin(ctx *context.Context, username, password string) (user *db.User, err error) {
if user, err = db.GetUserByUsername(username); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ctx.ErrorRes(500, "Cannot get user", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
return nil, ctx.RedirectTo("/login")
}
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
if err != nil {
return nil, ctx.ErrorRes(500, "Cannot check for password", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
return nil, ctx.RedirectTo("/login")
}
return user, nil
}
func tryLdapLogin(ctx *context.Context, username, password string) (user *db.User, err error) {
ok, err := ldap.Authenticate(username, password)
if err != nil {
log.Info().Err(err).Msgf("LDAP authentication error")
return nil, ctx.ErrorRes(500, "Cannot get user", err)
}
if !ok {
log.Warn().Msg("Invalid LDAP authentication attempt from " + ctx.RealIP())
return nil, nil
}
if user, err = db.GetUserByUsername(username); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ctx.ErrorRes(500, "Cannot get user", err)
}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
user = &db.User{
Username: username,
}
if err = user.Create(); err != nil {
log.Warn().Err(err).Msg("Cannot create user after LDAP authentication")
return nil, ctx.ErrorRes(500, "Cannot create user", err)
}
return user, nil
}
return user, nil
}

View File

@@ -1,11 +1,10 @@
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 {
@@ -26,7 +25,7 @@ func BeginTotp(ctx *context.Context) error {
sess := ctx.GetSession()
generatedSecret, _ := sess.Values["generatedSecret"].([]byte)
totpSecret, qrcode, generatedSecret, err := totp.GenerateQRCode(ctx.User.Username, ogUrl.Hostname(), generatedSecret)
totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(ctx.User.Username, ogUrl.Hostname(), generatedSecret)
if err != nil {
return ctx.ErrorRes(500, "Cannot generate TOTP QR code", err)
}

View File

@@ -2,9 +2,6 @@ package gist
import (
"errors"
"slices"
"strings"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/index"
@@ -12,6 +9,8 @@ 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 {
@@ -55,19 +54,18 @@ func AllGists(ctx *context.Context) error {
mode := ctx.GetData("mode")
if fromUserStr == "" {
switch mode {
case "search":
if mode == "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, "")
case "topics":
} else if mode == "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"))
case "all":
} else if mode == "all" {
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all"))
urlPage = "all"
gists, err = db.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order)
@@ -103,16 +101,15 @@ func AllGists(ctx *context.Context) error {
ctx.SetData("countForked", countForked)
}
switch mode {
case "liked":
if mode == "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)
case "forked":
} else if mode == "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)
case "fromUser":
} else if mode == "fromUser" {
urlPage = fromUserStr
if languages, err := db.GetGistLanguagesForUser(fromUser.ID, currentUserId); err != nil {
@@ -189,7 +186,6 @@ 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)

View File

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

View File

@@ -3,11 +3,11 @@ package gist
import (
"archive/zip"
"bytes"
"net/url"
"strconv"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
)
func RawFile(ctx *context.Context) error {
@@ -20,23 +20,10 @@ func RawFile(ctx *context.Context) error {
if file == nil {
return ctx.NotFound("File not found")
}
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")
contentType := handlers.GetContentTypeFromFilename(file.Filename)
ContentDisposition := handlers.GetContentDisposition(file.Filename)
ctx.Response().Header().Set("Content-Type", contentType)
ctx.Response().Header().Set("Content-Disposition", ContentDisposition)
return ctx.PlainText(200, file.Content)
}
@@ -51,10 +38,9 @@ func DownloadFile(ctx *context.Context) error {
return ctx.NotFound("File not found")
}
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
ctx.Response().Header().Set("Content-Disposition", "attachment; filename=\""+url.PathEscape(file.Filename)+"\"")
ctx.Response().Header().Set("Content-Type", "text/plain")
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+file.Filename)
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
ctx.Response().Header().Set("X-Content-Type-Options", "nosniff")
_, err = ctx.Response().Write([]byte(file.Content))
if err != nil {
return ctx.ErrorRes(500, "Error downloading the file", err)

View File

@@ -2,13 +2,12 @@ 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 {
@@ -35,7 +34,7 @@ func Fork(ctx *context.Context) error {
}
newGist := &db.Gist{
Uuid: strings.ReplaceAll(uuidGist.String(), "-", ""),
Uuid: strings.Replace(uuidGist.String(), "-", "", -1),
Title: gist.Title,
Preview: gist.Preview,
PreviewFilename: gist.PreviewFilename,

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,16 @@
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
@@ -14,52 +18,84 @@ var (
metricsInitialized bool = false
)
// initMetrics initializes metrics if they're not already initialized
func initMetrics() {
if metricsInitialized {
return
}
countUsersGauge = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "opengist_users_total",
Help: "Total number of users",
},
)
// 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",
},
)
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() {
if !metricsInitialized {
// Only update metrics if they're enabled
if !config.C.MetricsEnabled || !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)
}

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,6 @@ package server
import (
"errors"
"fmt"
"html/template"
"net/http"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"github.com/labstack/echo-contrib/echoprometheus"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
@@ -23,6 +15,12 @@ import (
"github.com/thomiceli/opengist/internal/web/handlers"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"html/template"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
)
func (s *Server) useCustomContext() {
@@ -38,7 +36,8 @@ func (s *Server) registerMiddlewares() {
s.echo.Use(Middleware(dataInit).toEcho())
s.echo.Use(Middleware(locale).toEcho())
if config.C.MetricsEnabled {
s.echo.Use(echoprometheus.NewMiddleware("opengist"))
p := echoprometheus.NewMiddleware("opengist")
s.echo.Use(p)
}
s.echo.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{
@@ -55,7 +54,7 @@ func (s *Server) registerMiddlewares() {
return nil
},
}))
s.echo.Use(middleware.Recover())
//s.echo.Use(middleware.Recover())
s.echo.Use(middleware.Secure())
s.echo.Use(Middleware(sessionInit).toEcho())
@@ -91,33 +90,21 @@ 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()
}
}
@@ -206,9 +193,6 @@ 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")
@@ -329,39 +313,6 @@ 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
@@ -388,12 +339,7 @@ func gistInit(next Handler) Handler {
if gist.Private == db.PrivateVisibility {
if currUser == nil || currUser.ID != gist.UserID {
// 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")
}
return ctx.NotFound("Gist not found")
}
}

View File

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

View File

@@ -17,6 +17,7 @@ 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"
)
@@ -28,11 +29,13 @@ func (s *Server) registerRoutes() {
r.GET("/", gist.Create, logged)
r.POST("/", gist.ProcessCreate, logged)
r.POST("/preview", gist.Preview, logged)
r.POST("/upload", gist.Upload, logged)
r.DELETE("/upload/:uuid", gist.DeleteUpload, logged)
r.GET("/healthcheck", health.Healthcheck)
if config.C.MetricsEnabled {
r.GET("/metrics", metrics.Metrics)
}
r.GET("/register", auth.Register)
r.POST("/register", auth.ProcessRegister)
r.GET("/login", auth.Login)
@@ -62,9 +65,6 @@ 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)

View File

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

View File

@@ -1,448 +0,0 @@
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)
}

View File

@@ -203,28 +203,49 @@ func TestGitOperations(t *testing.T) {
err = s.Request("POST", "/", gist3, 302)
require.NoError(t, err)
gitOperations := func(credentials, owner, url, filename string, expectErrorClone, expectErrorCheck, expectErrorPush bool) {
log.Debug().Msgf("Testing %s %s %t %t %t", credentials, url, expectErrorClone, expectErrorCheck, expectErrorPush)
err := clientGitClone(credentials, owner, url)
if expectErrorClone {
require.Error(t, err)
} else {
require.NoError(t, err)
}
err = clientCheckRepo(url, filename)
if expectErrorCheck {
require.Error(t, err)
} else {
require.NoError(t, err)
}
err = clientGitPush(url)
if expectErrorPush {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}
tests := []struct {
credentials string
user string
url string
pushOptions string
expectErrorClone bool
expectErrorCheck bool
expectErrorPush bool
}{
{":", "kaguya", "kaguya-pub-gist", "", false, false, true},
{":", "kaguya", "kaguya-unl-gist", "", false, false, true},
{":", "kaguya", "kaguya-priv-gist", "", true, true, true},
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false},
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true},
{":", "kaguya", "kaguya-pub-gist", false, false, true},
{":", "kaguya", "kaguya-unl-gist", false, false, true},
{":", "kaguya", "kaguya-priv-gist", true, true, true},
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", false, false, false},
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", true, true, true},
}
for _, test := range tests {
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
}
login(t, s, admin)
@@ -235,24 +256,23 @@ func TestGitOperations(t *testing.T) {
credentials string
user string
url string
pushOptions string
expectErrorClone bool
expectErrorCheck bool
expectErrorPush bool
}{
{":", "kaguya", "kaguya-pub-gist", "", true, true, true},
{":", "kaguya", "kaguya-unl-gist", "", true, true, true},
{":", "kaguya", "kaguya-priv-gist", "", true, true, true},
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false},
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true},
{":", "kaguya", "kaguya-pub-gist", true, true, true},
{":", "kaguya", "kaguya-unl-gist", true, true, true},
{":", "kaguya", "kaguya-priv-gist", true, true, true},
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", false, false, false},
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", false, false, false},
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", false, false, true},
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", true, true, true},
}
for _, test := range testsRequireLogin {
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
}
login(t, s, admin)
@@ -260,155 +280,31 @@ func TestGitOperations(t *testing.T) {
require.NoError(t, err)
for _, test := range tests {
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
}
}
func TestGitInit(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
admin := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, admin)
s.sessionCookie = ""
register(t, s, db.UserDTO{Username: "fujiwara", Password: "fujiwara"})
s.sessionCookie = ""
register(t, s, db.UserDTO{Username: "kaguya", Password: "kaguya"})
testsNewWithPush := []struct {
credentials string
user string
url string
pushOptions string
expectErrorClone bool
expectErrorCheck bool
expectErrorPush bool
}{
{":", "kaguya", "gist1", "", true, true, true},
{"kaguya:wrongpass", "kaguya", "gist2", "", true, true, true},
{"fujiwara:fujiwara", "kaguya", "gist3", "", true, true, true},
{"kaguya:kaguya", "kaguya", "gist4", "", false, false, false},
{"kaguya:kaguya", "kaguya", "gist5/g", "", true, true, true},
}
for _, test := range testsNewWithPush {
gitInitPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorPush)
}
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, "kaguya", gist1db.User.Username)
for _, test := range testsNewWithPush {
gitCloneCheckPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
}
count, err := db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(1), count)
testsNewWithInit := []struct {
credentials string
url string
pushOptions string
expectErrorPush bool
}{
{":", "init", "", true},
{"fujiwara:wrongpass", "init", "", true},
{"kaguya:kaguya", "init", "", false},
{"fujiwara:fujiwara", "init", "", false},
}
for _, test := range testsNewWithInit {
gitInitPush(t, test.credentials, "kaguya", test.url, "newfile.txt", test.pushOptions, test.expectErrorPush)
}
count, err = db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(3), count)
gist2db, err := db.GetGistByID("2")
require.NoError(t, err)
require.Equal(t, "kaguya", gist2db.User.Username)
gist3db, err := db.GetGistByID("3")
require.NoError(t, err)
require.Equal(t, "fujiwara", gist3db.User.Username)
}
func clientGitClone(creds string, user string, url string) error {
return exec.Command("git", "clone", "http://"+creds+"@localhost:6157/"+user+"/"+url, filepath.Join(config.GetHomeDir(), "tmp", url)).Run()
}
func clientGitPush(url string, pushOptions string, file string) error {
f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, file))
func clientGitPush(url string) error {
f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, "newfile.txt"))
if err != nil {
return err
}
_, _ = f.WriteString("new file")
_ = f.Close()
f.Close()
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", file).Run()
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", "newfile.txt").Run()
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "commit", "-m", "new file").Run()
if pushOptions != "" {
err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", pushOptions, "origin").Run()
} else {
err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", "origin").Run()
}
err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", "origin", "master").Run()
_ = os.RemoveAll(filepath.Join(config.GetHomeDir(), "tmp", url))
return err
}
func clientGitInit(path string) error {
return exec.Command("git", "init", "--initial-branch=master", filepath.Join(config.GetHomeDir(), "tmp", path)).Run()
}
func clientGitSetRemote(path string, remoteName string, remoteUrl string) error {
return exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", path), "remote", "add", remoteName, remoteUrl).Run()
}
func clientCheckRepo(url string, file string) error {
_, err := os.ReadFile(filepath.Join(config.GetHomeDir(), "tmp", url, file))
return err
}
func gitCloneCheckPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorClone, expectErrorCheck, expectErrorPush bool) {
log.Debug().Msgf("Testing %s %s %t %t %t", credentials, url, expectErrorClone, expectErrorCheck, expectErrorPush)
err := clientGitClone(credentials, owner, url)
if expectErrorClone {
require.Error(t, err)
} else {
require.NoError(t, err)
}
err = clientCheckRepo(url, filename)
if expectErrorCheck {
require.Error(t, err)
} else {
require.NoError(t, err)
}
err = clientGitPush(url, pushOptions, filename)
if expectErrorPush {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}
func gitInitPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorPush bool) {
log.Debug().Msgf("Testing %s %s %t", credentials, url, expectErrorPush)
err := clientGitInit(url)
require.NoError(t, err)
if url == "init" {
err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/init/")
} else {
err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/"+owner+"/"+url)
}
require.NoError(t, err)
err = clientGitPush(url, pushOptions, filename)
if expectErrorPush {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}

View File

@@ -66,7 +66,7 @@ func TestGists(t *testing.T) {
Content: []string{"", "yeah\ncool", "yeah\ncool gist actually"},
Topics: "",
}
err = s.Request("POST", "/", gist2, 302)
err = s.Request("POST", "/", gist2, 400)
require.NoError(t, err)
gist3 := db.GistDTO{
@@ -82,7 +82,7 @@ func TestGists(t *testing.T) {
err = s.Request("POST", "/", gist3, 302)
require.NoError(t, err)
gist3db, err := db.GetGistByID("3")
gist3db, err := db.GetGistByID("2")
require.NoError(t, err)
gist3files, err := git.GetFilesOfRepository(gist3db.User.Username, gist3db.Uuid, "HEAD")

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