Compare commits

..

2 Commits

Author SHA1 Message Date
Thomas Miceli
97bb9bd831 lint 2026-01-27 12:33:40 +07:00
Laptop Kitty
7a5c7c117f Ignore TCP errors
Fixes: #594
2026-01-26 11:41:04 -08:00
117 changed files with 4791 additions and 6769 deletions

View File

@@ -1,9 +1,17 @@
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:

View File

@@ -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

@@ -83,18 +83,6 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
meilisearch:
image: getmeili/meilisearch:latest
ports:
- 47700:7700
env:
MEILI_NO_ANALYTICS: true
MEILI_ENV: development
options: >-
--health-cmd "curl -sf http://localhost:7700/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -106,15 +94,13 @@ jobs:
- name: Run tests
run: make test TEST_DB_TYPE=${{ matrix.database }}
env:
OG_TEST_MEILI_HOST: http://localhost:47700
test:
name: Test
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest", "macOS-latest"]
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
go: ["1.25"]
database: ["sqlite"]
runs-on: ${{ matrix.os }}

View File

@@ -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.STATIC_REPO_TOKEN }}@github.com/${{ secrets.STATIC_REPO }}.git target-repo
mkdir -p target-repo/helm
cp helm/*.tgz target-repo/srv/helm/
cp helm/index.yaml target-repo/srv/helm/
cd target-repo
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Deploy helm chart from ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit"
git pull --rebase
git push
- 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

@@ -22,7 +22,7 @@ jobs:
run: make all_crosscompile
- name: Upload Release Assets
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
files: |
build/*.tar.gz
@@ -42,7 +42,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/thomiceli/opengist
@@ -54,26 +54,26 @@ jobs:
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -1,64 +1,5 @@
# Changelog
## [1.12.2](https://github.com/thomiceli/opengist/compare/v1.12.1...v1.12.2) - 2026-03-14
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- Search all fields (#622)
- Display a form to create an Opengist account coming from a OAuth provider (#623)
- Rebuild search index in admin options (#647)
### Fixed
- Clean file path names on file creation (#624)
- Support UTF-8 on gist download (#625)
- CSRF skipper only for GET *.js request (#627)
- Async-loaded gist embed scripts (#630)
- Make gists username/urls case insensitive in URLS (#641)
- Improve code search and index tests (#663)
- Translation strings (#659)
- Gitea avatar URL on OAuth (#674)
### [Helm Chart](helm/opengist)
- Add environment variables and secrets to statefulset (#644)
> Admins of Opengist instances may want to run "Rebuild search index" in the admin panel.
## [1.12.1](https://github.com/thomiceli/opengist/compare/v1.12.0...v1.12.1) - 2026-02-03
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- More translation strings (#605)
### Fixed
- Allow Access Tokens with Required Login (#611)
- Make text files renderable with mimetypes different than text/plain (#612)
- Improve security on raw files endpoint (#613)
> Admins of Opengist instances may want to run "Synchronize all gists previews" in the admin panel.
## [1.12.0](https://github.com/thomiceli/opengist/compare/v1.11.1...v1.12.0) - 2026-01-27
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- Access tokens (#602)
- Fuzzy search for gist search (#555)
- Allow Unicode letters/numbers in topics (#597)
- Resize editor height (#600)
- More translation strings (#516) (#604)
### Fixed
- Don't panic on Go TCP errors (#601)
### Other
- Reduce footprint of Docker image (#515)
- Update Go + JS deps (#603)
- Configure Dependabot for updates on Go and NPM (#449)
### [Helm Chart](helm/opengist)
- Use existing pvc claim of provided (#547)
- Adds StatefulSet support (#549)
- Move Prom metrics to a dedicated port + support ServiceMonitor (#599)
## [1.11.1](https://github.com/thomiceli/opengist/compare/v1.11.0...v1.11.1) - 2025-09-30
See here how to [update](https://opengist.io/docs/update) Opengist.

View File

@@ -8,11 +8,11 @@ RUN apk update && \
musl-dev \
libstdc++
COPY --from=golang:1.25.6-alpine3.22 /usr/local/go/ /usr/local/go/
COPY --from=golang:1.25-alpine3.22 /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:24.9.0-alpine3.22 /usr/local/ /usr/local/
ENV NODE_PATH="/usr/local/lib/node_modules"
ENV PATH="/usr/local/bin:${PATH}"
@@ -31,7 +31,7 @@ RUN apk add --no-cache \
gnupg \
xz
EXPOSE 6157 6158 2222 16157
EXPOSE 6157 2222 16157
RUN git config --global --add safe.directory /opengist
RUN make install
@@ -46,7 +46,7 @@ FROM base AS build
RUN make
FROM alpine:3.22 AS prod
FROM alpine:3.22 as prod
RUN apk update && \
apk add --no-cache \
@@ -64,7 +64,7 @@ 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

@@ -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.11
```
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.11
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.2/opengist1.12.2-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.11.1/opengist1.11.1-linux-amd64.tar.gz
tar xzvf opengist1.12.2-linux-amd64.tar.gz
tar xzvf opengist1.11.1-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`

View File

@@ -32,10 +32,6 @@ index.meili.host:
# Set the API key for the Meiliseach server
index.meili.api-key:
# Set the default search fields. Can contain multiple fields (e.g., `content,username`).
# Fields: content,user,title,description,filename,extension,language,topic. Default: content
search.default: content
# Default branch name used by Opengist when initializing Git repositories.
# If not set, uses the Git default branch name. See https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch
git.default-branch:
@@ -59,15 +55,9 @@ 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

@@ -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'},
@@ -85,7 +84,7 @@ export default defineConfig({
{icon: 'github', link: 'https://github.com/thomiceli/opengist'}
],
editLink: {
pattern: 'https://github.com/thomiceli/opengist/edit/v.1.12.2/docs/:path'
pattern: 'https://github.com/thomiceli/opengist/edit/stable/docs/:path'
},
// @ts-ignore
lastUpdated: true,

View File

@@ -19,7 +19,7 @@ export default {
<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="" >
<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.11</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
</svg>

View File

@@ -15,16 +15,13 @@ aside: false
| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
| search.default | OG_SEARCH_DEFAULT | `content` | Set the default search fields. Can contain multiple fields (e.g., `content,username`). Fields: `content,user,title,description,filename,extension,language,topic`. |
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) |
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
| 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. |
| 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. |
@@ -44,8 +41,6 @@ aside: false
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
| oidc.group-claim-name | OG_OIDC_GROUP_CLAIM_NAME | none | Name of the claim containing the groups. |
| oidc.admin-group | OG_OIDC_ADMIN_GROUP | none | Name of the group that should receive admin rights. |
| ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled |
| ldap.bind-dn | OG_LDAP_BIND_DN | none | Bind DN to authenticate against the LDAP. e.g: cn=read-only-admin,dc=example,dc=com |
| ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. |

View File

@@ -27,7 +27,7 @@ Usage via command line :
./opengist --config /path/to/config.yml
```
You can start by copying and/or modifying the provided [config.yml](https://github.com/thomiceli/opengist/blob/master/config.yml) file.
You can start by copying and/or modifying the provided [config.yml](https://github.com/thomiceli/opengist/blob/stable/config.yml) file.
## Configuration via Environment Variables
@@ -69,4 +69,4 @@ services:
secrets:
opengist_secrets:
file: ./secrets.env
```
```

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,8 +25,8 @@ 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

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.2/opengist1.12.2-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.11.1/opengist1.11.1-linux-amd64.tar.gz
tar xzvf opengist1.12.2-linux-amd64.tar.gz
tar xzvf opengist1.11.1-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.2 # optional, to checkout the latest release
git checkout v1.11.1 # optional, to checkout the latest release
make
./opengist

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.2/opengist1.12.2-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.11.1/opengist1.11.1-linux-amd64.tar.gz
tar xzvf opengist1.12.2-linux-amd64.tar.gz
tar xzvf opengist1.11.1-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
```

113
go.mod
View File

@@ -1,36 +1,36 @@
module github.com/thomiceli/opengist
go 1.25.5
go 1.25.1
require (
github.com/Kunde21/markdownfmt/v3 v3.1.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/alecthomas/chroma/v2 v2.21.1
github.com/blevesearch/bleve/v2 v2.5.7
github.com/dustin/go-humanize v1.0.1
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gabriel-vasile/mimetype v1.4.12
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.17.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/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.50.1
github.com/labstack/echo/v4 v4.15.1
github.com/labstack/echo-contrib v0.17.4
github.com/labstack/echo/v4 v4.15.0
github.com/markbates/goth v1.82.0
github.com/meilisearch/meilisearch-go v0.36.2
github.com/meilisearch/meilisearch-go v0.35.1
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/rs/zerolog v1.35.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.8.2
github.com/yuin/goldmark v1.7.15
github.com/yuin/goldmark-emoji v1.0.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/mermaid v0.6.0
golang.org/x/crypto v0.50.0
golang.org/x/text v0.36.0
golang.org/x/crypto v0.46.0
golang.org/x/text v0.32.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
@@ -38,89 +38,88 @@ require (
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/RoaringBitmap/roaring/v2 v2.18.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
github.com/andybalholm/brotli v1.2.0 // 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.11 // indirect
github.com/blevesearch/geo v0.2.5 // indirect
github.com/blevesearch/go-faiss v1.0.35 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.26 // 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.7 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // 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.3 // indirect
github.com/blevesearch/zapx/v12 v12.4.3 // indirect
github.com/blevesearch/zapx/v13 v13.4.3 // indirect
github.com/blevesearch/zapx/v14 v14.4.3 // indirect
github.com/blevesearch/zapx/v15 v15.4.3 // indirect
github.com/blevesearch/zapx/v16 v16.3.4 // indirect
github.com/blevesearch/vellum v1.1.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.2.8 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // 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/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/pgx/v5 v5.7.6 // 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/json-iterator/go v1.1.12 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.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.20.1 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.72.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
modernc.org/sqlite v1.39.0 // indirect
)

267
go.sum
View File

@@ -1,63 +1,64 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/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.18.0 h1:h7sS0VqCkfBMGgcHaudJFB4FE6Td71H6svRB2poRnGY=
github.com/RoaringBitmap/roaring/v2 v2.18.0/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
github.com/RoaringBitmap/roaring/v2 v2.10.0/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.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.3.11 h1:x29vbV8OjWfLcrDVd7Lr1q+BkLNS0JWNEig0MCVnKH4=
github.com/blevesearch/bleve_index_api v1.3.11/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE=
github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4=
github.com/blevesearch/go-faiss v1.0.35 h1:DJF8yKgH8/n+Oo4E047Jy4oDeRivOugfMb6Cih//JjI=
github.com/blevesearch/go-faiss v1.0.35/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
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.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
github.com/blevesearch/go-faiss v1.0.26/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.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw=
github.com/blevesearch/scorch_segment_api/v2 v2.4.7/go.mod h1://IJ7tG3QCf0cWW/aVSXqy77tc1AvLu3fcJLYEvOAFs=
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.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
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.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY=
github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo=
github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE=
github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM=
github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU=
github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0=
github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg=
github.com/blevesearch/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.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.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
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=
@@ -69,8 +70,9 @@ github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCf
github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -79,58 +81,59 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/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.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.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.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
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.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
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.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.0 h1:8tFdaByIF7EgAg0W849Wt5q+213f1drsV2ggC0t80wM=
github.com/go-webauthn/webauthn v0.17.0/go.mod h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -148,16 +151,14 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/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.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
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=
@@ -186,24 +187,27 @@ 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.50.1 h1:W9cZZ9viA4TDdFtm8cuA+XGFwOcnfbjJpl7VgfsRLHE=
github.com/labstack/echo-contrib v0.50.1/go.mod h1:8r/++U/Fw/QniApFnzunLanKaviPfBX7fX7/2QX0qOk=
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meilisearch/meilisearch-go v0.36.2 h1:MYaMPCpdLh2aYPt+zK+19mLoA4dfBY3S1L7T0FADCjU=
github.com/meilisearch/meilisearch-go v0.36.2/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meilisearch/meilisearch-go v0.35.1 h1:5H2FeY5eR4HSkaZMJIoefNzOj3XX1+5dd7ZfhAfzeMg=
github.com/meilisearch/meilisearch-go v0.35.1/go.mod h1:cUVJZ2zMqTvvwIMEEAdsWH+zrHsrLpAw6gm8Lt1MXK0=
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=
@@ -213,10 +217,9 @@ 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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
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=
@@ -225,16 +228,17 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
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.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
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/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/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/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=
@@ -242,8 +246,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -257,8 +259,8 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
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.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.15 h1:xYJWgq3Qd8qsaZpj5pHKoEI4mosqVZi/qRpq/MdKyyk=
github.com/yuin/goldmark v1.7.15/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
@@ -271,34 +273,41 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.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=
@@ -307,30 +316,28 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/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.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
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/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
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.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
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,34 +0,0 @@
# Helm Chart Changelog
# 0.7.0 - 2026-03-14
- Bump Opengist image to 1.12.2
- Add environment variables and secrets to statefulset
## 0.6.0 - 2026-02-03
- Bump Opengist image to 1.12.1
## 0.5.0 - 2026-01-27
- Bump Opengist image to 1.12.0
- Add StatefulSet support
- Add Prometheus ServiceMonitor support if Opengist metrics are enabled
- New service for metrics endpoint, dissociated from the main service
- Use existing pvc claim of provided
## 0.4.0 - 2025-09-30
- Bump Opengist image to 1.11.1
## 0.3.0 - 2025-09-21
- Bump Opengist image to 1.11.0
## 0.2.0 - 2025-05-10
- Add `deployment.env[]` in values
## 0.1.0 - 2025-04-06
- Initial release, with Opengist image 1.10.0

View File

@@ -4,6 +4,6 @@ dependencies:
version: 16.7.27
- name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.26.0
digest: sha256:7182bad3df032b3cb21a793ea6b027eaa96e142ff207b607b62df974bc82de90
generated: "2026-03-09T03:39:04.820136+07:00"
version: 0.17.1
digest: sha256:ad702e35f258fed1f804d3e48b071767499f5730e099a8c461610950e5182368
generated: "2025-09-21T04:49:08.679554149+02:00"

View File

@@ -2,8 +2,8 @@ apiVersion: v2
name: opengist
description: Opengist Helm chart for Kubernetes
type: application
version: 0.6.0
appVersion: 1.12.2
version: 0.5.0
appVersion: 1.11.1
home: https://opengist.io
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg
sources:
@@ -15,5 +15,5 @@ dependencies:
condition: postgresql.enabled
- name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.26.0
version: 0.17.1
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.2](https://img.shields.io/badge/AppVersion-1.12.2-informational?style=flat-square)
![Version: 0.5.0](https://img.shields.io/badge/Version-0.5.0-informational?style=flat-square) ![AppVersion: 1.11.1](https://img.shields.io/badge/AppVersion-1.11.1-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

View File

@@ -63,28 +63,10 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if or .Values.deployment.env .Values.deployment.envFromSecrets }}
env:
{{- if .Values.deployment.env }}
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
{{- range .Values.deployment.envFromSecrets }}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName }}
key: {{ .secretKey }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.http.port }}
protocol: TCP
{{- if index .Values.config "metrics.enabled" }}
- name: metrics
containerPort: {{ .Values.service.metrics.port }}
protocol: TCP
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}

View File

@@ -25,7 +25,7 @@
{{- $user := default "" .Values.postgresql.global.postgresql.auth.username }}
{{- $pass := default "" .Values.postgresql.global.postgresql.auth.password }}
{{- $db := default "" .Values.postgresql.global.postgresql.auth.database }}
{{- $port := int (default 5432 .Values.postgresql.global.postgresql.service.ports.postgresql) }}
{{- $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 }}

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

@@ -84,7 +84,7 @@ spec:
serviceName: {{ include "opengist.fullname" . }}-http
podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }}
updateStrategy:
{{- toYaml .Values.statefulSet.updateStrategy | nindent 4 }}
{{- toYaml .Values.statefulSet.updateStrategy | nindent 2 }}
selector:
matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }}
@@ -131,19 +131,6 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if or .Values.deployment.env .Values.deployment.envFromSecrets }}
env:
{{- if .Values.deployment.env }}
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
{{- range .Values.deployment.envFromSecrets }}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName }}
key: {{ .secretKey }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.http.port }}
@@ -153,11 +140,6 @@ spec:
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 }}
@@ -190,7 +172,7 @@ spec:
defaultMode: 511
- name: config-volume
emptyDir: {}
{{- /*
{{- /*
========================================
VOLUME MOUNTING DECISION TREE
========================================
@@ -234,7 +216,7 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- /*
{{- /*
========================================
VOLUMECLAIMTEMPLATES DECISION TREE
========================================
@@ -242,14 +224,14 @@ spec:
- 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) "") }}

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.2"
tag: "1.11.1"
digest: ""
imagePullSecrets: []
# - name: "image-pull-secret"
@@ -102,26 +101,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:
@@ -258,13 +237,6 @@ autoscaling:
## Additional deployment configuration
deployment:
env: []
## Load environment variables from specific secret keys
## Each entry creates an env.valueFrom.secretKeyRef in the container spec
## ref: https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-environment-variables
envFromSecrets: []
# - name: OG_OIDC_SECRET
# secretName: opengist-oidc-client-secret
# secretKey: client_secret
terminationGracePeriodSeconds: 60
labels: {}
annotations: {}

View File

@@ -150,12 +150,7 @@ func resetHooks() {
}
func indexGists() {
log.Info().Msg("Rebuilding index from scratch...")
if err := index.ResetIndex(); err != nil {
log.Error().Err(err).Msg("Cannot reset index")
return
}
log.Info().Msg("Indexing all Gists...")
gists, err := db.GetAllGistsRows()
if err != nil {
log.Error().Err(err).Msg("Cannot get gists")

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,11 +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
}
func (p *GiteaCallbackProvider) IsAdmin() bool {
return false
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

@@ -77,10 +77,6 @@ func (p *GitHubCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = "https://avatars.githubusercontent.com/u/" + p.User.UserID + "?v=4"
}
func (p *GitHubCallbackProvider) IsAdmin() bool {
return false
}
func NewGitHubCallbackProvider(user *goth.User) CallbackProvider {
return &GitHubCallbackProvider{
User: user,

View File

@@ -111,10 +111,6 @@ func (p *GitLabCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = field.(string)
}
func (p *GitLabCallbackProvider) IsAdmin() bool {
return false
}
func NewGitLabCallbackProvider(user *goth.User) CallbackProvider {
return &GitLabCallbackProvider{
User: user,

View File

@@ -3,8 +3,6 @@ package oauth
import (
gocontext "context"
"errors"
"slices"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/openidConnect"
@@ -81,31 +79,6 @@ func (p *OIDCCallbackProvider) UpdateUserDB(user *db.User) {
user.AvatarURL = p.User.AvatarURL
}
func (p *OIDCCallbackProvider) IsAdmin() bool {
if config.C.OIDCAdminGroup == "" {
return false
}
groupClaimName := config.C.OIDCGroupClaimName
if groupClaimName == "" {
return false
}
groups, ok := p.User.RawData[groupClaimName].([]interface{})
if !ok {
return false
}
var groupNames []string
for _, group := range groups {
if groupName, ok := group.(string); ok {
groupNames = append(groupNames, groupName)
}
}
return slices.Contains(groupNames, config.C.OIDCAdminGroup)
}
func NewOIDCCallbackProvider(user *goth.User) CallbackProvider {
return &OIDCCallbackProvider{
User: user,

View File

@@ -2,16 +2,15 @@ package oauth
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"io"
"net/http"
"net/url"
"strings"
)
const (
@@ -33,7 +32,6 @@ type CallbackProvider interface {
GetProviderUserID(user *db.User) bool
GetProviderUserSSHKeys() ([]string, error)
UpdateUserDB(user *db.User)
IsAdmin() bool
}
func DefineProvider(provider string, url string) (Provider, error) {
@@ -71,29 +69,6 @@ func CompleteUserAuth(ctx *context.Context) (CallbackProvider, error) {
return nil, fmt.Errorf("unsupported provider %s", user.Provider)
}
func NewCallbackProviderFromSession(provider string, userID string, nickname string, email string, avatarURL string) (CallbackProvider, error) {
user := &goth.User{
Provider: provider,
UserID: userID,
NickName: nickname,
Email: email,
AvatarURL: avatarURL,
}
switch provider {
case GitHubProviderString:
return NewGitHubCallbackProvider(user), nil
case GitLabProviderString:
return NewGitLabCallbackProvider(user), nil
case GiteaProviderString:
return NewGiteaCallbackProvider(user), nil
case OpenIDConnectString:
return NewOIDCCallbackProvider(user), nil
}
return nil, fmt.Errorf("unsupported provider %s", provider)
}
func urlJoin(base string, elem ...string) string {
joined, err := url.JoinPath(base, elem...)
if err != nil {

View File

@@ -2,12 +2,6 @@ package cli
import (
"fmt"
"os"
"os/signal"
"path"
"path/filepath"
"syscall"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/webauthn"
"github.com/thomiceli/opengist/internal/config"
@@ -15,9 +9,13 @@ 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"
"os/signal"
"path"
"path/filepath"
"syscall"
)
var CmdVersion = cli.Command{
@@ -38,18 +36,12 @@ var CmdStart = cli.Command{
Initialize(ctx)
httpServer := server.NewServer(os.Getenv("OG_DEV") == "1")
go httpServer.Start()
server := server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false)
go server.Start()
go ssh.Start()
var metricsServer *metrics.Server
if config.C.MetricsEnabled {
metricsServer = metrics.NewServer()
go metricsServer.Start()
}
<-stopCtx.Done()
shutdown(httpServer, metricsServer)
shutdown(server)
return nil
},
}
@@ -139,7 +131,7 @@ func Initialize(ctx *cli.Context) {
}
}
func shutdown(httpServer *server.Server, metricsServer *metrics.Server) {
func shutdown(server *server.Server) {
log.Info().Msg("Shutting down database...")
if err := db.Close(); err != nil {
log.Error().Err(err).Msg("Failed to close database")
@@ -150,11 +142,7 @@ func shutdown(httpServer *server.Server, metricsServer *metrics.Server) {
index.Close()
}
httpServer.Stop()
if metricsServer != nil {
metricsServer.Stop()
}
server.Stop()
log.Info().Msg("Shutdown complete")
}

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"github.com/thomiceli/opengist/internal/session"
"io"
"net/url"
"os"
@@ -12,8 +13,6 @@ import (
"strings"
"time"
"github.com/thomiceli/opengist/internal/session"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
@@ -38,12 +37,11 @@ type config struct {
DBUri string `yaml:"db-uri" env:"OG_DB_URI"`
DBFilename string `yaml:"db-filename" env:"OG_DB_FILENAME"` // deprecated
IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"` // deprecated
Index string `yaml:"index" env:"OG_INDEX"`
BleveDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` // deprecated
MeiliHost string `yaml:"index.meili.host" env:"OG_MEILI_HOST"`
MeiliAPIKey string `yaml:"index.meili.api-key" env:"OG_MEILI_API_KEY"`
SearchDefault string `yaml:"search.default" env:"OG_SEARCH_DEFAULT"`
IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"` // deprecated
Index string `yaml:"index" env:"OG_INDEX"`
BleveDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` // deprecated
MeiliHost string `yaml:"index.meili.host" env:"OG_MEILI_HOST"`
MeiliAPIKey string `yaml:"index.meili.api-key" env:"OG_MEILI_API_KEY"`
GitDefaultBranch string `yaml:"git.default-branch" env:"OG_GIT_DEFAULT_BRANCH"`
@@ -81,9 +79,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"`
@@ -112,7 +108,6 @@ func configWithDefaults() (*config, error) {
c.OpengistHome = ""
c.DBUri = "opengist.db"
c.Index = "bleve"
c.SearchDefault = "content"
c.SqliteJournalMode = "WAL"
@@ -133,8 +128,6 @@ func configWithDefaults() (*config, error) {
c.GiteaName = "Gitea"
c.MetricsEnabled = false
c.MetricsHost = "0.0.0.0"
c.MetricsPort = "6158"
return c, nil
}

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

@@ -155,7 +155,7 @@ func Setup(dbUri string) error {
return err
}
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}); err != nil {
return err
}
@@ -269,5 +269,5 @@ func DeprecationDBFilename() {
}
func TruncateDatabase() error {
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{})
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{})
}

View File

@@ -71,10 +71,8 @@ type Gist struct {
Uuid string
Title string
URL string
URLNormalized string
Preview string
PreviewFilename string
PreviewMimeType string
Description string
Private Visibility // 0: public, 1: unlisted, 2: private
UserID uint
@@ -99,11 +97,6 @@ type Like struct {
CreatedAt int64
}
func (gist *Gist) BeforeSave(_ *gorm.DB) error {
gist.URLNormalized = strings.ToLower(gist.URL)
return nil
}
func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
// Decrement fork counter if the gist was forked
err := tx.Model(&Gist{}).
@@ -116,8 +109,7 @@ func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
func GetGist(user string, gistUuid string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("(gists.uuid LIKE ? OR gists.url_normalized = ?) AND users.username_normalized = ?",
strings.ToLower(gistUuid)+"%", strings.ToLower(gistUuid), strings.ToLower(user)).
Where("(gists.uuid like ? OR gists.url = ?) AND users.username like ?", gistUuid+"%", gistUuid, user).
Joins("join users on gists.user_id = users.id").
First(&gist).Error
@@ -559,7 +551,6 @@ 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)
@@ -571,7 +562,6 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
}
gist.Preview = ""
gist.PreviewFilename = file.Filename
gist.PreviewMimeType = file.MimeType.ContentType
if !file.MimeType.CanBeEdited() {
continue
@@ -727,17 +717,13 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
// -- DTO -- //
type GistDTO struct {
Title string `validate:"max=250" form:"title"`
Description string `validate:"max=1000" form:"description"`
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
Files []FileDTO `validate:"min=1,dive"`
Name []string `form:"name"`
Content []string `form:"content"`
Topics string `validate:"gisttopics" form:"topics"`
UploadedFilesUUID []string `validate:"omitempty,dive,required,uuid" form:"uploadedfile_uuid"`
UploadedFilesNames []string `validate:"omitempty,dive,required" form:"uploadedfile_filename"`
BinaryFileOldName []string `form:"binary_old_name"`
BinaryFileNewName []string `form:"binary_new_name"`
Title string `validate:"max=250" form:"title"`
Description string `validate:"max=1000" form:"description"`
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
Files []FileDTO `validate:"min=1,dive"`
Name []string `form:"name"`
Content []string `form:"content"`
Topics string `validate:"gisttopics" form:"topics"`
VisibilityDTO
}
@@ -817,19 +803,18 @@ func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
}
indexedGist := &index.Gist{
GistID: gist.ID,
UserID: gist.UserID,
Visibility: gist.Private.Uint(),
Username: gist.User.Username,
Description: gist.Description,
Title: gist.Title,
Content: wholeContent,
Filenames: fileNames,
Extensions: exts,
Languages: langs,
Topics: topics,
CreatedAt: gist.CreatedAt,
UpdatedAt: gist.UpdatedAt,
GistID: gist.ID,
UserID: gist.UserID,
Visibility: gist.Private.Uint(),
Username: gist.User.Username,
Title: gist.Title,
Content: wholeContent,
Filenames: fileNames,
Extensions: exts,
Languages: langs,
Topics: topics,
CreatedAt: gist.CreatedAt,
UpdatedAt: gist.UpdatedAt,
}
return indexedGist, nil

View File

@@ -2,9 +2,7 @@ package db
import (
"fmt"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type MigrationVersion struct {
@@ -14,74 +12,60 @@ type MigrationVersion struct {
func applyMigrations(dbInfo *databaseInfo) error {
switch dbInfo.Type {
case SQLite, PostgreSQL, MySQL:
return applyAllMigrations(dbInfo.Type)
case SQLite:
return applySqliteMigrations()
case PostgreSQL, MySQL:
return nil
default:
return fmt.Errorf("unknown database type: %s", dbInfo.Type)
}
}
func applyAllMigrations(dbType databaseType) error {
func applySqliteMigrations() error {
// Create migration table if it doesn't exist
if err := db.AutoMigrate(&MigrationVersion{}); err != nil {
log.Fatal().Err(err).Msg("Error creating migration version table")
return err
}
// Get the current migration version
var currentVersion MigrationVersion
db.First(&currentVersion)
// Define migrations
migrations := []struct {
Version uint
DBTypes []databaseType // nil = all types
Func func() error
}{
{1, []databaseType{SQLite}, v1_modifyConstraintToSSHKeys},
{2, []databaseType{SQLite}, v2_lowercaseEmails},
{3, nil, v3_normalizedColumns},
{1, v1_modifyConstraintToSSHKeys},
{2, v2_lowercaseEmails},
// Add more migrations here as needed
}
// Apply migrations
for _, m := range migrations {
if m.Version <= currentVersion.Version {
continue
}
// Skip migrations not intended for this DB type
if len(m.DBTypes) > 0 {
applicable := false
for _, t := range m.DBTypes {
if t == dbType {
applicable = true
break
}
if m.Version > currentVersion.Version {
tx := db.Begin()
if err := tx.Error; err != nil {
log.Fatal().Err(err).Msg("Error starting transaction")
return err
}
if !applicable {
// Advance version so we don't retry on next startup
if err := m.Func(); err != nil {
log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version))
tx.Rollback()
return err
} else {
if err = tx.Commit().Error; err != nil {
log.Fatal().Err(err).Msg(fmt.Sprintf("Error committing migration %d:", m.Version))
return err
}
currentVersion.Version = m.Version
db.Save(&currentVersion)
continue
log.Info().Msg(fmt.Sprintf("Migration %d applied successfully", m.Version))
}
}
tx := db.Begin()
if err := tx.Error; err != nil {
log.Fatal().Err(err).Msg("Error starting transaction")
return err
}
if err := m.Func(); err != nil {
tx.Rollback()
log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version))
return err
}
if err := tx.Commit().Error; err != nil {
log.Fatal().Err(err).Msg(fmt.Sprintf("Error committing migration %d:", m.Version))
return err
}
currentVersion.Version = m.Version
db.Save(&currentVersion)
log.Info().Msg(fmt.Sprintf("Migration %d applied successfully", m.Version))
}
return nil
@@ -128,12 +112,3 @@ func v2_lowercaseEmails() error {
copySQL := `UPDATE users SET email = lower(email);`
return db.Exec(copySQL).Error
}
func v3_normalizedColumns() error {
if err := db.Model(&User{}).Where("username_normalized = '' OR username_normalized IS NULL").
Updates(map[string]interface{}{"username_normalized": gorm.Expr("LOWER(username)")}).Error; err != nil {
return err
}
return db.Model(&Gist{}).Where("url_normalized = '' OR url_normalized IS NULL").
Updates(map[string]interface{}{"url_normalized": gorm.Expr("LOWER(url)")}).Error
}

View File

@@ -2,38 +2,29 @@ package db
import (
"encoding/json"
"strings"
"github.com/thomiceli/opengist/internal/git"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"`
UsernameNormalized string `gorm:"index"`
Password string
IsAdmin bool
CreatedAt int64
Email string
MD5Hash string // for gravatar, if no Email is specified, the value is random
AvatarURL string
GithubID string
GitlabID string
GiteaID string
OIDCID string `gorm:"column:oidc_id"`
StylePreferences string
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"`
Password string
IsAdmin bool
CreatedAt int64
Email string
MD5Hash string // for gravatar, if no Email is specified, the value is random
AvatarURL string
GithubID string
GitlabID string
GiteaID string
OIDCID string `gorm:"column:oidc_id"`
StylePreferences string
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
AccessTokens []AccessToken `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
}
func (user *User) BeforeSave(_ *gorm.DB) error {
user.UsernameNormalized = strings.ToLower(user.Username)
return nil
}
func (user *User) BeforeDelete(tx *gorm.DB) error {
@@ -81,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
@@ -101,7 +87,7 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
func UserExists(username string) (bool, error) {
var count int64
err := db.Model(&User{}).Where("username_normalized = ?", strings.ToLower(username)).Count(&count).Error
err := db.Model(&User{}).Where("username like ?", username).Count(&count).Error
return count > 0, err
}
@@ -119,7 +105,7 @@ func GetAllUsers(offset int) ([]*User, error) {
func GetUserByUsername(username string) (*User, error) {
user := new(User)
err := db.
Where("username_normalized = ?", strings.ToLower(username)).
Where("username like ?", username).
First(&user).Error
return user, err
}
@@ -266,11 +252,6 @@ type UserDTO struct {
Password string `form:"password" validate:"required"`
}
type OAuthRegisterDTO struct {
Username string `form:"username" validate:"required,max=24,alphanumdash,notreserved"`
Email string `form:"email" validate:"omitempty,email"`
}
func (dto *UserDTO) ToUser() *User {
return &User{
Username: dto.Username,

View File

@@ -1,19 +0,0 @@
package git
import (
"path/filepath"
"strings"
)
func CleanTreePathName(s string) string {
name := filepath.Base(s)
if name == "." || name == ".." {
return ""
}
name = strings.ReplaceAll(name, "/", "")
name = strings.ReplaceAll(name, "\\", "")
return name
}

View File

@@ -2,45 +2,43 @@ 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
ContentType string
extension string
}
func (mt MimeType) IsText() bool {
return strings.HasPrefix(mt.ContentType, "text/") || strings.HasPrefix(mt.golangContentType, "text/")
return strings.Contains(mt.ContentType, "text/")
}
func (mt MimeType) IsCSV() bool {
return strings.HasPrefix(mt.ContentType, "text/csv") &&
return strings.Contains(mt.ContentType, "text/csv") &&
(strings.HasSuffix(mt.extension, ".csv"))
}
func (mt MimeType) IsImage() bool {
return strings.HasPrefix(mt.ContentType, "image/")
return strings.Contains(mt.ContentType, "image/")
}
func (mt MimeType) IsSVG() bool {
return strings.HasPrefix(mt.ContentType, "image/svg+xml")
return strings.Contains(mt.ContentType, "image/svg+xml")
}
func (mt MimeType) IsPDF() bool {
return strings.HasPrefix(mt.ContentType, "application/pdf")
return strings.Contains(mt.ContentType, "application/pdf")
}
func (mt MimeType) IsAudio() bool {
return strings.HasPrefix(mt.ContentType, "audio/")
return strings.Contains(mt.ContentType, "audio/")
}
func (mt MimeType) IsVideo() bool {
return strings.HasPrefix(mt.ContentType, "video/")
return strings.Contains(mt.ContentType, "video/")
}
func (mt MimeType) CanBeHighlighted() bool {
@@ -89,5 +87,5 @@ func (mt MimeType) RenderType() string {
}
func DetectMimeType(data []byte, extension string) MimeType {
return MimeType{mimetype.Detect(data).String(), extension, http.DetectContentType(data)}
return MimeType{mimetype.Detect(data).String(), extension}
}

View File

@@ -192,7 +192,7 @@ admin.actions.sync-db: 'Gists von der Datenbank synchronisieren'
admin.actions.git-gc: '„garbage collection“ bei allen git Repositories ausführen'
admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren'
admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories synchronisieren'
admin.actions.index-gists: 'Suchindex neu aufbauen'
admin.actions.index-gists: 'Alle Gists Indexieren'
admin.id: 'ID'
admin.user: 'Benutzer'
admin.delete: 'Löschen'
@@ -236,7 +236,7 @@ flash.admin.sync-db: 'Synchronisiere Repositories aus der Datenbank...'
flash.admin.git-gc: 'Sammle Repositories...'
flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...'
flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...'
flash.admin.index-gists: 'Suchindex wird neu aufgebaut...'
flash.admin.index-gists: 'Indiziere alle Gists...'
flash.auth.username-exists: 'Benutzername existiert bereits'
flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen'

View File

@@ -88,12 +88,10 @@ gist.search.found: gists found
gist.search.no-results: No gists found
gist.search.help.user: gists created by user
gist.search.help.title: gists with given title
gist.search.help.description: gists with given description
gist.search.help.filename: gists having files with given name
gist.search.help.extension: gists having files with given extension
gist.search.help.language: gists having files with given language
gist.search.help.topic: gists with given topic
gist.search.help.all: search all fields
gist.search.placeholder.title: Title
gist.search.placeholder.visibility: Visibility
gist.search.placeholder.public: Public
@@ -159,7 +157,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
@@ -172,26 +169,6 @@ 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
@@ -202,13 +179,6 @@ auth.password: Password
auth.register-instead: Register instead
auth.login-instead: Login instead
auth.oauth: Continue with %s account
auth.oauth.no-provider: OAuth provider not found
auth.oauth.complete-registration: Complete your registration
auth.oauth.complete-registration-button: Create account
auth.oauth.signing-in-with: Signing in with %s
auth.oauth.cancel: Cancel
auth.oauth.existing-account: Existing account?
auth.oauth.already-have-account: If you already have an Opengist account, login first and link your %s account from your settings.
auth.mfa: Multi-factor authentication
auth.mfa.passkey: Passkey
auth.mfa.passkeys: Passkeys
@@ -250,7 +220,7 @@ error.signup-disabled: Signing up is disabled
error.signup-disabled-form: Signing up via registration form is disabled
error.login-disabled-form: Logging in via login form is disabled
error.complete-oauth-login: "Cannot complete user auth: %s"
error.oauth-unsupported: Unsupported OAuth2 provider
error.oauth-unsupported: Unsupported provider
error.cannot-bind-data: Cannot bind data
error.invalid-number: Invalid number
error.invalid-character-unescaped: Invalid character unescaped
@@ -294,7 +264,7 @@ admin.actions.sync-db: Synchronize gists from database
admin.actions.git-gc: Garbage collect all git repositories
admin.actions.sync-previews: Synchronize all gists previews
admin.actions.reset-hooks: Reset Git server hooks for all repositories
admin.actions.index-gists: Rebuild search index
admin.actions.index-gists: Index all gists
admin.actions.sync-gist-languages: Synchronize all gists languages
admin.id: ID
admin.user: User
@@ -340,7 +310,7 @@ flash.admin.sync-db: Syncing repositories from database...
flash.admin.git-gc: Garbage collecting repositories...
flash.admin.sync-previews: Syncing Gist previews...
flash.admin.reset-hooks: Resetting Git server hooks for all repositories...
flash.admin.index-gists: Rebuilding search index...
flash.admin.index-gists: Indexing all gists...
flash.admin.sync-gist-languages: Syncing Gist languages...
flash.auth.username-exists: Username already exists
@@ -352,8 +322,6 @@ flash.auth.user-sshkeys-not-created: Could not create ssh key
flash.auth.must-be-logged-in: You must be logged in to access gists
flash.auth.passkey-registred: Passkey %s registered
flash.auth.passkey-deleted: Passkey deleted
flash.auth.oauth-session-expired: OAuth2 session expired, please try again
flash.auth.oauth-already-linked: This %s account is already linked to another user
flash.gist.visibility-changed: Gist visibility has been changed
flash.gist.deleted: Gist has been deleted
@@ -376,4 +344,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

@@ -213,7 +213,7 @@ admin.invitations: 'Invitaciones'
admin.invitations.create: 'Crear invitación'
admin.actions.sync-previews: 'Sincronizar todas las vistas previas de gists'
admin.actions.reset-hooks: 'Resetear los hooks de Git en todos los repositorios'
admin.actions.index-gists: 'Reconstruir índice de búsqueda'
admin.actions.index-gists: 'Indexar todos los gists'
admin.config-link-overriden: 'sobrescrito'
admin.invitations.help: 'Las invitaciones se pueden usar para crear una cuenta aunque el registro esté deshabilitado.'
admin.invitations.max_uses: 'Cantidad máxima de usos'
@@ -231,7 +231,7 @@ flash.admin.sync-db: 'Sincronizando repositorios desde la base de datos...'
flash.admin.git-gc: 'Recolectando basura en los repositorios...'
flash.admin.sync-previews: 'Sincronizando vistas previas de gists...'
flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...'
flash.admin.index-gists: 'Reconstruyendo índice de búsqueda...'
flash.admin.index-gists: 'Indexando todos los gists...'
flash.auth.username-exists: 'El nombre de usuario ya existe'
flash.auth.invalid-credentials: 'Credenciales incorrectas'
flash.auth.account-linked-oauth: 'Cuenta vinculada a %s'

View File

@@ -193,7 +193,7 @@ admin.actions.reset-hooks: Réinitialiser les hooks de Git pour tous les dépôt
gist.new.url: URL
gist.search.no-results: Aucun gist trouvé
settings.unlink-gitlab-account: Détacher le compte GitLab
admin.actions.index-gists: Reconstruire l'index de recherche
admin.actions.index-gists: Indexer tous les gists
gist.new.preview: 'Aperçu'
gist.new.create-a-new-gist: 'Créer un nouveau gist'
gist.edit.edit-gist: 'Modifier %s'
@@ -231,7 +231,7 @@ flash.admin.sync-db: 'Synchronisation des dépôts à partir de la base de donn
flash.admin.git-gc: 'Nettoyage des dépôts...'
flash.admin.sync-previews: 'Synchronisation des aperçus du Gist...'
flash.admin.reset-hooks: 'Réinitialisation des hooks du serveur Git pour tous les dépôts...'
flash.admin.index-gists: 'Reconstruction de l''index de recherche...'
flash.admin.index-gists: 'Indexation de tous les gists...'
flash.auth.username-exists: 'Nom d''utilisateur déjà utilisé'
flash.auth.invalid-credentials: 'Identifiants non valides'
flash.auth.account-linked-oauth: 'Compte lié à %s'

View File

@@ -170,7 +170,7 @@ admin.actions.sync-db: Gistek szinkronizálása az adatbázissal
admin.actions.git-gc: Használatlan git repository-k eltávolítása
admin.actions.sync-previews: Gist előnézetek szinkronizálása
admin.actions.reset-hooks: Git server hook-ok alaphelyzetbe állítása minden repository-nál
admin.actions.index-gists: Keresési index újraépítése
admin.actions.index-gists: Gistek indexelése
admin.id: Azonosító
admin.user: Felhasználó
admin.delete: Törlés

View File

@@ -191,7 +191,7 @@ admin.actions.sync-db: 'Sincronizza gists dal database'
admin.actions.git-gc: 'Esegui la garbage collection da tutti i repositories'
admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists'
admin.actions.reset-hooks: 'Resetta tutti gli hook del server Git per tutti i repositories'
admin.actions.index-gists: 'Ricostruisci indice di ricerca'
admin.actions.index-gists: 'Indicizza tutti i gists'
admin.id: 'ID'
admin.user: 'Utente'
admin.delete: 'Elimina'
@@ -235,7 +235,7 @@ flash.admin.sync-db: 'Sincronizzando i repositories dal database...'
flash.admin.git-gc: 'Eseguendo il garbage collector dei repositories...'
flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...'
flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...'
flash.admin.index-gists: 'Ricostruzione indice di ricerca...'
flash.admin.index-gists: 'Indicizzando tutti i gists...'
flash.auth.username-exists: 'Il nome utente esiste già'
flash.auth.invalid-credentials: 'Credenziali errate'

View File

@@ -227,7 +227,7 @@ admin.actions.sync-db: 'Synchronizuj Gisty z bazy danych'
admin.actions.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git'
admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów'
admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytoriów'
admin.actions.index-gists: 'Przebuduj indeks wyszukiwania'
admin.actions.index-gists: 'Indeksuj wszystkie Gisty'
admin.id: 'ID'
admin.user: 'Użytkownik'
admin.delete: 'Usuń'
@@ -271,7 +271,7 @@ flash.admin.sync-db: 'Synchronizowanie repozytoriów z bazy danych...'
flash.admin.git-gc: 'Zbieranie śmieci w repozytoriach...'
flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...'
flash.admin.reset-hooks: 'Resetowanie hooków serwera Git dla wszystkich repozytoriów...'
flash.admin.index-gists: 'Przebudowywanie indeksu wyszukiwania...'
flash.admin.index-gists: 'Indeksowanie wszystkich Gistów...'
flash.auth.username-exists: 'Nazwa użytkownika już istnieje'
flash.auth.invalid-credentials: 'Niepoprawne dane logowania'

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

@@ -191,7 +191,7 @@ admin.actions.sync-db: Gistleri veri tabanından senkronize et
admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et
admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla
admin.actions.index-gists: Arama dizinini yeniden oluştur
admin.actions.index-gists: Tüm gistleri indeksle
admin.id: ID
admin.user: Kullanıcı
admin.delete: Sil
@@ -234,7 +234,7 @@ flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor...
flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor...
flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor...
flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor...
flash.admin.index-gists: Arama dizini yeniden oluşturuluyor...
flash.admin.index-gists: Tüm gistler indeksleniyor...
flash.auth.username-exists: Kullanıcı adı zaten mevcut
flash.auth.invalid-credentials: Geçersiz kimlik bilgileri

View File

@@ -77,7 +77,7 @@ gist.list.all-from: Всі gists від %s
gist.search.found: gists знайдено
gist.search.no-results: Не знайдено gists
gist.search.help.user: gists створені користувачем
gist.search.help.title: gists з наданим ім'ям
gist.search.help.title: gists з наданим ім'ям
gist.search.help.filename: gists мають файли з наданим ім'ям
gist.search.help.extension: gists мають файли з наданим розширенням
gist.search.help.language: gists мають файли з наданою мовою
@@ -192,7 +192,7 @@ admin.actions.sync-db: Синхронізувати gists з базою дани
admin.actions.git-gc: Збір сміття з репозиторіїв Git
admin.actions.sync-previews: Синхронізувати всі gists перегляди
admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв
admin.actions.index-gists: Перебудувати пошуковий індекс
admin.actions.index-gists: Проіндексувати всі gists
admin.id: ID
admin.user: Користувач
admin.delete: Видалити
@@ -236,7 +236,7 @@ flash.admin.sync-db: Синхронізація репозиторіїв за б
flash.admin.git-gc: Збір сміття з репозиторіїв...
flash.admin.sync-previews: Синхронізація Gist переглядів...
flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв...
flash.admin.index-gists: Перебудова пошукового індексу...
flash.admin.index-gists: Індексація всіх gists...
flash.auth.username-exists: Це ім'я користувача вже існує
flash.auth.invalid-credentials: Недійсні облікові дані
@@ -266,4 +266,4 @@ validation.should-only-contain-alphanumeric-characters-and-dashes: Поле %s
validation.not-enough: Недостатньо %s
validation.invalid: Недійсний %s
html.title.admin-panel: Панель адміністратора
html.title.admin-panel: Панель адміністратора

View File

@@ -214,7 +214,7 @@ admin.invitations: '邀请'
admin.invitations.create: '创建邀请'
admin.actions.sync-previews: '同步所有 Gists 预览'
admin.actions.reset-hooks: '重置所有存储库的 Git 服务 hooks'
admin.actions.index-gists: '重建搜索索引'
admin.actions.index-gists: '索引所有 Gists'
admin.invitations.help: '即使在禁用注册功能的情况下,邀请功能也可用于创建帐户。'
admin.invitations.max_uses: '最多使用次数'
admin.invitations.expires_at: '过期时间'
@@ -231,7 +231,7 @@ flash.admin.sync-db: '正在从数据库同步存储库...'
flash.admin.git-gc: '正在进行存储库垃圾回收...'
flash.admin.sync-previews: '正在同步 Gist 预览...'
flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...'
flash.admin.index-gists: '正在重建搜索索引...'
flash.admin.index-gists: '正在索引所有 Gists...'
flash.auth.username-exists: '用户名已存在'
flash.auth.invalid-credentials: '无效的凭证'
flash.auth.account-linked-oauth: '帐户已关联到 %s'

View File

@@ -190,7 +190,7 @@ gist.search.no-results: 沒有找到任何 Gists
gist.search.help.title: Gists 的標題
gist.search.help.filename: Gists 的檔案名稱
gist.search.help.language: Gists 的程式語言
admin.actions.index-gists: 重建搜尋索引
admin.actions.index-gists: 索引所有的 Gists
gist.search.help.user: 由使用者建立的 Gists
gist.search.found: 已找到 Gists
gist.search.help.extension: Gists 的副檔名

View File

@@ -2,21 +2,16 @@ package index
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
"github.com/blevesearch/bleve/v2/analysis/token/length"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/search/query"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
)
type BleveIndexer struct {
@@ -57,9 +52,14 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
return nil, err
}
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()
// Token filters
if err = mapping.AddCustomTokenFilter("unicodeNormalize", map[string]any{
"type": unicodenorm.Name,
"form": unicodenorm.NFC,
@@ -67,88 +67,21 @@ func (i *BleveIndexer) open() (bleve.Index, error) {
return nil, err
}
if err = mapping.AddCustomTokenFilter("lengthMin2", map[string]interface{}{
"type": length.Name,
"min": 2.0,
}); err != nil {
return nil, err
}
// Analyzer: split mode (camelCase splitting for partial search)
// "CPUCard" -> ["cpu", "card"]
if err = mapping.AddCustomAnalyzer("codeSplit", map[string]interface{}{
if err = mapping.AddCustomAnalyzer("gistAnalyser", map[string]interface{}{
"type": custom.Name,
"char_filters": []string{},
"tokenizer": unicode.Name,
"token_filters": []string{"unicodeNormalize", camelcase.Name, lowercase.Name, "lengthMin2"},
"token_filters": []string{"unicodeNormalize", camelcase.Name, lowercase.Name},
}); err != nil {
return nil, err
}
// Analyzer: exact mode (no camelCase splitting for full-word search)
// "CPUCard" -> ["cpucard"]
if err = mapping.AddCustomAnalyzer("codeExact", map[string]interface{}{
"type": custom.Name,
"char_filters": []string{},
"tokenizer": unicode.Name,
"token_filters": []string{"unicodeNormalize", lowercase.Name},
}); err != nil {
return nil, err
}
// Analyzer: keyword with lowercase (for Languages - single token, no splitting)
if err = mapping.AddCustomAnalyzer("lowercaseKeyword", map[string]interface{}{
"type": custom.Name,
"char_filters": []string{},
"tokenizer": "single",
"token_filters": []string{lowercase.Name},
}); err != nil {
return nil, err
}
// Document mapping
docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("GistID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("UserID", bleve.NewNumericFieldMapping())
docMapping.AddFieldMappingsAt("Visibility", bleve.NewNumericFieldMapping())
// Content: dual indexing (exact + split)
// "Content" uses the property name so Bleve resolves its analyzer correctly
contentExact := bleve.NewTextFieldMapping()
contentExact.Name = "Content"
contentExact.Analyzer = "codeExact"
contentExact.Store = false
contentExact.IncludeTermVectors = true
contentSplit := bleve.NewTextFieldMapping()
contentSplit.Name = "ContentSplit"
contentSplit.Analyzer = "codeSplit"
contentSplit.Store = false
contentSplit.IncludeTermVectors = true
docMapping.AddFieldMappingsAt("Content", contentExact, contentSplit)
// Languages: keyword analyzer (preserves as single token)
languageFieldMapping := bleve.NewTextFieldMapping()
languageFieldMapping.Analyzer = "lowercaseKeyword"
docMapping.AddFieldMappingsAt("Languages", languageFieldMapping)
// All other text fields use codeSplit as default
docMapping.DefaultAnalyzer = "codeSplit"
docMapping.DefaultAnalyzer = "gistAnalyser"
mapping.DefaultMapping = docMapping
return bleve.New(i.path, mapping)
}
func (i *BleveIndexer) Reset() error {
i.Close()
if err := os.RemoveAll(i.path); err != nil {
return fmt.Errorf("failed to remove Bleve index directory: %w", err)
}
log.Info().Msg("Bleve index directory removed, re-creating index")
return i.Init()
}
func (i *BleveIndexer) Close() {
if i == nil || i.index == nil {
return
@@ -172,111 +105,18 @@ func (i *BleveIndexer) Remove(gistID uint) error {
return (*atomicIndexer.Load()).(*BleveIndexer).index.Delete(strconv.Itoa(int(gistID)))
}
// Search returns a list of Gist IDs that match the given search metadata.
// The method returns an error if any.
//
// The queryMetadata parameter is used to filter the search results.
// For example, passing a non-empty Username will search for gists whose
// username matches the given string.
//
// If the "All" field in queryMetadata is non-empty, the method will
// search across all metadata fields with OR logic. Otherwise, the method
// will add each metadata field with AND logic.
//
// The page parameter is used to paginate the search results.
// The method returns the total number of search results in the second return
// value.
//
// The third return value is a map of language counts for the search results.
// The language counts are computed by asking Bleve to return the top 10
// facets for the "Languages" field.
func (i *BleveIndexer) Search(metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
func (i *BleveIndexer) Search(queryStr string, queryMetadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
var err error
var indexerQuery query.Query = bleve.NewMatchAllQuery()
// Query factory
factoryQuery := func(field, value string) query.Query {
query := bleve.NewMatchPhraseQuery(value)
query.SetField(field)
return query
}
// Exact search
addQuery := func(field, value string) {
if value != "" && value != "." {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, factoryQuery(field, value))
}
}
// Query factory for text fields: exact match boosted + match query + prefix
factoryTextQuery := func(field, value string) query.Query {
exact := bleve.NewMatchPhraseQuery(value)
exact.SetField(field)
exact.SetBoost(2.0)
fuzzy := bleve.NewMatchQuery(value)
fuzzy.SetField(field)
fuzzy.SetFuzziness(1)
fuzzy.SetOperator(query.MatchQueryOperatorAnd)
queries := []query.Query{exact, fuzzy}
if len([]rune(value)) >= 2 {
prefix := bleve.NewPrefixQuery(strings.ToLower(value))
prefix.SetField(field)
prefix.SetBoost(1.5)
queries = append(queries, prefix)
}
if len([]rune(value)) >= 4 {
wildcard := bleve.NewWildcardQuery("*" + strings.ToLower(value) + "*")
wildcard.SetField(field)
wildcard.SetBoost(0.5)
queries = append(queries, wildcard)
}
return bleve.NewDisjunctionQuery(queries...)
}
// Query factory for Content: searches both exact (Content) and split (ContentSplit) fields
factoryContentQuery := func(value string) query.Query {
// Exact field (no camelCase split): matches "cpucard"
exactMatch := bleve.NewMatchQuery(value)
exactMatch.SetField("Content")
exactMatch.SetOperator(query.MatchQueryOperatorAnd)
exactMatch.SetBoost(2.0)
// Split field (camelCase split): matches "cpu", "card"
splitMatch := bleve.NewMatchQuery(value)
splitMatch.SetField("ContentSplit")
splitMatch.SetFuzziness(1)
splitMatch.SetOperator(query.MatchQueryOperatorAnd)
splitMatch.SetBoost(1.0)
queries := []query.Query{exactMatch, splitMatch}
if len([]rune(value)) >= 2 {
prefix := bleve.NewPrefixQuery(strings.ToLower(value))
prefix.SetField("Content")
prefix.SetBoost(1.5)
queries = append(queries, prefix)
}
if len([]rune(value)) >= 4 {
wildcard := bleve.NewWildcardQuery("*" + strings.ToLower(value) + "*")
wildcard.SetField("Content")
wildcard.SetBoost(0.5)
queries = append(queries, wildcard)
}
return bleve.NewDisjunctionQuery(queries...)
}
// Text field search
addTextQuery := func(field, value string) {
if value != "" && value != "." {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, factoryTextQuery(field, value))
}
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)
indexerQuery = contentQuery
} else {
contentQuery := bleve.NewMatchAllQuery()
indexerQuery = contentQuery
}
// Visibility filtering: show public gists (Visibility=0) OR user's own gists
@@ -292,62 +132,48 @@ func (i *BleveIndexer) Search(metadata SearchGistMetadata, userId uint, page int
accessQuery := bleve.NewDisjunctionQuery(publicQuery, userIdQuery)
indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery)
buildFieldQuery := func(field, value string) query.Query {
switch field {
case "Content":
return factoryContentQuery(value)
case "Title", "Description", "Filenames":
return factoryTextQuery(field, value)
case "Extensions":
return factoryQuery(field, "."+value)
default: // Username, Languages, Topics
return factoryQuery(field, value)
}
}
// Handle "All" field - search across all metadata fields with OR logic
if metadata.All != "" {
allQueries := make([]query.Query, 0, len(AllSearchFields))
for _, field := range AllSearchFields {
allQueries = append(allQueries, buildFieldQuery(field, metadata.All))
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},
}
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, bleve.NewDisjunctionQuery(allQueries...))
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("Username", metadata.Username)
addTextQuery("Title", metadata.Title)
addTextQuery("Description", metadata.Description)
addQuery("Extensions", "."+metadata.Extension)
addTextQuery("Filenames", metadata.Filename)
addQuery("Languages", metadata.Language)
addQuery("Topics", metadata.Topic)
if metadata.Content != "" {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, factoryContentQuery(metadata.Content))
addQuery := func(field, value string) {
if value != "" && value != "." {
q := bleve.NewMatchPhraseQuery(value)
q.FieldVal = field
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, q)
}
}
// Handle default search fields from config with OR logic
if metadata.Default != "" {
var fields []string
for _, f := range strings.Split(config.C.SearchDefault, ",") {
f = strings.TrimSpace(f)
if f == "all" {
fields = AllSearchFields
break
}
if indexField, ok := SearchFieldMap[f]; ok {
fields = append(fields, indexField)
}
}
if len(fields) == 1 {
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, buildFieldQuery(fields[0], metadata.Default))
} else if len(fields) > 1 {
defaultQueries := make([]query.Query, 0, len(fields))
for _, field := range fields {
defaultQueries = append(defaultQueries, buildFieldQuery(field, metadata.Default))
}
indexerQuery = bleve.NewConjunctionQuery(indexerQuery, bleve.NewDisjunctionQuery(defaultQueries...))
}
}
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)
@@ -360,8 +186,6 @@ func (i *BleveIndexer) Search(metadata SearchGistMetadata, userId uint, page int
s.Fields = []string{"GistID"}
s.IncludeLocations = false
log.Debug().Interface("searchRequest", s).Msg("Bleve search request")
results, err := (*atomicIndexer.Load()).(*BleveIndexer).index.Search(s)
if err != nil {
return nil, 0, nil, err

View File

@@ -4,31 +4,33 @@ import (
"os"
"path/filepath"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
)
// setupBleveIndexer creates a new BleveIndexer for testing
func setupBleveIndexer(t *testing.T) (Indexer, func()) {
zerolog.SetGlobalLevel(zerolog.Disabled)
func setupBleveIndexer(t *testing.T) (*BleveIndexer, func()) {
t.Helper()
// Create a temporary directory for the test index
tmpDir, err := os.MkdirTemp("", "bleve-test-*")
require.NoError(t, err)
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()
@@ -38,50 +40,123 @@ func setupBleveIndexer(t *testing.T) (Indexer, func()) {
return indexer, cleanup
}
func TestBleveAddAndSearch(t *testing.T) { testAddAndSearch(t, setupBleveIndexer) }
func TestBleveAccessControl(t *testing.T) { testAccessControl(t, setupBleveIndexer) }
func TestBleveMetadataFilters(t *testing.T) { testMetadataFilters(t, setupBleveIndexer) }
func TestBleveAllFieldSearch(t *testing.T) { testAllFieldSearch(t, setupBleveIndexer) }
func TestBleveFuzzySearch(t *testing.T) { testFuzzySearch(t, setupBleveIndexer) }
func TestBleveContentSearch(t *testing.T) { testContentSearch(t, setupBleveIndexer) }
func TestBlevePagination(t *testing.T) { testPagination(t, setupBleveIndexer) }
func TestBleveLanguageFacets(t *testing.T) { testLanguageFacets(t, setupBleveIndexer) }
func TestBleveWildcardSearch(t *testing.T) { testWildcardSearch(t, setupBleveIndexer) }
func TestBleveMetadataOnlySearch(t *testing.T) { testMetadataOnlySearch(t, setupBleveIndexer) }
func TestBleveTitleFuzzySearch(t *testing.T) { testTitleFuzzySearch(t, setupBleveIndexer) }
func TestBleveMultiLanguageFacets(t *testing.T) { testMultiLanguageFacets(t, setupBleveIndexer) }
func TestBleveIndexerAddGist(t *testing.T) {
indexer, cleanup := setupBleveIndexer(t)
defer cleanup()
func TestBlevePersistence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bleve-persist-test-*")
require.NoError(t, err)
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)
// Create and populate index
indexer1 := NewBleveIndexer(indexPath)
require.NoError(t, indexer1.Init())
// Test initialization
err = indexer.Init()
if err != nil {
t.Fatalf("Failed to initialize BleveIndexer: %v", err)
}
var idx Indexer = indexer1
atomicIndexer.Store(&idx)
if indexer.index == nil {
t.Fatal("Expected index to be initialized, got nil")
}
g := newGist(1, 1, 0, "persistent data survives restart")
require.NoError(t, indexer1.Add(g))
// Test closing
indexer.Close()
indexer1.Close()
atomicIndexer.Store(nil)
// Reopen at same path
// Test reopening the same index
indexer2 := NewBleveIndexer(indexPath)
require.NoError(t, indexer2.Init())
err = indexer2.Init()
if err != nil {
t.Fatalf("Failed to reopen BleveIndexer: %v", err)
}
defer indexer2.Close()
idx = indexer2
atomicIndexer.Store(&idx)
defer atomicIndexer.Store(nil)
ids, total, _, err := indexer2.Search(SearchGistMetadata{Content: "persistent"}, 1, 1)
require.NoError(t, err)
require.Equal(t, uint64(1), total, "data should survive close+reopen")
require.Equal(t, uint(1), ids[0])
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

@@ -1,43 +1,26 @@
package index
var AllSearchFields = []string{"Username", "Title", "Description", "Filenames", "Extensions", "Languages", "Topics", "Content"}
var SearchFieldMap = map[string]string{
"user": "Username",
"title": "Title",
"description": "Description",
"filename": "Filenames",
"extension": "Extensions",
"language": "Languages",
"topic": "Topics",
"content": "Content",
}
type Gist struct {
GistID uint
UserID uint
Visibility uint
Username string
Description string
Title string
Content string
Filenames []string
Extensions []string
Languages []string
Topics []string
CreatedAt int64
UpdatedAt int64
GistID uint
UserID uint
Visibility uint
Username string
Title string
Content string
Filenames []string
Extensions []string
Languages []string
Topics []string
CreatedAt int64
UpdatedAt int64
}
type SearchGistMetadata struct {
Username string
Title string
Description string
Content string
Filename string
Extension string
Language string
Topic string
All string
Default string
Username string
Title string
Filename string
Extension string
Language string
Topic string
All string
}

View File

@@ -2,11 +2,10 @@ package index
import (
"fmt"
"path/filepath"
"sync/atomic"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"path/filepath"
"sync/atomic"
)
var atomicIndexer atomic.Pointer[Indexer]
@@ -14,10 +13,9 @@ var atomicIndexer atomic.Pointer[Indexer]
type Indexer interface {
Init() error
Close()
Reset() error
Add(gist *Gist) error
Remove(gistID uint) error
Search(metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error)
Search(query string, metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error)
}
type IndexerType string
@@ -86,19 +84,6 @@ func Close() {
atomicIndexer.Store(nil)
}
func ResetIndex() error {
if !IndexEnabled() {
return nil
}
idx := atomicIndexer.Load()
if idx == nil {
return fmt.Errorf("indexer is not initialized")
}
return (*idx).Reset()
}
func AddInIndex(gist *Gist) error {
if !IndexEnabled() {
return nil
@@ -125,11 +110,7 @@ func RemoveFromIndex(gistID uint) error {
return (*idx).Remove(gistID)
}
// SearchGists returns a list of Gist IDs that match the given search metadata.
// If the indexer is not enabled, it returns nil, 0, nil, nil.
// If the indexer is not initialized, it returns nil, 0, nil, fmt.Errorf("indexer is not initialized").
// The function returns an error if any.
func SearchGists(metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
func SearchGists(query string, metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
if !IndexEnabled() {
return nil, 0, nil, nil
}
@@ -139,7 +120,7 @@ func SearchGists(metadata SearchGistMetadata, userId uint, page int) ([]uint, ui
return nil, 0, nil, fmt.Errorf("indexer is not initialized")
}
return (*idx).Search(metadata, userId, page)
return (*idx).Search(query, metadata, userId, page)
}
func DepreactionIndexDirname() {

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,9 @@ import (
"fmt"
"strconv"
"strings"
"unicode"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
)
type MeiliIndexer struct {
@@ -52,45 +50,28 @@ func (i *MeiliIndexer) open() (meilisearch.IndexManager, error) {
i.client = meilisearch.New(i.host, meilisearch.WithAPIKey(i.apikey))
indexResult, err := i.client.GetIndex(i.indexName)
if indexResult == nil || err != nil {
_, err = i.client.CreateIndex(&meilisearch.IndexConfig{
Uid: i.indexName,
PrimaryKey: "GistID",
})
if err != nil {
return nil, err
}
if indexResult != nil && err == nil {
return indexResult.IndexManager, nil
}
_, err = i.client.CreateIndex(&meilisearch.IndexConfig{
Uid: i.indexName,
PrimaryKey: "GistID",
})
if err != nil {
return nil, err
}
_, _ = i.client.Index(i.indexName).UpdateSettings(&meilisearch.Settings{
FilterableAttributes: []string{"GistID", "UserID", "Visibility", "Username", "Extensions", "Languages", "Topics"},
SearchableAttributes: []string{"Content", "ContentSplit", "Username", "Title", "Description", "Filenames", "Extensions", "Languages", "Topics"},
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
TypoTolerance: &meilisearch.TypoTolerance{
Enabled: true,
DisableOnNumbers: true,
MinWordSizeForTypos: meilisearch.MinWordSizeForTypos{OneTypo: 4, TwoTypos: 10},
},
FilterableAttributes: []string{"GistID", "UserID", "Visibility", "Username", "Title", "Filenames", "Extensions", "Languages", "Topics"},
DisplayedAttributes: []string{"GistID"},
SearchableAttributes: []string{"Content", "Username", "Title", "Filenames", "Extensions", "Languages", "Topics"},
RankingRules: []string{"words"},
})
return i.client.Index(i.indexName), nil
}
func (i *MeiliIndexer) Reset() error {
if i.client != nil {
taskInfo, err := i.client.DeleteIndex(i.indexName)
if err != nil {
return fmt.Errorf("failed to delete Meilisearch index: %w", err)
}
_, err = i.client.WaitForTask(taskInfo.TaskUID, 0)
if err != nil {
return fmt.Errorf("failed to wait for Meilisearch index deletion: %w", err)
}
log.Info().Msg("Meilisearch index deleted, re-creating index")
}
return i.Init()
}
func (i *MeiliIndexer) Close() {
if i.client != nil {
i.client.Close()
@@ -99,21 +80,12 @@ func (i *MeiliIndexer) Close() {
i.client = nil
}
type meiliGist struct {
Gist
ContentSplit string
}
func (i *MeiliIndexer) Add(gist *Gist) error {
if gist == nil {
return errors.New("failed to add nil gist to index")
}
doc := &meiliGist{
Gist: *gist,
ContentSplit: splitCamelCase(gist.Content),
}
primaryKey := "GistID"
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(doc, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
return err
}
@@ -122,14 +94,13 @@ func (i *MeiliIndexer) Remove(gistID uint) error {
return err
}
func (i *MeiliIndexer) Search(queryMetadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
func (i *MeiliIndexer) Search(queryStr string, queryMetadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
searchRequest := &meilisearch.SearchRequest{
Offset: int64((page - 1) * 10),
Limit: 11,
AttributesToRetrieve: []string{"GistID", "Languages"},
Facets: []string{"Languages"},
AttributesToSearchOn: []string{"Content", "ContentSplit"},
MatchingStrategy: meilisearch.All,
AttributesToSearchOn: []string{"Content"},
}
var filters []string
@@ -140,83 +111,23 @@ func (i *MeiliIndexer) Search(queryMetadata SearchGistMetadata, userId uint, pag
filters = append(filters, fmt.Sprintf("%s = \"%s\"", field, escapeFilterValue(value)))
}
}
var query string
if queryMetadata.All != "" {
query = queryMetadata.All
searchRequest.AttributesToSearchOn = append(AllSearchFields, "ContentSplit")
} else {
// Exact-match fields stay as filters
addFilter("Username", queryMetadata.Username)
if queryMetadata.Extension != "" {
ext := queryMetadata.Extension
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
addFilter("Extensions", ext)
}
addFilter("Languages", queryMetadata.Language)
addFilter("Topics", queryMetadata.Topic)
if queryMetadata.Default != "" {
query = queryMetadata.Default
var fields []string
for _, f := range strings.Split(config.C.SearchDefault, ",") {
f = strings.TrimSpace(f)
if f == "all" {
fields = AllSearchFields
break
}
if indexField, ok := SearchFieldMap[f]; ok {
fields = append(fields, indexField)
}
}
if len(fields) > 0 {
for _, f := range fields {
if f == "Content" {
fields = append(fields, "ContentSplit")
break
}
}
searchRequest.AttributesToSearchOn = fields
}
} else {
// Fuzzy-matchable fields become part of the query
var queryParts []string
var searchFields []string
if queryMetadata.Content != "" {
queryParts = append(queryParts, queryMetadata.Content)
searchFields = append(searchFields, "Content", "ContentSplit")
}
if queryMetadata.Title != "" {
queryParts = append(queryParts, queryMetadata.Title)
searchFields = append(searchFields, "Title")
}
if queryMetadata.Description != "" {
queryParts = append(queryParts, queryMetadata.Description)
searchFields = append(searchFields, "Description")
}
if queryMetadata.Filename != "" {
queryParts = append(queryParts, queryMetadata.Filename)
searchFields = append(searchFields, "Filenames")
}
query = strings.Join(queryParts, " ")
if len(searchFields) > 0 {
searchRequest.AttributesToSearchOn = searchFields
}
}
}
addFilter("Username", queryMetadata.Username)
addFilter("Title", queryMetadata.Title)
addFilter("Filenames", queryMetadata.Filename)
addFilter("Extensions", queryMetadata.Extension)
addFilter("Languages", queryMetadata.Language)
addFilter("Topics", queryMetadata.Topic)
if len(filters) > 0 {
searchRequest.Filter = strings.Join(filters, " AND ")
}
response, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.Search(query, searchRequest)
response, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.Search(queryStr, searchRequest)
if err != nil {
log.Error().Err(err).Msg("Failed to search Meilisearch index")
return nil, 0, nil, err
}
gistIds := make([]uint, 0, len(response.Hits))
for _, hit := range response.Hits {
if gistIDRaw, ok := hit["GistID"]; ok {
@@ -232,9 +143,7 @@ func (i *MeiliIndexer) Search(queryMetadata SearchGistMetadata, userId uint, pag
var facetDist map[string]map[string]int
if err := json.Unmarshal(response.FacetDistribution, &facetDist); err == nil {
if facets, ok := facetDist["Languages"]; ok {
for lang, count := range facets {
languageCounts[strings.ToLower(lang)] += count
}
languageCounts = facets
}
}
}
@@ -242,30 +151,6 @@ func (i *MeiliIndexer) Search(queryMetadata SearchGistMetadata, userId uint, pag
return gistIds, uint64(response.EstimatedTotalHits), languageCounts, nil
}
func splitCamelCase(text string) string {
var result strings.Builder
runes := []rune(text)
for i := 0; i < len(runes); i++ {
r := runes[i]
if i > 0 {
prev := runes[i-1]
if unicode.IsUpper(r) {
if unicode.IsLower(prev) || unicode.IsDigit(prev) {
result.WriteRune(' ')
} else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
result.WriteRune(' ')
}
} else if unicode.IsDigit(r) && !unicode.IsDigit(prev) {
result.WriteRune(' ')
} else if !unicode.IsDigit(r) && unicode.IsDigit(prev) {
result.WriteRune(' ')
}
}
result.WriteRune(r)
}
return result.String()
}
func escapeFilterValue(value string) string {
escaped := strings.ReplaceAll(value, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")

View File

@@ -1,88 +0,0 @@
package index
import (
"fmt"
"os"
"strconv"
"testing"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog"
)
// syncMeiliIndexer wraps MeiliIndexer to make Add/Remove synchronous for tests.
type syncMeiliIndexer struct {
*MeiliIndexer
}
func (s *syncMeiliIndexer) Add(gist *Gist) error {
if gist == nil {
return fmt.Errorf("failed to add nil gist to index")
}
doc := &meiliGist{
Gist: *gist,
ContentSplit: splitCamelCase(gist.Content),
}
primaryKey := "GistID"
taskInfo, err := s.index.AddDocuments(doc, &meilisearch.DocumentOptions{PrimaryKey: &primaryKey})
if err != nil {
return err
}
_, err = s.client.WaitForTask(taskInfo.TaskUID, 0)
return err
}
func (s *syncMeiliIndexer) Remove(gistID uint) error {
taskInfo, err := s.index.DeleteDocument(strconv.Itoa(int(gistID)), nil)
if err != nil {
return err
}
_, err = s.client.WaitForTask(taskInfo.TaskUID, 0)
return err
}
func setupMeiliIndexer(t *testing.T) (Indexer, func()) {
zerolog.SetGlobalLevel(zerolog.Disabled)
t.Helper()
host := os.Getenv("OG_TEST_MEILI_HOST")
if host == "" {
host = "http://localhost:47700"
}
apiKey := os.Getenv("OG_TEST_MEILI_API_KEY")
indexName := fmt.Sprintf("test_%d", os.Getpid())
inner := NewMeiliIndexer(host, apiKey, indexName)
err := inner.Init()
if err != nil {
t.Skipf("MeiliSearch not available at %s: %v", host, err)
}
wrapped := &syncMeiliIndexer{MeiliIndexer: inner}
// Store the inner MeiliIndexer in atomicIndexer, because MeiliIndexer.Search
// type-asserts the global to *MeiliIndexer.
var idx Indexer = inner
atomicIndexer.Store(&idx)
cleanup := func() {
atomicIndexer.Store(nil)
inner.Reset()
inner.Close()
}
return wrapped, cleanup
}
func TestMeiliAddAndSearch(t *testing.T) { testAddAndSearch(t, setupMeiliIndexer) }
func TestMeiliAccessControl(t *testing.T) { testAccessControl(t, setupMeiliIndexer) }
func TestMeiliMetadataFilters(t *testing.T) { testMetadataFilters(t, setupMeiliIndexer) }
func TestMeiliAllFieldSearch(t *testing.T) { testAllFieldSearch(t, setupMeiliIndexer) }
func TestMeiliFuzzySearch(t *testing.T) { testFuzzySearch(t, setupMeiliIndexer) }
func TestMeiliContentSearch(t *testing.T) { testContentSearch(t, setupMeiliIndexer) }
func TestMeiliPagination(t *testing.T) { testPagination(t, setupMeiliIndexer) }
func TestMeiliLanguageFacets(t *testing.T) { testLanguageFacets(t, setupMeiliIndexer) }
func TestMeiliMetadataOnlySearch(t *testing.T) { testMetadataOnlySearch(t, setupMeiliIndexer) }
func TestMeiliTitleFuzzySearch(t *testing.T) { testTitleFuzzySearch(t, setupMeiliIndexer) }
func TestMeiliMultiLanguageFacets(t *testing.T) { testMultiLanguageFacets(t, setupMeiliIndexer) }

View File

@@ -27,9 +27,8 @@ func (r HighlightedFile) InternalType() string {
type RenderedGist struct {
*db.Gist
Lines []string
HTML string
PreviewMimeType *git.MimeType
Lines []string
HTML string
}
func highlightFile(file *git.File) (HighlightedFile, error) {
@@ -77,18 +76,6 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
Gist: gist,
}
if gist.PreviewMimeType != "" {
mt := &git.MimeType{ContentType: gist.PreviewMimeType}
if mt.CanBeEmbedded() {
rendered.PreviewMimeType = mt
return rendered, nil
}
}
if gist.Preview == "" {
return rendered, nil
}
style := newStyle()
lexer := newLexer(gist.PreviewFilename)
if lexer.Config().Name == "markdown" {

View File

@@ -59,7 +59,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
name := fl.Field().String()
restrictedNames := map[string]struct{}{}
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} {
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn"} {
restrictedNames[restrictedName] = struct{}{}
}

View File

@@ -1,46 +0,0 @@
package admin_test
import (
"testing"
"github.com/stretchr/testify/require"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestAdminActions(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
urls := []string{
"/admin-panel/sync-fs",
"/admin-panel/sync-db",
"/admin-panel/gc-repos",
"/admin-panel/sync-previews",
"/admin-panel/reset-hooks",
"/admin-panel/index-gists",
"/admin-panel/sync-languages",
}
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("NoUser", func(t *testing.T) {
for _, url := range urls {
s.Request(t, "POST", url, nil, 404)
}
})
t.Run("AdminUser", func(t *testing.T) {
s.Login(t, "thomas")
for _, url := range urls {
resp := s.Request(t, "POST", url, nil, 302)
require.Equal(t, "/admin-panel", resp.Header.Get("Location"))
}
})
t.Run("NonAdminUser", func(t *testing.T) {
s.Login(t, "nonadmin")
for _, url := range urls {
s.Request(t, "POST", url, nil, 404)
}
})
}

View File

@@ -1,269 +0,0 @@
package admin_test
import (
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestAdminPages(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
urls := []string{
"/admin-panel",
"/admin-panel/users",
"/admin-panel/gists",
"/admin-panel/invitations",
"/admin-panel/configuration",
}
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("NoUser", func(t *testing.T) {
for _, url := range urls {
s.Request(t, "GET", url, nil, 404)
}
})
t.Run("AdminUser", func(t *testing.T) {
s.Login(t, "thomas")
for _, url := range urls {
s.Request(t, "GET", url, nil, 200)
}
})
t.Run("NonAdminUser", func(t *testing.T) {
s.Login(t, "nonadmin")
for _, url := range urls {
s.Request(t, "GET", url, nil, 404)
}
})
}
func TestAdminSetConfig(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
settings := []string{
db.SettingDisableSignup,
db.SettingRequireLogin,
db.SettingAllowGistsWithoutLogin,
db.SettingDisableLoginForm,
db.SettingDisableGravatar,
}
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("NoUser", func(t *testing.T) {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
})
t.Run("NonAdminUser", func(t *testing.T) {
s.Login(t, "nonadmin")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
})
t.Run("AdminUser", func(t *testing.T) {
s.Login(t, "thomas")
for _, setting := range settings {
val, err := db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "0", val)
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200)
val, err = db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "1", val)
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200)
val, err = db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "0", val)
}
})
}
func TestAdminPagination(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
for i := 0; i < 11; i++ {
s.Register(t, "user"+strconv.Itoa(i))
}
t.Run("Pagination", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "GET", "/admin-panel/users", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=2", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=3", nil, 404)
s.Request(t, "GET", "/admin-panel/users?page=0", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=-1", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=a", nil, 200)
})
}
func TestAdminUserOperations(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("DeleteUser", func(t *testing.T) {
s.Login(t, "nonadmin")
gist1 := db.GistDTO{
Title: "gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt"},
Content: []string{"yeah"},
Topics: "",
}
s.Request(t, "POST", "/", gist1, 302)
_, err := os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin"))
require.NoError(t, err)
count, err := db.CountAll(db.User{})
require.NoError(t, err)
require.Equal(t, int64(2), count)
s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 404)
s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 302)
count, err = db.CountAll(db.User{})
require.NoError(t, err)
require.Equal(t, int64(1), count)
_, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin"))
require.Error(t, err)
})
}
func TestAdminGistOperations(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("DeleteGist", func(t *testing.T) {
s.Login(t, "nonadmin")
gist1 := db.GistDTO{
Title: "gist",
VisibilityDTO: db.VisibilityDTO{
Private: 0,
},
Name: []string{"gist1.txt"},
Content: []string{"yeah"},
Topics: "",
}
s.Request(t, "POST", "/", gist1, 302)
count, err := db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(1), count)
gist1Db, err := db.GetGistByID("1")
require.NoError(t, err)
_, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier()))
require.NoError(t, err)
s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 404)
s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 302)
count, err = db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(0), count)
_, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier()))
require.Error(t, err)
})
}
func TestAdminInvitationOperations(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("Invitation", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {""},
"expiredAtUnix": {""},
}, 302)
invitation1, err := db.GetInvitationByID(1)
require.NoError(t, err)
require.Equal(t, uint(1), invitation1.ID)
require.Equal(t, uint(0), invitation1.NbUsed)
require.Equal(t, uint(10), invitation1.NbMax)
require.InDelta(t, time.Now().Unix()+604800, invitation1.ExpiresAt, 10)
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {"aa"},
"expiredAtUnix": {"1735722000"},
}, 302)
invitation2, err := db.GetInvitationByID(2)
require.NoError(t, err)
require.Equal(t, invitation2, &db.Invitation{
ID: 2,
Code: invitation2.Code,
ExpiresAt: time.Unix(1735722000, 0).Unix(),
NbUsed: 0,
NbMax: 10,
})
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {"20"},
"expiredAtUnix": {"1735722000"},
}, 302)
invitation3, err := db.GetInvitationByID(3)
require.NoError(t, err)
require.Equal(t, invitation3, &db.Invitation{
ID: 3,
Code: invitation3.Code,
ExpiresAt: time.Unix(1735722000, 0).Unix(),
NbUsed: 0,
NbMax: 20,
})
count, err := db.CountAll(db.Invitation{})
require.NoError(t, err)
require.Equal(t, int64(3), count)
s.Request(t, "POST", "/admin-panel/invitations/1/delete", nil, 302)
count, err = db.CountAll(db.Invitation{})
require.NoError(t, err)
require.Equal(t, int64(2), count)
})
}

View File

@@ -1 +0,0 @@
package auth_test

View File

@@ -4,15 +4,16 @@ import (
"crypto/md5"
"errors"
"fmt"
"slices"
"strings"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/oauth"
"github.com/thomiceli/opengist/internal/config"
"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"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gorm.io/gorm"
)
@@ -47,8 +48,7 @@ func Oauth(ctx *context.Context) error {
provider, err := oauth.DefineProvider(providerStr, opengistUrl)
if err != nil {
ctx.AddFlash(ctx.Tr("error.oauth-unsupported"), "error")
return ctx.Redirect(302, "/login")
return ctx.ErrorRes(400, ctx.Tr("error.oauth-unsupported"), nil)
}
if err = provider.RegisterProvider(); err != nil {
@@ -62,37 +62,28 @@ func Oauth(ctx *context.Context) error {
func OauthCallback(ctx *context.Context) error {
provider, err := oauth.CompleteUserAuth(ctx)
if err != nil {
ctx.AddFlash(ctx.Tr("auth.oauth.no-provider"), "error")
return ctx.Redirect(302, "/login")
return ctx.ErrorRes(400, ctx.Tr("error.complete-oauth-login", err.Error()), err)
}
currUser := ctx.User
user := provider.GetProviderUser()
// if user is logged in, link account to user and update its avatar URL
if currUser != nil {
// check if this OAuth account is already linked to another user
if existingUser, err := db.GetUserByProvider(user.UserID, provider.GetProvider()); err == nil && existingUser != nil {
ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error")
return ctx.RedirectTo("/settings")
}
provider.UpdateUserDB(currUser)
if err = currUser.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot update user "+config.C.OIDCProviderName+" id", err)
return ctx.ErrorRes(500, "Cannot update user "+cases.Title(language.English).String(provider.GetProvider())+" id", err)
}
ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", config.C.OIDCProviderName), "success")
ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", cases.Title(language.English).String(provider.GetProvider())), "success")
return ctx.RedirectTo("/settings")
}
user := provider.GetProviderUser()
userDB, err := db.GetUserByProvider(user.UserID, provider.GetProvider())
// if user is not in database, redirect to OAuth registration page
// if user is not in database, create it
if err != nil {
if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
return ctx.ErrorRes(403, ctx.Tr("error.signup-disabled"), nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -103,174 +94,79 @@ func OauthCallback(ctx *context.Context) error {
user.NickName = strings.Split(user.Email, "@")[0]
}
sess := ctx.GetSession()
sess.Values["oauthProvider"] = provider.GetProvider()
sess.Values["oauthUserID"] = user.UserID
sess.Values["oauthNickname"] = user.NickName
sess.Values["oauthEmail"] = user.Email
sess.Values["oauthAvatarURL"] = user.AvatarURL
sess.Values["oauthIsAdmin"] = provider.IsAdmin()
sess.Options.MaxAge = 10 * 60 // 10 minutes
ctx.SaveSession(sess)
return ctx.RedirectTo("/oauth/register")
}
// promote user to admin from oidc group
if !userDB.IsAdmin && provider.IsAdmin() {
userDB.IsAdmin = true
if err = userDB.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot set user admin", err)
userDB = &db.User{
Username: user.NickName,
Email: user.Email,
MD5Hash: fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(user.Email))))),
}
}
sess := ctx.GetSession()
sess.Values["user"] = userDB.ID
ctx.SaveSession(sess)
ctx.DeleteCsrfCookie()
// set provider id and avatar URL
provider.UpdateUserDB(userDB)
return ctx.RedirectTo("/")
}
if err = userDB.Create(); err != nil {
if db.IsUniqueConstraintViolation(err) {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
return ctx.RedirectTo("/login")
}
func OauthRegister(ctx *context.Context) error {
if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
}
return ctx.ErrorRes(500, "Cannot create user", err)
}
sess := ctx.GetSession()
// if oidc admin group is not configured set first user as admin
if config.C.OIDCAdminGroup == "" && userDB.ID == 1 {
if err = userDB.SetAdmin(); err != nil {
return ctx.ErrorRes(500, "Cannot set user admin", err)
}
}
ctx.SetData("title", ctx.TrH("auth.oauth.complete-registration"))
ctx.SetData("htmlTitle", ctx.TrH("auth.oauth.complete-registration"))
ctx.SetData("oauthProvider", config.C.OIDCProviderName)
ctx.SetData("oauthNickname", sess.Values["oauthNickname"])
ctx.SetData("oauthEmail", sess.Values["oauthEmail"])
ctx.SetData("oauthAvatarURL", sess.Values["oauthAvatarURL"])
return ctx.Html("oauth_register.html")
}
func ProcessOauthRegister(ctx *context.Context) error {
if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
}
sess := ctx.GetSession()
providerStr := sess.Values["oauthProvider"].(string)
oauthUserID := sess.Values["oauthUserID"].(string)
setOauthRegisterData := func(dto *db.OAuthRegisterDTO) {
ctx.SetData("title", ctx.TrH("auth.oauth.complete-registration"))
ctx.SetData("htmlTitle", ctx.TrH("auth.oauth.complete-registration"))
ctx.SetData("oauthProvider", config.C.OIDCProviderName)
if dto != nil {
ctx.SetData("oauthNickname", dto.Username)
ctx.SetData("oauthEmail", dto.Email)
keys, err := provider.GetProviderUserSSHKeys()
if err != nil {
ctx.AddFlash(ctx.Tr("flash.auth.user-sshkeys-not-retrievable"), "error")
log.Error().Err(err).Msg("Could not get user keys")
} else {
ctx.SetData("oauthNickname", sess.Values["oauthNickname"])
ctx.SetData("oauthEmail", sess.Values["oauthEmail"])
}
ctx.SetData("oauthAvatarURL", sess.Values["oauthAvatarURL"])
}
for _, key := range keys {
sshKey := db.SSHKey{
Title: "Added from " + user.Provider,
Content: key,
User: *userDB,
}
// Bind and validate form data
dto := new(db.OAuthRegisterDTO)
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")
setOauthRegisterData(dto)
return ctx.Html("oauth_register.html")
}
if exists, err := db.UserExists(dto.Username); err != nil || exists {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
setOauthRegisterData(dto)
return ctx.Html("oauth_register.html")
}
// Check if OAuth account is already linked to another user (race condition protection)
if existingUser, err := db.GetUserByProvider(oauthUserID, providerStr); err == nil && existingUser != nil {
ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error")
setOauthRegisterData(dto)
return ctx.Html("oauth_register.html")
}
userDB := &db.User{
Username: dto.Username,
Email: dto.Email,
}
if dto.Email != "" {
userDB.MD5Hash = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(dto.Email)))))
}
nickname := ""
if n, ok := sess.Values["oauthNickname"].(string); ok {
nickname = n
}
avatarURL := ""
if av, ok := sess.Values["oauthAvatarURL"].(string); ok {
avatarURL = av
}
callbackProvider, err := oauth.NewCallbackProviderFromSession(providerStr, oauthUserID, nickname, dto.Email, avatarURL)
if err != nil {
return ctx.ErrorRes(500, "Cannot create provider", err)
}
callbackProvider.UpdateUserDB(userDB)
if err := userDB.Create(); err != nil {
if db.IsUniqueConstraintViolation(err) {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
setOauthRegisterData(dto)
return ctx.Html("oauth_register.html")
}
return ctx.ErrorRes(500, "Cannot create user", err)
}
if config.C.OIDCAdminGroup == "" && userDB.ID == 1 {
if err := userDB.SetAdmin(); err != nil {
return ctx.ErrorRes(500, "Cannot set user admin", err)
}
}
if isAdmin, ok := sess.Values["oauthIsAdmin"].(bool); ok && isAdmin {
userDB.IsAdmin = true
_ = userDB.Update()
}
keys, err := callbackProvider.GetProviderUserSSHKeys()
if err != nil {
ctx.AddFlash(ctx.Tr("flash.auth.user-sshkeys-not-retrievable"), "error")
log.Error().Err(err).Msg("Could not get user keys")
} else {
for _, key := range keys {
sshKey := db.SSHKey{
Title: "Added from " + providerStr,
Content: key,
User: *userDB,
}
if err = sshKey.Create(); err != nil {
ctx.AddFlash(ctx.Tr("flash.auth.user-sshkeys-not-created"), "error")
log.Error().Err(err).Msg("Could not create ssh key")
if err = sshKey.Create(); err != nil {
ctx.AddFlash(ctx.Tr("flash.auth.user-sshkeys-not-created"), "error")
log.Error().Err(err).Msg("Could not create ssh key")
}
}
}
}
delete(sess.Values, "oauthProvider")
delete(sess.Values, "oauthUserID")
delete(sess.Values, "oauthNickname")
delete(sess.Values, "oauthEmail")
delete(sess.Values, "oauthAvatarURL")
delete(sess.Values, "oauthIsAdmin")
// update is admin status from oidc group
if config.C.OIDCAdminGroup != "" {
groupClaimName := config.C.OIDCGroupClaimName
if groupClaimName == "" {
log.Error().Msg("No OIDC group claim name configured")
} else if groups, ok := user.RawData[groupClaimName].([]interface{}); ok {
var groupNames []string
for _, group := range groups {
if groupName, ok := group.(string); ok {
groupNames = append(groupNames, groupName)
}
}
isOIDCAdmin := slices.Contains(groupNames, config.C.OIDCAdminGroup)
log.Debug().Bool("isOIDCAdmin", isOIDCAdmin).Str("user", user.Name).Msg("User is in admin group")
if userDB.IsAdmin != isOIDCAdmin {
userDB.IsAdmin = isOIDCAdmin
if err = userDB.Update(); err != nil {
return ctx.ErrorRes(500, "Cannot set user admin", err)
}
}
} else {
log.Error().Msg("No groups found in user data")
}
}
sess := ctx.GetSession()
sess.Values["user"] = userDB.ID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
ctx.SaveSession(sess)
ctx.DeleteCsrfCookie()
@@ -288,10 +184,10 @@ func OauthUnlink(ctx *context.Context) error {
if provider.UserHasProvider(currUser) {
if err := currUser.DeleteProviderID(providerStr); err != nil {
return ctx.ErrorRes(500, "Cannot unlink account from "+config.C.OIDCProviderName, err)
return ctx.ErrorRes(500, "Cannot unlink account from "+cases.Title(language.English).String(providerStr), err)
}
ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", config.C.OIDCProviderName), "success")
ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", cases.Title(language.English).String(providerStr)), "success")
return ctx.RedirectTo("/settings")
}

View File

@@ -1,221 +0,0 @@
package auth_test
import (
"net/url"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestRegisterPage(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("Form", func(t *testing.T) {
s.Request(t, "GET", "/register", nil, 200)
s.TestCtxData(t, echo.Map{
"isLoginPage": false,
"disableForm": false,
"disableSignup": false,
})
})
t.Run("FormDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "GET", "/register", nil, 200)
s.TestCtxData(t, echo.Map{
"disableSignup": true,
})
})
t.Run("FormDisabledWithInviteCode", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {"10"},
"expiredAtUnix": {""},
}, 302)
invitation, err := db.GetInvitationByID(1)
require.NoError(t, err)
s.Logout()
s.Request(t, "GET", "/register", nil, 200)
s.TestCtxData(t, echo.Map{
"disableSignup": true,
})
s.Request(t, "GET", "/register?code="+invitation.Code, nil, 200)
s.TestCtxData(t, echo.Map{
"disableSignup": false,
})
})
}
func TestProcessRegister(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Register", func(t *testing.T) {
user, err := db.GetUserByUsername("thomas")
require.NoError(t, err)
require.True(t, user.IsAdmin)
s.Logout()
s.Request(t, "POST", "/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302)
user, err = db.GetUserByUsername("seconduser")
require.NoError(t, err)
require.False(t, user.IsAdmin)
s.Logout()
})
t.Run("DuplicateUsername", func(t *testing.T) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302)
s.Logout()
s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200)
s.Logout()
})
t.Run("InvalidUsername", func(t *testing.T) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "", Password: "password123"}, 200)
s.Request(t, "POST", "/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200)
})
t.Run("EmptyPassword", func(t *testing.T) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "newuser", Password: ""}, 200)
})
t.Run("RegisterDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "POST", "/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403)
exists, err := db.UserExists("blocked")
require.NoError(t, err)
require.False(t, exists)
})
t.Run("RegisterWithInvitationCode", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
"nbMax": {"10"},
"expiredAtUnix": {""},
}, 302)
s.Logout()
invitations, err := db.GetAllInvitations()
require.NoError(t, err)
require.NotEmpty(t, invitations)
invitation := invitations[len(invitations)-1]
s.Logout()
s.Request(t, "POST", "/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302)
user, err := db.GetUserByUsername("inviteduser")
require.NoError(t, err)
require.Equal(t, "inviteduser", user.Username)
updatedInvitation, err := db.GetInvitationByID(invitation.ID)
require.NoError(t, err)
require.Equal(t, uint(1), updatedInvitation.NbUsed)
})
}
func TestLoginPage(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("Form", func(t *testing.T) {
s.Request(t, "GET", "/login", nil, 200)
s.TestCtxData(t, echo.Map{
"isLoginPage": true,
"disableForm": false,
})
})
t.Run("FormDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "GET", "/login", nil, 200)
s.TestCtxData(t, echo.Map{
"disableForm": true,
})
})
}
func TestProcessLogin(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("ValidCredentials", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302)
require.Equal(t, "/", resp.Header.Get("Location"))
require.NotEmpty(t, s.SessionCookie)
require.Equal(t, "thomas", s.User().Username)
s.Logout()
})
t.Run("InvalidPassword", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302)
require.Equal(t, "/login", resp.Header.Get("Location"))
require.Nil(t, s.User())
})
t.Run("NonExistentUser", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302)
require.Equal(t, "/login", resp.Header.Get("Location"))
require.Nil(t, s.User())
})
t.Run("EmptyCredentials", func(t *testing.T) {
s.Request(t, "POST", "/login", db.UserDTO{Username: "", Password: ""}, 302)
require.Nil(t, s.User())
})
t.Run("LoginFormDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403)
require.Nil(t, s.User())
})
}
func TestLogout(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("LogoutRedirects", func(t *testing.T) {
s.Login(t, "thomas")
require.Equal(t, "thomas", s.User().Username)
resp := s.Request(t, "GET", "/logout", nil, 302)
require.Equal(t, "/all", resp.Header.Get("Location"))
require.Nil(t, s.User())
s.Request(t, "GET", "/", nil, 302)
})
}

View File

@@ -164,18 +164,6 @@ func AllGists(ctx *context.Context) error {
return ctx.Html("all.html")
}
// Search handles the search page for gists.
//
// It takes a query parameter "q" which is a search query in the format:
// "user:username title:title description:description filename:filename language:language topic:topic"
//
// It also takes a page parameter "page" which is the page number to display.
//
// It returns an error if the search query is invalid or if the page number is invalid.
//
// It returns the search results as a list of rendered gists, along with the total number of results, the languages found, and the search query.
//
// The search results are paginated, with 10 results per page.
func Search(ctx *context.Context) error {
var err error
@@ -183,7 +171,7 @@ func Search(ctx *context.Context) error {
Query: ctx.QueryParam("q"),
}
metadata := handlers.ParseSearchQueryStr(ctx.QueryParam("q"))
content, meta := handlers.ParseSearchQueryStr(ctx.QueryParam("q"))
pageInt := handlers.GetPage(ctx)
var currentUserId uint
@@ -194,18 +182,14 @@ func Search(ctx *context.Context) error {
currentUserId = 0
}
// Search gists in the index and fetch the gists IDs from the database
gistsIds, nbHits, langs, err := index.SearchGists(index.SearchGistMetadata{
Username: metadata["user"],
Title: metadata["title"],
Description: metadata["description"],
Filename: metadata["filename"],
Extension: metadata["extension"],
Language: metadata["language"],
Topic: metadata["topic"],
Content: metadata["content"],
All: metadata["all"],
Default: metadata["default"],
gistsIds, nbHits, langs, err := index.SearchGists(content, index.SearchGistMetadata{
Username: meta["user"],
Title: meta["title"],
Filename: meta["filename"],
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

@@ -24,6 +24,11 @@ func Create(ctx *context.Context) error {
func ProcessCreate(ctx *context.Context) error {
isCreate := ctx.Request().URL.Path == "/"
err := ctx.Request().ParseForm()
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), err)
}
dto := new(db.GistDTO)
var gist *db.Gist
@@ -34,24 +39,25 @@ func ProcessCreate(ctx *context.Context) error {
ctx.SetData("htmlTitle", ctx.TrH("gist.edit.edit-gist", gist.Title))
}
err := ctx.Bind(dto)
if err != nil {
if err := ctx.Bind(dto); err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
}
dto.Files = make([]db.FileDTO, 0)
fileCounter := 0
names := dto.Name
contents := dto.Content
names := ctx.Request().PostForm["name"]
contents := ctx.Request().PostForm["content"]
// Process files from text editors
for i, content := range contents {
if content == "" {
continue
}
name := git.CleanTreePathName(names[i])
name := names[i]
if name == "" {
name = "gistfile" + strconv.Itoa(len(dto.Files)+1) + ".txt"
fileCounter += 1
name = "gistfile" + strconv.Itoa(fileCounter) + ".txt"
}
escapedValue, err := url.PathUnescape(content)
@@ -66,26 +72,18 @@ func ProcessCreate(ctx *context.Context) error {
}
// Process uploaded files from UUID arrays
fileUUIDs := dto.UploadedFilesUUID
fileFilenames := dto.UploadedFilesNames
fileUUIDs := ctx.Request().PostForm["uploadedfile_uuid"]
fileFilenames := ctx.Request().PostForm["uploadedfile_filename"]
if len(fileUUIDs) == len(fileFilenames) {
for i, fileUUID := range fileUUIDs {
if !uuidRegex.MatchString(filepath.Base(fileUUID)) {
continue
}
filePath := filepath.Join(config.GetHomeDir(), "uploads", fileUUID)
filePath := filepath.Join(filepath.Join(config.GetHomeDir(), "uploads"), fileUUID)
if _, err := os.Stat(filePath); err != nil {
continue
}
name := git.CleanTreePathName(fileFilenames[i])
if name == "" {
name = "gistfile" + strconv.Itoa(len(dto.Files)+1) + ".txt"
}
dto.Files = append(dto.Files, db.FileDTO{
Filename: name,
Filename: fileFilenames[i],
SourcePath: filePath,
Content: "", // Empty since we're using SourcePath
})
@@ -93,11 +91,11 @@ func ProcessCreate(ctx *context.Context) error {
}
// Process binary file operations (edit mode)
binaryOldNames := dto.BinaryFileOldName
binaryNewNames := dto.BinaryFileNewName
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 := git.CleanTreePathName(binaryNewNames[i])
newName := binaryNewNames[i]
if newName == "" { // deletion
continue

View File

@@ -1,519 +0,0 @@
package gist_test
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// Helper function to extract username and gist identifier from redirect URL
func getGistInfoFromRedirect(resp *http.Response) (username, identifier string) {
location := resp.Header.Get("Location")
// Location format: /{username}/{identifier}
parts := strings.Split(strings.TrimPrefix(location, "/"), "/")
if len(parts) >= 2 {
return parts[0], parts[1]
}
return "", ""
}
func verifyGistCreation(t *testing.T, gist *db.Gist, username, identifier string) {
require.NotNil(t, gist)
require.Equal(t, username, gist.User.Username)
require.Equal(t, identifier, gist.Identifier())
require.NotEmpty(t, gist.Uuid)
require.Greater(t, gist.NbFiles, 0)
gistPath := filepath.Join(config.GetHomeDir(), git.ReposDirectory, username, gist.Uuid)
_, err := os.Stat(gistPath)
require.NoError(t, err, "Gist repository should exist on filesystem at %s", gistPath)
}
func TestGistCreationPage(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("NoAuth", func(t *testing.T) {
s.Request(t, "GET", "/", nil, 302)
})
t.Run("Authenticated", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "GET", "/", nil, 200)
})
}
func TestGistCreation(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("NoAuth", func(t *testing.T) {
s.Request(t, "POST", "/", url.Values{
"title": {"Test Gist"},
"name": {"test.txt"},
"content": {"hello world"},
}, 302) // Redirects to login
// Verify no gist was created
count, err := db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(0), count)
})
tests := []struct {
name string
data url.Values
expectedCode int
expectGistCreated bool
expectedTitle string
expectedDescription string
expectedURL string
expectedTopics string // Expected topics string
expectedNbFiles int
expectedVisibility db.Visibility
expectedFileNames []string // Expected filenames in the gist
expectedFileContents map[string]string // Expected content for each file (filename -> content)
}{
{
name: "NoFiles",
data: url.Values{
"title": {"Test Gist"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "EmptyContent",
data: url.Values{
"title": {"Test Gist"},
"name": {"test.txt"},
"content": {""},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "TitleTooLong",
data: url.Values{
"title": {strings.Repeat("a", 251)}, // Max is 250
"name": {"test.txt"},
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "DescriptionTooLong",
data: url.Values{
"title": {"Test Gist"},
"description": {strings.Repeat("a", 1001)}, // Max is 1000
"name": {"test.txt"},
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "URLTooLong",
data: url.Values{
"title": {"Test Gist"},
"url": {strings.Repeat("a", 33)}, // Max is 32
"name": {"test.txt"},
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "URLInvalidCharacters",
data: url.Values{
"title": {"Test Gist"},
"url": {"invalid@url#here"}, // Only alphanumeric and dashes allowed
"name": {"test.txt"},
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "InvalidVisibility",
data: url.Values{
"title": {"Test Gist"},
"name": {"test.txt"},
"content": {"hello"},
"private": {"3"}, // Valid values are 0, 1, 2
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "Valid",
data: url.Values{
"title": {"My Test Gist"},
"name": {"test.txt"},
"url": {"my-custom-url-123"}, // Alphanumeric + dashes should be valid
"content": {"hello world"},
"private": {"0"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "My Test Gist",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"test.txt"},
expectedFileContents: map[string]string{
"test.txt": "hello world",
},
},
{
name: "AutoNamedFile",
data: url.Values{
"title": {"Auto Named"},
"name": {""},
"content": {"content without name"},
"private": {"0"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Auto Named",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"gistfile1.txt"},
expectedFileContents: map[string]string{
"gistfile1.txt": "content without name",
},
},
{
name: "MultipleFiles",
data: url.Values{
"title": {"Multi File Gist"},
"name": []string{"", "file2.md"},
"content": []string{"content 1", "content 2"},
"private": {"0"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Multi File Gist",
expectedNbFiles: 2,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"gistfile1.txt", "file2.md"},
expectedFileContents: map[string]string{
"gistfile1.txt": "content 1",
"file2.md": "content 2",
},
},
{
name: "NoTitle",
data: url.Values{
"name": {"readme.md"},
"content": {"# README"},
"private": {"0"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "readme.md",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"readme.md"},
expectedFileContents: map[string]string{
"readme.md": "# README",
},
},
{
name: "Unlisted",
data: url.Values{
"title": {"Unlisted Gist"},
"name": {"secret.txt"},
"content": {"secret content"},
"private": {"1"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Unlisted Gist",
expectedNbFiles: 1,
expectedVisibility: db.UnlistedVisibility,
expectedFileNames: []string{"secret.txt"},
expectedFileContents: map[string]string{
"secret.txt": "secret content",
},
},
{
name: "Private",
data: url.Values{
"title": {"Private Gist"},
"name": {"secret.txt"},
"content": {"secret content"},
"private": {"2"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Private Gist",
expectedNbFiles: 1,
expectedVisibility: db.PrivateVisibility,
expectedFileNames: []string{"secret.txt"},
expectedFileContents: map[string]string{
"secret.txt": "secret content",
},
},
{
name: "Topics",
data: url.Values{
"title": {"Gist With Topics"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {"golang testing webdev"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Gist With Topics",
expectedTopics: "golang,testing,webdev",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"test.txt"},
expectedFileContents: map[string]string{
"test.txt": "hello",
},
},
{
name: "TopicsTooMany",
data: url.Values{
"title": {"Test"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {"topic1 topic2 topic3 topic4 topic5 topic6 topic7 topic8 topic9 topic10 topic11"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "TopicTooLong",
data: url.Values{
"title": {"Test"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {strings.Repeat("a", 51)}, // 51 chars - exceeds max of 50
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "TopicInvalidCharacters",
data: url.Values{
"title": {"Test"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {"topic@name topic.name"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "TopicUnicode",
data: url.Values{
"title": {"Unicode Topics"},
"name": {"test.txt"},
"content": {"hello"},
"topics": {"编程 тест"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Unicode Topics",
expectedTopics: "编程,тест",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"test.txt"},
},
{
name: "DuplicateFileNames",
data: url.Values{
"title": {"Duplicate Files"},
"name": []string{"test.txt", "test.txt"},
"content": []string{"content1", "content2"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Duplicate Files",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"test.txt"},
expectedFileContents: map[string]string{
"test.txt": "content2",
},
},
{
name: "FileNameTooLong",
data: url.Values{
"title": {"Too Long Filename"},
"name": {strings.Repeat("a", 256) + ".txt"}, // 260 total - exceeds 255
"content": {"hello"},
},
expectedCode: 400,
expectGistCreated: false,
},
{
name: "FileNameWithUnicode",
data: url.Values{
"title": {"Unicode Filename"},
"name": {"文件.txt"},
"content": {"hello world"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Unicode Filename",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"文件.txt"},
expectedFileContents: map[string]string{
"文件.txt": "hello world",
},
},
{
name: "FileNamePathTraversal",
data: url.Values{
"title": {"Path Traversal"},
"name": {"../../../etc/passwd"},
"content": {"malicious"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Path Traversal",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"passwd"},
expectedFileContents: map[string]string{
"passwd": "malicious",
},
},
{
name: "EmptyAndValidContent",
data: url.Values{
"title": {"Mixed Content"},
"name": []string{"empty.txt", "valid.txt", "also-empty.txt"},
"content": []string{"", "valid content", ""},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Mixed Content",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"valid.txt"},
expectedFileContents: map[string]string{
"valid.txt": "valid content",
},
},
{
name: "ContentWithSpecialCharacters",
data: url.Values{
"title": {"Special Chars"},
"name": {"special.txt"},
"content": {"Line1\nLine2\tTabbed\x00NullByte😀Emoji"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Special Chars",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"special.txt"},
expectedFileContents: map[string]string{
"special.txt": "Line1\nLine2\tTabbed\x00NullByte😀Emoji",
},
},
{
name: "ContentMultibyteUnicode",
data: url.Values{
"title": {"Unicode Content"},
"name": {"unicode.txt"},
"content": {"Hello 世界 🌍 Привет"},
},
expectedCode: 302,
expectGistCreated: true,
expectedTitle: "Unicode Content",
expectedNbFiles: 1,
expectedVisibility: db.PublicVisibility,
expectedFileNames: []string{"unicode.txt"},
expectedFileContents: map[string]string{
"unicode.txt": "Hello 世界 🌍 Привет",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas")
resp := s.Request(t, "POST", "/", tt.data, tt.expectedCode)
if tt.expectGistCreated {
// Get gist info from redirect
username, gistIdentifier := getGistInfoFromRedirect(resp)
require.Equal(t, "thomas", username)
require.NotEmpty(t, gistIdentifier)
// Verify gist was created
gist, err := db.GetGist(username, gistIdentifier)
require.NoError(t, err)
// Run common verification (filesystem, git, etc.)
verifyGistCreation(t, gist, username, gistIdentifier)
// Verify all expected fields
require.Equal(t, tt.expectedTitle, gist.Title, "Title mismatch")
require.Equal(t, tt.expectedNbFiles, gist.NbFiles, "File count mismatch")
require.Equal(t, tt.expectedVisibility, gist.Private, "Visibility mismatch")
// Verify description if specified
if tt.expectedDescription != "" {
require.Equal(t, tt.expectedDescription, gist.Description, "Description mismatch")
}
// Verify URL if specified
if tt.expectedURL != "" {
require.Equal(t, tt.expectedURL, gist.Identifier(), "URL/Identifier mismatch")
}
// Verify topics if specified
if tt.expectedTopics != "" {
// Get gist topics
topics, err := gist.GetTopics()
require.NoError(t, err, "Failed to get gist topics")
require.ElementsMatch(t, strings.Split(tt.expectedTopics, ","), topics, "Topics mismatch")
}
// Verify files if specified
if len(tt.expectedFileNames) > 0 {
files, err := gist.Files("HEAD", false)
require.NoError(t, err, "Failed to get gist files")
require.Len(t, files, len(tt.expectedFileNames), "File count mismatch")
actualFileNames := make([]string, len(files))
for i, file := range files {
actualFileNames[i] = file.Filename
}
require.ElementsMatch(t, tt.expectedFileNames, actualFileNames, "File names mismatch")
// Verify file contents if specified
if len(tt.expectedFileContents) > 0 {
for filename, expectedContent := range tt.expectedFileContents {
content, _, err := git.GetFileContent(username, gist.Uuid, "HEAD", filename, false)
require.NoError(t, err, "Failed to get content for file %s", filename)
require.Equal(t, expectedContent, content, "Content mismatch for file %s", filename)
}
}
}
}
})
}
}

View File

@@ -1,74 +0,0 @@
package gist_test
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestDeleteGist(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("NoAuth", func(t *testing.T) {
gistPath, _, username, identifier := s.CreateGist(t, "0")
deleteURL := "/" + username + "/" + identifier + "/delete"
s.Request(t, "POST", deleteURL, nil, 302)
gistCheck, err := db.GetGist(username, identifier)
require.NoError(t, err, "Gist should still exist in database")
require.NotNil(t, gistCheck)
_, err = os.Stat(gistPath)
require.NoError(t, err, "Gist should still exist on filesystem")
})
t.Run("DeleteOwnGist", func(t *testing.T) {
gistPath, _, username, identifier := s.CreateGist(t, "0")
gistCheck, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.NotNil(t, gistCheck)
s.Login(t, "thomas")
deleteURL := "/" + username + "/" + identifier + "/delete"
s.Request(t, "POST", deleteURL, nil, 302)
gistCheck, err = db.GetGist(username, identifier)
require.Error(t, err, "Gist should be deleted from database")
_, err = os.Stat(gistPath)
require.Error(t, err, "Gist should not exist on filesystem after deletion")
require.True(t, os.IsNotExist(err), "Filesystem should return 'not exist' error")
require.Equal(t, uint(0), gistCheck.ID, "Gist should be not in database after deletion")
})
t.Run("DeleteOthersGist", func(t *testing.T) {
gistPath, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
deleteURL := "/" + username + "/" + identifier + "/delete"
s.Request(t, "POST", deleteURL, nil, 403)
gistCheck, err := db.GetGist(username, identifier)
require.NoError(t, err, "Gist should still exist in database")
require.NotNil(t, gistCheck)
_, err = os.Stat(gistPath)
require.NoError(t, err, "Gist should still exist on filesystem")
})
t.Run("DeleteNonExistentGist", func(t *testing.T) {
s.Login(t, "thomas")
deleteURL := "/thomas/nonexistent-gist-12345/delete"
s.Request(t, "POST", deleteURL, nil, 404)
})
}

View File

@@ -3,7 +3,6 @@ package gist
import (
"archive/zip"
"bytes"
"net/url"
"strconv"
"github.com/thomiceli/opengist/internal/db"
@@ -20,23 +19,8 @@ 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; charset=utf-8")
} 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")
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
ctx.Response().Header().Set("Content-Disposition", "inline; filename=\""+file.Filename+"\"")
return ctx.PlainText(200, file.Content)
}
@@ -52,9 +36,8 @@ func DownloadFile(ctx *context.Context) error {
}
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-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

@@ -1,141 +0,0 @@
package gist_test
import (
"archive/zip"
"bytes"
"io"
"testing"
"github.com/stretchr/testify/require"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestDownloadZip(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
t.Run("MultipleFiles", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/archive/HEAD", nil, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
require.NoError(t, err)
require.Len(t, zipReader.File, 2)
fileNames := make([]string, len(zipReader.File))
contents := make([]string, len(zipReader.File))
for i, file := range zipReader.File {
fileNames[i] = file.Name
f, err := file.Open()
require.NoError(t, err)
content, err := io.ReadAll(f)
require.NoError(t, err)
contents[i] = string(content)
f.Close()
}
require.ElementsMatch(t, []string{"file.txt", "otherfile.txt"}, fileNames)
require.ElementsMatch(t, []string{"hello world", "other content"}, contents)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Request(t, "GET", "/"+username+"/"+identifier+"/archive/HEAD", nil, 404)
})
t.Run("NonExistentRevision", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
// TODO: return 404
s.Request(t, "GET", "/"+username+"/"+identifier+"/archive/zz", nil, 0)
})
}
func TestRawFile(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
t.Run("ExistingFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/HEAD/file.txt", nil, 200)
require.Equal(t, `inline; filename="file.txt"`, resp.Header.Get("Content-Disposition"))
require.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
require.Contains(t, resp.Header.Get("Content-Type"), "text/plain")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "hello world", string(body))
})
t.Run("NonExistentFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/HEAD/nonexistent.txt", nil, 404)
})
t.Run("NonExistentRevision", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/zz/file.txt", nil, 404)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/HEAD/file.txt", nil, 404)
})
}
func TestDownloadFile(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
t.Run("ExistingFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/download/HEAD/file.txt", nil, 200)
require.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
require.Equal(t, `attachment; filename="file.txt"`, resp.Header.Get("Content-Disposition"))
require.Equal(t, "11", resp.Header.Get("Content-Length"))
require.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "hello world", string(body))
})
t.Run("NonExistentFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/download/HEAD/nonexistent.txt", nil, 404)
_, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// TODO: change the response to not found
// require.Equal(t, "File not found", string(body))
})
t.Run("NonExistentRevision", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/download/zz/file.txt", nil, 404)
_, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// TODO: change the response to not found
// require.Equal(t, "File not found", string(body))
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Request(t, "GET", "/"+username+"/"+identifier+"/download/HEAD/file.txt", nil, 404)
})
}

View File

@@ -1,11 +1,10 @@
package gist
import (
"strconv"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/render"
"github.com/thomiceli/opengist/internal/web/context"
"strconv"
)
func Edit(ctx *context.Context) error {

View File

@@ -1,66 +0,0 @@
package gist_test
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestVisibility(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("ChangeVisibility", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", url.Values{
"private": {"2"},
}, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, db.PrivateVisibility, gist.Private)
})
t.Run("ChangeToUnlisted", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", url.Values{
"private": {"1"},
}, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, db.UnlistedVisibility, gist.Private)
})
t.Run("OtherUserCannotChange", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", nil, 403)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, db.PublicVisibility, gist.Private)
})
t.Run("NoAuth", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Logout()
s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", nil, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, db.PublicVisibility, gist.Private)
})
}

View File

@@ -1,102 +0,0 @@
package gist_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestFork(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Fork", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
resp := s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
forkedGist, err := db.GetGistByID("2")
require.NoError(t, err)
require.Equal(t, "alice", forkedGist.User.Username)
require.Equal(t, gist.Title, forkedGist.Title)
require.Equal(t, gist.Description, forkedGist.Description)
require.Equal(t, gist.Private, forkedGist.Private)
require.Equal(t, gist.ID, forkedGist.ForkedID)
forkedFiles, err := forkedGist.Files("HEAD", false)
require.NoError(t, err)
gistFiles, err := gist.Files("HEAD", false)
require.NoError(t, err)
for i, file := range gistFiles {
require.Equal(t, file.Filename, forkedFiles[i].Filename)
require.Equal(t, file.Content, forkedFiles[i].Content)
}
require.Equal(t, "/alice/"+forkedGist.Identifier(), resp.Header.Get("Location"))
original, err := db.GetGistByID("1")
require.NoError(t, err)
require.Equal(t, 1, original.NbForks)
forks, err := original.GetForks(2, 0)
require.NoError(t, err)
require.Len(t, forks, 1)
require.Equal(t, forkedGist.ID, forks[0].ID)
forkedGists, err := db.GetAllGistsForkedByUser(2, 2, 0, "created", "asc")
require.NoError(t, err)
require.Len(t, forkedGists, 1)
require.Equal(t, forkedGist.ID, forkedGists[0].ID)
})
t.Run("OwnGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
original, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 0, original.NbForks)
})
t.Run("AlreadyForked", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
firstResp := s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
forkLocation := firstResp.Header.Get("Location")
secondResp := s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
require.Equal(t, forkLocation, secondResp.Header.Get("Location"))
original, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 1, original.NbForks)
})
t.Run("NoAuth", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302)
original, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 0, original.NbForks)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 404)
})
}

View File

@@ -165,39 +165,10 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string) (string, erro
}
js := fmt.Sprintf(`
(function() {
if (!customElements.get('opengist-embed')) {
customElements.define('opengist-embed', class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
init(css1, css2, content) {
this.shadowRoot.innerHTML = %s
<style>
@import url(${css1});
@import url(${css2});
:host { display: block; all: initial; font-family: sans-serif; }
</style>
<div class="container">${content}</div>
%s;
}
});
}
var currentScript = document.currentScript || (function() {
var scripts = document.getElementsByTagName('script');
return scripts[scripts.length - 1];
})();
const instance = document.createElement('opengist-embed');
instance.init(%s, %s, %s);
currentScript.parentNode.insertBefore(instance, currentScript.nextSibling);
})();
`,
"`",
"`",
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,327 +0,0 @@
package gist_test
import (
"encoding/json"
"io"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func setupManifestEntries() {
context.ManifestEntries = map[string]context.Asset{
"embed.css": {File: "assets/embed.css"},
"ts/embed.ts": {Css: []string{"assets/embed.css"}},
"ts/light.ts": {Css: []string{"assets/light.css"}},
"ts/dark.ts": {Css: []string{"assets/dark.css"}},
}
}
func TestGistIndex(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Public", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
})
t.Run("NonExistentRevision", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier+"/rev/nonexistent", nil, 404)
})
t.Run("NonExistentGist", func(t *testing.T) {
s.Request(t, "GET", "/thomas/nonexistent", nil, 404)
})
t.Run("Unlisted", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "1")
s.Login(t, "thomas")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
s.Login(t, "alice")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
s.Logout()
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
})
t.Run("Private", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Login(t, "thomas")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200)
s.Login(t, "alice")
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 404)
s.Logout()
s.Request(t, "GET", "/"+username+"/"+identifier, nil, 404)
})
t.Run("SpecificRevision", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{
"title": {"Test"},
"name": {"file.txt"},
"content": {"updated content"},
}, 302)
files, err := gist.Files("HEAD", false)
require.NoError(t, err)
found := false
for _, f := range files {
if f.Filename == "file.txt" {
require.Equal(t, "updated content", f.Content)
found = true
}
}
require.True(t, found)
commits, err := gist.Log(0)
require.NoError(t, err)
require.Len(t, commits, 2)
filesOld, err := gist.Files(commits[1].Hash, false)
require.NoError(t, err)
for _, f := range filesOld {
if f.Filename == "file.txt" {
require.Equal(t, "hello world", f.Content)
}
}
s.Request(t, "GET", "/"+username+"/"+identifier+"/rev/HEAD", nil, 200)
s.Request(t, "GET", "/"+username+"/"+identifier+"/rev/"+commits[1].Hash, nil, 200)
})
}
func TestPreview(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("Markdown", func(t *testing.T) {
s.Login(t, "thomas")
resp := s.Request(t, "POST", "/preview", url.Values{
"content": {"# Hello\n\nThis is **bold** and *italic*."},
}, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
html := string(body)
require.Contains(t, html, "<h1>")
require.Contains(t, html, "Hello")
require.Contains(t, html, "<strong>bold</strong>")
require.Contains(t, html, "<em>italic</em>")
})
t.Run("NoAuth", func(t *testing.T) {
s.Logout()
s.Request(t, "POST", "/preview", url.Values{
"content": {"# Hello"},
}, 302)
})
}
func TestGistJson(t *testing.T) {
setupManifestEntries()
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Public", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+".json", nil, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(body, &result)
require.NoError(t, err)
t.Helper()
require.Equal(t, username, result["owner"])
require.Equal(t, identifier, result["id"])
require.Equal(t, gist.Uuid, result["uuid"])
require.Equal(t, gist.Title, result["title"])
require.Equal(t, "public", result["visibility"])
require.Equal(t, []interface{}{"hello", "opengist"}, result["topics"])
require.Equal(t, []interface{}{
map[string]interface{}{
"content": "hello world",
"filename": "file.txt",
"human_size": "11 B",
"size": float64(11),
"truncated": false,
"type": "Text",
},
map[string]interface{}{
"content": "other content",
"filename": "otherfile.txt",
"human_size": "13 B",
"size": float64(13),
"truncated": false,
"type": "Text",
},
}, result["files"])
embed, ok := result["embed"].(map[string]interface{})
require.True(t, ok)
require.Contains(t, embed["js"], identifier+".js")
require.Contains(t, embed["js_dark"], identifier+".js?dark")
require.NotEmpty(t, embed["css"])
require.NotEmpty(t, embed["html"])
})
t.Run("Unlisted", func(t *testing.T) {
s.Logout()
_, _, username, identifier := s.CreateGist(t, "1")
s.Request(t, "GET", "/"+username+"/"+identifier+".json", nil, 200)
})
t.Run("Private", func(t *testing.T) {
s.Logout()
_, _, username, identifier := s.CreateGist(t, "2")
s.Request(t, "GET", "/"+username+"/"+identifier+".json", nil, 404)
})
t.Run("NonExistentGist", func(t *testing.T) {
s.Request(t, "GET", "/thomas/nonexistent.json", nil, 404)
})
}
func TestGistAccess(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
_, _, user, publicId := s.CreateGist(t, "0")
_, _, _, unlistedId := s.CreateGist(t, "1")
_, _, _, privateId := s.CreateGist(t, "2")
tests := []struct {
name string
settings map[string]string
// expected codes: [owner, otherUser, anonymous] x [public, unlisted, private]
owner, otherUser, anonymous []int
}{
{
name: "Default",
owner: []int{200, 200, 200},
otherUser: []int{200, 200, 404},
anonymous: []int{200, 200, 404},
},
{
name: "RequireLogin",
settings: map[string]string{db.SettingRequireLogin: "1"},
owner: []int{200, 200, 200},
otherUser: []int{200, 200, 404},
anonymous: []int{302, 302, 302},
},
{
name: "AllowGistsWithoutLogin",
settings: map[string]string{db.SettingRequireLogin: "1", db.SettingAllowGistsWithoutLogin: "1"},
owner: []int{200, 200, 200},
otherUser: []int{200, 200, 404},
anonymous: []int{200, 200, 404},
},
}
gists := []string{publicId, unlistedId, privateId}
labels := []string{"Public", "Unlisted", "Private"}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas")
for k, v := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
}
t.Run("Owner", func(t *testing.T) {
s.Login(t, "thomas")
for i, id := range gists {
s.Request(t, "GET", "/"+user+"/"+id, nil, tt.owner[i])
}
})
t.Run("OtherUser", func(t *testing.T) {
s.Login(t, "alice")
for i, id := range gists {
s.Request(t, "GET", "/"+user+"/"+id, nil, tt.otherUser[i])
}
})
t.Run("Anonymous", func(t *testing.T) {
s.Logout()
for i, id := range gists {
t.Run(labels[i], func(t *testing.T) {
s.Request(t, "GET", "/"+user+"/"+id, nil, tt.anonymous[i])
})
}
})
s.Login(t, "thomas")
for k := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
}
})
}
}
func TestGetGistCaseInsensitive(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "THOmas")
s.Login(t, "THOmas")
s.Request(t, "POST", "/", url.Values{
"title": {"Test"},
"name": {"file.txt"},
"content": {"hello world"},
"url": {"my-GIST"},
"private": {"0"},
}, 302)
gist, err := db.GetGistByID("1")
require.NoError(t, err)
s.Logout()
t.Run("URL", func(t *testing.T) {
s.Request(t, "GET", "/thomas/my-gist", nil, 200)
s.Request(t, "GET", "/THOMAS/MY-GIST", nil, 200)
s.Request(t, "GET", "/thomas/MY-GIST", nil, 200)
s.Request(t, "GET", "/THOMAS/my-gist", nil, 200)
})
t.Run("UUID", func(t *testing.T) {
s.Request(t, "GET", "/thomas/"+strings.ToLower(gist.Uuid), nil, 200)
s.Request(t, "GET", "/THOMAS/"+strings.ToUpper(gist.Uuid), nil, 200)
})
}

View File

@@ -1,96 +0,0 @@
package gist_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestLike(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Like", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
resp := s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
require.Equal(t, "/"+username+"/"+identifier, resp.Header.Get("Location"))
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 2, gist.NbLikes)
likers, err := gist.GetUsersLikes(0)
require.NoError(t, err)
require.Len(t, likers, 2)
})
t.Run("Unlike", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 0, gist.NbLikes)
likers, err := gist.GetUsersLikes(0)
require.NoError(t, err)
require.Len(t, likers, 0)
})
t.Run("NoAuth", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Logout()
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
gist, err := db.GetGist(username, identifier)
require.NoError(t, err)
require.Equal(t, 0, gist.NbLikes)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 404)
})
}
func TestLikes(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Likes", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
s.Login(t, "alice")
s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302)
s.Request(t, "GET", "/"+username+"/"+identifier+"/likes", nil, 200)
users, err := gist.GetUsersLikes(0)
require.NoError(t, err)
require.Len(t, users, 2)
require.Equal(t, "thomas", users[0].Username)
require.Equal(t, "alice", users[1].Username)
})
}

View File

@@ -1,11 +1,10 @@
package gist
import (
"strings"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
"strings"
)
func Revisions(ctx *context.Context) error {

View File

@@ -1,153 +0,0 @@
package gist_test
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/git"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestRevisions(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("Revisions", func(t *testing.T) {
_, gist, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{
"title": {"Test"},
"name": {"file.txt", "ok.txt"},
"content": {"updated content", "okay"},
}, 302)
s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{
"title": {"Test"},
"name": {"renamed.txt", "ok.txt"},
"content": {"updated content", "okay"},
}, 302)
commits, err := gist.Log(0)
require.NoError(t, err)
require.Len(t, commits, 3)
require.Regexp(t, "^[a-f0-9]{40}$", commits[0].Hash)
require.Regexp(t, "^[a-f0-9]{40}$", commits[1].Hash)
require.Regexp(t, "^[a-f0-9]{40}$", commits[2].Hash)
require.Equal(t, &git.Commit{
Hash: commits[0].Hash,
Timestamp: commits[0].Timestamp,
AuthorName: "thomas",
Changed: "1 file changed, 0 insertions, 0 deletions",
Files: []git.File{
{
Filename: "renamed.txt",
Size: 0,
HumanSize: "",
OldFilename: "file.txt",
Content: ``,
Truncated: false,
IsCreated: false,
IsDeleted: false,
IsBinary: false,
MimeType: git.MimeType{},
},
},
}, commits[0])
require.Equal(t, &git.Commit{
Hash: commits[1].Hash,
Timestamp: commits[1].Timestamp,
AuthorName: "thomas",
Changed: "3 files changed, 2 insertions, 2 deletions",
Files: []git.File{
{
Filename: "file.txt",
OldFilename: "file.txt",
Content: `@@ -1 +1 @@
-hello world
\ No newline at end of file
+updated content
\ No newline at end of file
`,
IsCreated: false,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "ok.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
+okay
\ No newline at end of file
`,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "otherfile.txt",
OldFilename: "",
Content: `@@ -1 +0,0 @@
-other content
\ No newline at end of file
`,
IsCreated: false,
IsDeleted: true,
IsBinary: false,
},
},
}, commits[1])
require.Equal(t, &git.Commit{
Hash: commits[2].Hash,
Timestamp: commits[2].Timestamp,
AuthorName: "thomas",
Changed: "2 files changed, 2 insertions",
Files: []git.File{
{
Filename: "file.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
+hello world
\ No newline at end of file
`,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "otherfile.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
+other content
\ No newline at end of file
`,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
},
},
}, commits[2])
s.Request(t, "GET", "/"+username+"/"+identifier+"/revisions", nil, 200)
})
t.Run("NoAuth", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Logout()
s.Request(t, "GET", "/"+username+"/"+identifier+"/revisions", nil, 200)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
s.Login(t, "alice")
s.Request(t, "GET", "/"+username+"/"+identifier+"/revisions", nil, 404)
})
}

View File

@@ -4,15 +4,12 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"github.com/google/uuid"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/web/context"
)
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
func Upload(ctx *context.Context) error {
err := ctx.Request().ParseMultipartForm(32 << 20) // 32 MB max
if err != nil {
@@ -60,13 +57,13 @@ func Upload(ctx *context.Context) error {
}
func DeleteUpload(ctx *context.Context) error {
fileUuid := filepath.Base(ctx.Param("uuid"))
if fileUuid == "" || !uuidRegex.MatchString(fileUuid) {
uuid := ctx.Param("uuid")
if uuid == "" {
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), nil)
}
filePath := filepath.Join(config.GetHomeDir(), "uploads", fileUuid)
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 {

View File

@@ -1,151 +0,0 @@
package gist_test
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func createMultipartRequest(t *testing.T, uri, fieldName, fileName, content string) *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(fieldName, fileName)
require.NoError(t, err)
_, err = part.Write([]byte(content))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, uri, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func TestUpload(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("UploadFile", func(t *testing.T) {
s.Login(t, "thomas")
req := createMultipartRequest(t, "/upload", "file", "test.txt", "file content")
resp := s.RawRequest(t, req, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var result map[string]string
err = json.Unmarshal(body, &result)
require.NoError(t, err)
require.Equal(t, "test.txt", result["filename"])
require.NotEmpty(t, result["uuid"])
require.Regexp(t, `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, result["uuid"])
filePath := filepath.Join(config.GetHomeDir(), "uploads", result["uuid"])
data, err := os.ReadFile(filePath)
require.NoError(t, err)
require.Equal(t, "file content", string(data))
})
t.Run("NoFile", func(t *testing.T) {
s.Login(t, "thomas")
req := httptest.NewRequest(http.MethodPost, "/upload", nil)
req.Header.Set("Content-Type", "multipart/form-data; boundary=xxx")
s.RawRequest(t, req, 400)
})
t.Run("NoAuth", func(t *testing.T) {
s.Logout()
req := createMultipartRequest(t, "/upload", "file", "test.txt", "content")
s.RawRequest(t, req, 302)
})
}
func TestDeleteUpload(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("DeleteExistingFile", func(t *testing.T) {
s.Login(t, "thomas")
req := createMultipartRequest(t, "/upload", "file", "todelete.txt", "delete me")
uploadResp := s.RawRequest(t, req, 200)
body, err := io.ReadAll(uploadResp.Body)
require.NoError(t, err)
var uploadResult map[string]string
err = json.Unmarshal(body, &uploadResult)
require.NoError(t, err)
fileUUID := uploadResult["uuid"]
filePath := filepath.Join(config.GetHomeDir(), "uploads", fileUUID)
_, err = os.Stat(filePath)
require.NoError(t, err)
deleteReq := httptest.NewRequest(http.MethodDelete, "/upload/"+fileUUID, nil)
deleteResp := s.RawRequest(t, deleteReq, 200)
deleteBody, err := io.ReadAll(deleteResp.Body)
require.NoError(t, err)
var deleteResult map[string]string
err = json.Unmarshal(deleteBody, &deleteResult)
require.NoError(t, err)
require.Equal(t, "deleted", deleteResult["status"])
_, err = os.Stat(filePath)
require.True(t, os.IsNotExist(err))
})
t.Run("DeleteNonExistentFile", func(t *testing.T) {
s.Login(t, "thomas")
req := httptest.NewRequest(http.MethodDelete, "/upload/00000000-0000-0000-0000-000000000000", nil)
s.RawRequest(t, req, 200)
})
t.Run("InvalidUUID", func(t *testing.T) {
s.Login(t, "thomas")
req := httptest.NewRequest(http.MethodDelete, "/upload/not-a-valid-uuid", nil)
s.RawRequest(t, req, 400)
})
t.Run("PathTraversal", func(t *testing.T) {
s.Login(t, "thomas")
req := httptest.NewRequest(http.MethodDelete, "/upload/../../etc/passwd", nil)
s.RawRequest(t, req, 400)
})
t.Run("NoAuth", func(t *testing.T) {
s.Logout()
req := httptest.NewRequest(http.MethodDelete, "/upload/00000000-0000-0000-0000-000000000000", nil)
s.RawRequest(t, req, 302)
})
}

View File

@@ -1,235 +0,0 @@
package git_test
import (
"net/url"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func gitClone(baseUrl, creds, user, gistId, destDir string) error {
authUrl := baseUrl
if creds != "" {
authUrl = "http://" + creds + "@" + baseUrl[len("http://"):]
}
return exec.Command("git", "clone", authUrl+"/"+user+"/"+gistId+".git", destDir).Run()
}
func gitPush(repoDir, filename, content string) error {
if err := os.WriteFile(filepath.Join(repoDir, filename), []byte(content), 0644); err != nil {
return err
}
if err := exec.Command("git", "-C", repoDir, "add", filename).Run(); err != nil {
return err
}
if err := exec.Command("git", "-C", repoDir, "commit", "-m", "add "+filename).Run(); err != nil {
return err
}
return exec.Command("git", "-C", repoDir, "push", "origin").Run()
}
func TestGitClonePull(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
baseUrl := s.StartHttpServer(t)
s.Register(t, "thomas")
s.Register(t, "alice")
_, _, user, publicId := s.CreateGist(t, "0")
_, _, _, unlistedId := s.CreateGist(t, "1")
_, _, _, privateId := s.CreateGist(t, "2")
type credTest struct {
name string
creds string
expect [3]bool // [public, unlisted, private]
}
tests := []struct {
name string
settings map[string]string
creds []credTest
}{
{
name: "Default",
creds: []credTest{
{"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}},
{"OtherUserAuth", "alice:alice", [3]bool{true, true, false}},
{"WrongPassword", "thomas:wrong", [3]bool{true, true, false}},
{"WrongUser", "aze:aze", [3]bool{true, true, false}},
{"Anonymous", "", [3]bool{true, true, false}},
},
},
{
name: "RequireLogin",
settings: map[string]string{db.SettingRequireLogin: "1"},
creds: []credTest{
{"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}},
{"OtherUserAuth", "alice:alice", [3]bool{true, true, false}},
{"WrongPassword", "thomas:wrong", [3]bool{false, false, false}},
{"WrongUser", "aze:aze", [3]bool{false, false, false}},
{"Anonymous", "", [3]bool{false, false, false}},
},
},
{
name: "AllowGistsWithoutLogin",
settings: map[string]string{db.SettingRequireLogin: "1", db.SettingAllowGistsWithoutLogin: "1"},
creds: []credTest{
{"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}},
{"OtherUserAuth", "alice:alice", [3]bool{true, true, false}},
{"WrongPassword", "thomas:wrong", [3]bool{true, true, false}},
{"WrongUser", "aze:aze", [3]bool{true, true, false}},
{"Anonymous", "", [3]bool{true, true, false}},
},
},
}
gists := [3]string{publicId, unlistedId, privateId}
labels := [3]string{"Public", "Unlisted", "Private"}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas")
for k, v := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
}
for _, ct := range tt.creds {
t.Run(ct.name, func(t *testing.T) {
for i, id := range gists {
t.Run(labels[i], func(t *testing.T) {
dest := t.TempDir()
err := gitClone(baseUrl, ct.creds, user, id, dest)
if ct.expect[i] {
require.NoError(t, err)
_, err = os.Stat(filepath.Join(dest, "file.txt"))
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
})
}
// Reset settings
s.Login(t, "thomas")
for k := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
}
})
}
}
func TestGitPush(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
baseUrl := s.StartHttpServer(t)
s.Register(t, "thomas")
s.Register(t, "alice")
_, _, user, publicId := s.CreateGist(t, "0")
_, _, _, unlistedId := s.CreateGist(t, "1")
_, _, _, privateId := s.CreateGist(t, "2")
type credTest struct {
name string
creds string
expect [3]bool // [public, unlisted, private]
}
tests := []credTest{
{"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}},
{"OtherUserAuth", "alice:alice", [3]bool{false, false, false}},
{"WrongPassword", "thomas:wrong", [3]bool{false, false, false}},
{"WrongUser", "aze:aze", [3]bool{false, false, false}},
{"Anonymous", "", [3]bool{false, false, false}},
}
gists := [3]string{publicId, unlistedId, privateId}
labels := [3]string{"Public", "Unlisted", "Private"}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for i, id := range gists {
t.Run(labels[i], func(t *testing.T) {
dest := t.TempDir()
require.NoError(t, gitClone(baseUrl, "thomas:thomas", user, id, dest))
if tt.creds != "thomas:thomas" {
require.NoError(t, exec.Command("git", "-C", dest, "remote", "set-url", "origin",
"http://"+tt.creds+"@"+baseUrl[len("http://"):]+"/"+user+"/"+id+".git").Run())
}
err := gitPush(dest, "newfile.txt", "new content")
if tt.expect[i] {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
})
}
}
func TestGitCreatePush(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
baseUrl := s.StartHttpServer(t)
s.Register(t, "thomas")
s.Register(t, "alice")
gitInitAndPush := func(t *testing.T, creds, remoteUrl string) error {
dest := t.TempDir()
require.NoError(t, exec.Command("git", "init", "--initial-branch=master", dest).Run())
require.NoError(t, exec.Command("git", "-C", dest, "remote", "add", "origin",
"http://"+creds+"@"+baseUrl[len("http://"):]+remoteUrl).Run())
require.NoError(t, os.WriteFile(filepath.Join(dest, "hello.txt"), []byte("hello"), 0644))
require.NoError(t, exec.Command("git", "-C", dest, "add", "hello.txt").Run())
require.NoError(t, exec.Command("git", "-C", dest, "commit", "-m", "initial").Run())
return exec.Command("git", "-C", dest, "push", "origin").Run()
}
tests := []struct {
name string
creds string
url string
expect bool
gistOwner string // if expect=true, verify gist exists at this owner/identifier
gistId string
}{
{"OwnerCreates", "thomas:thomas", "/thomas/mygist.git", true, "thomas", "mygist"},
{"OtherUserCreatesOnOwnUrl", "alice:alice", "/alice/alicegist.git", true, "alice", "alicegist"},
{"WrongPassword", "thomas:wrong", "/thomas/newgist.git", false, "", ""},
{"OtherUserCannotCreateOnOwner", "alice:alice", "/thomas/hackgist.git", false, "", ""},
{"WrongUser", "aze:aze", "/aze/azegist.git", false, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := gitInitAndPush(t, tt.creds, tt.url)
if tt.expect {
require.NoError(t, err)
gist, err := db.GetGist(tt.gistOwner, tt.gistId)
require.NoError(t, err)
require.NotNil(t, gist)
require.Equal(t, tt.gistId, gist.Identifier())
} else {
require.Error(t, err)
}
})
}
}

View File

@@ -1,30 +0,0 @@
package health_test
import (
"encoding/json"
"io"
"testing"
"github.com/stretchr/testify/require"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestHealthcheck(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
t.Run("OK", func(t *testing.T) {
resp := s.Request(t, "GET", "/healthcheck", nil, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(body, &result)
require.NoError(t, err)
require.Equal(t, "ok", result["opengist"])
require.Equal(t, "ok", result["database"])
require.NotEmpty(t, result["time"])
})
}

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

@@ -1,332 +0,0 @@
package settings_test
import (
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestAccessTokensCRUD(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("RequiresAuth", func(t *testing.T) {
s.Logout()
s.Request(t, "GET", "/settings/access-tokens", nil, 302)
})
t.Run("AccessTokensPage", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "GET", "/settings/access-tokens", nil, 200)
})
t.Run("CreateReadToken", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
Name: "test-token",
ScopeGist: db.ReadPermission,
}, 302)
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)
})
t.Run("CreateExpiringToken", func(t *testing.T) {
s.Login(t, "thomas")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
Name: "expiring-token",
ScopeGist: db.ReadWritePermission,
ExpiresAt: tomorrow,
}, 302)
tokens, err := db.GetAccessTokensByUserID(1)
require.NoError(t, err)
require.Len(t, tokens, 2)
})
t.Run("DeleteToken", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "DELETE", "/settings/access-tokens/1", nil, 302)
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 := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user, identifier := s.CreateGist(t, "2")
// 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)
require.NoError(t, token.Create())
s.Logout()
headers := map[string]string{"Authorization": "Token " + plainToken}
t.Run("NoTokenReturns404", func(t *testing.T) {
s.Request(t, "GET", "/"+user+"/"+identifier, nil, 404)
})
t.Run("ValidTokenGrantsAccess", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, headers)
})
t.Run("RawContentAccessible", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier+"/raw/HEAD/file.txt", nil, 200, headers)
})
t.Run("JSONEndpointAccessible", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier+".json", nil, 200, headers)
})
t.Run("InvalidTokenReturns404", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{
"Authorization": "Token invalid_token",
})
})
}
func TestAccessTokenPermissions(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user, identifier := s.CreateGist(t, "2")
// 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)
require.NoError(t, noPermToken.Create())
// Create token with READ permission
readToken := &db.AccessToken{
Name: "read-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
readPlain, err := readToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, readToken.Create())
s.Logout()
t.Run("NoPermissionDenied", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{
"Authorization": "Token " + noPermPlain,
})
})
t.Run("ReadPermissionGranted", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{
"Authorization": "Token " + readPlain,
})
})
}
func TestAccessTokenExpiration(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user, identifier := s.CreateGist(t, "2")
// Create an expired token
expiredToken := &db.AccessToken{
Name: "expired-token",
UserID: 1,
ScopeGist: db.ReadPermission,
ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(),
}
expiredPlain, err := expiredToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, expiredToken.Create())
// Create a valid token
validToken := &db.AccessToken{
Name: "valid-token",
UserID: 1,
ScopeGist: db.ReadPermission,
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
}
validPlain, err := validToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, validToken.Create())
s.Logout()
t.Run("ExpiredTokenDenied", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{
"Authorization": "Token " + expiredPlain,
})
})
t.Run("ValidTokenGranted", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{
"Authorization": "Token " + validPlain,
})
})
}
func TestAccessTokenWrongUser(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "kaguya")
_, _, user, identifier := s.CreateGist(t, "2")
// Create token for kaguya
kaguyaToken := &db.AccessToken{
Name: "kaguya-token",
UserID: 2,
ScopeGist: db.ReadPermission,
}
kaguyaPlain, err := kaguyaToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, kaguyaToken.Create())
// Create token for thomas
thomasToken := &db.AccessToken{
Name: "thomas-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
thomasPlain, err := thomasToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, thomasToken.Create())
s.Logout()
t.Run("OtherUserTokenDenied", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{
"Authorization": "Token " + kaguyaPlain,
})
})
t.Run("OwnerTokenGranted", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{
"Authorization": "Token " + thomasPlain,
})
})
}
func TestAccessTokenLastUsedUpdate(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user, identifier := s.CreateGist(t, "2")
token := &db.AccessToken{
Name: "test-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
plainToken, err := token.GenerateToken()
require.NoError(t, err)
require.NoError(t, token.Create())
// Verify LastUsedAt is 0 initially
tokenFromDB, err := db.GetAccessTokenByID(token.ID)
require.NoError(t, err)
require.Equal(t, int64(0), tokenFromDB.LastUsedAt)
s.Logout()
// Use the token
s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{
"Authorization": "Token " + plainToken,
})
// 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 := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, _, user1, identifier1 := s.CreateGist(t, "2")
s.Login(t, "thomas")
_, _, user2, identifier2 := s.CreateGist(t, "0")
s.Login(t, "thomas")
token := &db.AccessToken{
Name: "read-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
plainToken, err := token.GenerateToken()
require.NoError(t, err)
require.NoError(t, token.Create())
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingRequireLogin}, "value": {"1"}}, 200)
s.Logout()
headers := map[string]string{"Authorization": "Token " + plainToken}
t.Run("UnauthenticatedRedirects", func(t *testing.T) {
s.Request(t, "GET", "/"+user1+"/"+identifier1, nil, 302)
s.Request(t, "GET", "/"+user2+"/"+identifier2, nil, 302)
})
t.Run("ValidTokenGrantsAccess", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1, nil, 200, headers)
s.RequestWithHeaders(t, "GET", "/"+user2+"/"+identifier2, nil, 200, headers)
s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1+"/raw/HEAD/file.txt", nil, 200, headers)
})
t.Run("InvalidTokenRedirects", func(t *testing.T) {
s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1, nil, 302, map[string]string{
"Authorization": "Token invalid_token",
})
})
t.Run("NoPermTokenRedirects", func(t *testing.T) {
noPermToken := &db.AccessToken{
Name: "no-perm-token",
UserID: 1,
ScopeGist: db.NoPermission,
}
noPermPlain, err := noPermToken.GenerateToken()
require.NoError(t, err)
require.NoError(t, noPermToken.Create())
s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1, nil, 302, map[string]string{
"Authorization": "Token " + noPermPlain,
})
})
}

View File

@@ -3,17 +3,16 @@ package settings
import (
"crypto/md5"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"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"
"os"
"path/filepath"
"strings"
"time"
)
func EmailProcess(ctx *context.Context) error {
@@ -62,22 +61,18 @@ func UsernameProcess(ctx *context.Context) error {
return ctx.RedirectTo("/settings")
}
if !strings.EqualFold(dto.Username, user.Username) {
if exists, err := db.UserExists(dto.Username); err != nil || exists {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
return ctx.RedirectTo("/settings")
}
if exists, err := db.UserExists(dto.Username); err != nil || exists {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
return ctx.RedirectTo("/settings")
}
sourceDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(user.Username))
destinationDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(dto.Username))
if sourceDir != destinationDir {
if _, err := os.Stat(sourceDir); !os.IsNotExist(err) {
err := os.Rename(sourceDir, destinationDir)
if err != nil {
return ctx.ErrorRes(500, "Cannot rename user directory", err)
}
if _, err := os.Stat(sourceDir); !os.IsNotExist(err) {
err := os.Rename(sourceDir, destinationDir)
if err != nil {
return ctx.ErrorRes(500, "Cannot rename user directory", err)
}
}

View File

@@ -8,7 +8,7 @@ import (
"strings"
"github.com/gorilla/schema"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/web/context"
)
@@ -119,16 +119,10 @@ func Paginate[T any](ctx *context.Context, data []*T, pageInt int, perPage int,
return nil
}
// ParseSearchQueryStr parses a search query string and returns a map of metadata.
// The query string is split into words and each word is checked if it contains a colon (:).
// If a word contains a colon, it is split into a key-value pair and added to the metadata map.
// If a word does not contain a colon, it is added to an "all" key in the metadata map.
// The "all" key is used to search all fields in the index.
// The function returns the metadata map.
func ParseSearchQueryStr(query string) map[string]string {
func ParseSearchQueryStr(query string) (string, map[string]string) {
words := strings.Fields(query)
metadata := make(map[string]string)
var allFieldsBuilder strings.Builder
var contentBuilder strings.Builder
for _, word := range words {
if strings.Contains(word, ":") {
@@ -139,18 +133,10 @@ func ParseSearchQueryStr(query string) map[string]string {
metadata[key] = value
}
} else {
// Add to content search by default
allFieldsBuilder.WriteString(word + " ")
contentBuilder.WriteString(word + " ")
}
}
// Set the default search field
allContent := strings.TrimSpace(allFieldsBuilder.String())
if allContent != "" {
metadata["default"] = allContent
}
log.Debug().Msgf("Metadata: %v", metadata)
return metadata
content := strings.TrimSpace(contentBuilder.String())
return content, metadata
}

View File

@@ -28,7 +28,7 @@ import (
func (s *Server) useCustomContext() {
s.echo.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
cc := context.NewContext(c, filepath.Join(config.GetHomeDir(), "sessions"))
cc := context.NewContext(c, s.sessionsPath)
return next(cc)
}
})
@@ -38,7 +38,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{
@@ -58,27 +59,29 @@ func (s *Server) registerMiddlewares() {
s.echo.Use(middleware.Recover())
s.echo.Use(middleware.Secure())
s.echo.Use(Middleware(sessionInit).toEcho())
s.echo.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "form:_csrf,header:X-CSRF-Token",
CookiePath: "/",
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
Skipper: func(ctx echo.Context) bool {
/* skip CSRF for embeds */
gistName := ctx.Param("gistname")
/* skip CSRF for git clients */
matchUploadPack, _ := regexp.MatchString("(.*?)/git-upload-pack$", ctx.Request().URL.Path)
matchReceivePack, _ := regexp.MatchString("(.*?)/git-receive-pack$", ctx.Request().URL.Path)
return (filepath.Ext(gistName) == ".js" && ctx.Request().Method == "GET") || matchUploadPack || matchReceivePack
},
ErrorHandler: func(err error, c echo.Context) error {
log.Info().Err(err).Msg("CSRF error")
return err
},
}))
s.echo.Use(Middleware(csrfInit).toEcho())
if !s.ignoreCsrf {
s.echo.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "form:_csrf,header:X-CSRF-Token",
CookiePath: "/",
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
Skipper: func(ctx echo.Context) bool {
/* skip CSRF for embeds */
gistName := ctx.Param("gistname")
/* skip CSRF for git clients */
matchUploadPack, _ := regexp.MatchString("(.*?)/git-upload-pack$", ctx.Request().URL.Path)
matchReceivePack, _ := regexp.MatchString("(.*?)/git-receive-pack$", ctx.Request().URL.Path)
return filepath.Ext(gistName) == ".js" || matchUploadPack || matchReceivePack
},
ErrorHandler: func(err error, c echo.Context) error {
log.Info().Err(err).Msg("CSRF error")
return err
},
}))
s.echo.Use(Middleware(csrfInit).toEcho())
}
}
func (s *Server) errorHandler(err error, ctx echo.Context) {
@@ -157,10 +160,10 @@ func dataInit(next Handler) Handler {
func writePermission(next Handler) Handler {
return func(ctx *context.Context) error {
gist := ctx.GetData("gist").(*db.Gist)
gist := ctx.GetData("gist")
user := ctx.User
if !gist.CanWrite(user) {
return ctx.ErrorRes(403, "You don't have permission to edit this gist", nil)
if !gist.(*db.Gist).CanWrite(user) {
return ctx.RedirectTo("/" + gist.(*db.Gist).User.Username + "/" + gist.(*db.Gist).Identifier())
}
return next(ctx)
}
@@ -197,17 +200,6 @@ func inMFASession(next Handler) Handler {
}
}
func inOAuthRegisterSession(next Handler) Handler {
return func(ctx *context.Context) error {
sess := ctx.GetSession()
_, ok := sess.Values["oauthProvider"].(string)
if !ok {
return ctx.RedirectTo("/login")
}
return next(ctx)
}
}
func makeCheckRequireLogin(isSingleGistAccess bool) Middleware {
return func(next Handler) Handler {
return func(ctx *context.Context) error {
@@ -215,9 +207,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")
@@ -318,6 +307,7 @@ func csrfInit(next Handler) Handler {
csrf = csrfToken
}
ctx.SetData("csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
ctx.SetData("csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
return next(ctx)
}
@@ -337,39 +327,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
@@ -396,12 +353,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

@@ -147,10 +147,7 @@ func (s *Server) setFuncMap() {
return dict, nil
},
"addMetadataToSearchQuery": func(input, key, value string) string {
metadata := handlers.ParseSearchQueryStr(input)
// extract free-text content (stored under "all") and remove it from metadata
content := metadata["all"]
delete(metadata, "all")
content, metadata := handlers.ParseSearchQueryStr(input)
metadata[key] = value
@@ -195,9 +192,6 @@ func (s *Server) setFuncMap() {
"humanDate": func(t int64) string {
return time.Unix(t, 0).Format("02/01/2006 15:04")
},
"humanDateOnly": func(t int64) string {
return time.Unix(t, 0).Format("02/01/2006")
},
"mainTheme": func(theme *db.UserStyleDTO) string {
if theme == nil {
return "auto"

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"
)
@@ -33,13 +34,15 @@ func (s *Server) registerRoutes() {
r.GET("/healthcheck", health.Healthcheck)
if config.C.MetricsEnabled {
r.GET("/metrics", metrics.Metrics)
}
r.GET("/register", auth.Register)
r.POST("/register", auth.ProcessRegister)
r.GET("/login", auth.Login)
r.POST("/login", auth.ProcessLogin)
r.GET("/logout", auth.Logout)
r.GET("/oauth/register", auth.OauthRegister, inOAuthRegisterSession)
r.POST("/oauth/register", auth.ProcessOauthRegister, inOAuthRegisterSession)
r.GET("/oauth/:provider", auth.Oauth)
r.GET("/oauth/:provider/callback", auth.OauthCallback)
r.GET("/oauth/:provider/unlink", auth.OauthUnlink, logged)
@@ -64,9 +67,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

@@ -2,6 +2,7 @@ package server
import (
"fmt"
"github.com/thomiceli/opengist/internal/validator"
"net"
"net/http"
"os"
@@ -9,8 +10,6 @@ import (
"strconv"
"strings"
"github.com/thomiceli/opengist/internal/validator"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
@@ -19,16 +18,19 @@ import (
type Server struct {
echo *echo.Echo
dev bool
dev bool
sessionsPath string
ignoreCsrf bool
}
func NewServer(isDev bool) *Server {
func NewServer(isDev bool, sessionsPath string, ignoreCsrf bool) *Server {
e := echo.New()
e.HideBanner = true
e.HidePort = true
e.Validator = validator.NewValidator()
s := &Server{echo: e, dev: isDev}
s := &Server{echo: e, dev: isDev, sessionsPath: sessionsPath, ignoreCsrf: ignoreCsrf}
s.useCustomContext()
@@ -173,7 +175,3 @@ func (s *Server) createPidFile(pidPath string) error {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.echo.ServeHTTP(w, r)
}
func (s *Server) Use(middleware echo.MiddlewareFunc) {
s.echo.Use(middleware)
}

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