Compare commits

...

48 Commits

Author SHA1 Message Date
Thomas Miceli
3c0115d829
Fix Markdown preview links (#475) 2025-05-15 15:16:40 +02:00
Thomas Miceli
d796895b75
Fix filename unescape (#474) 2025-05-14 11:51:42 +02:00
Andy Piper
5542497622 Add Proxmox VE Helper-Script (#473) 2025-05-14 10:49:27 +02:00
Thomas Miceli
546f1968e0 Fix helm ci 2025-05-09 20:16:57 +02:00
Thomas Miceli
75e71fd042
Use Helm deployment.env[] values (#471) 2025-05-09 20:08:25 +02:00
Thomas Miceli
897dc43790
Add LDAP authentication (#470)
* Introduce basic LDAP authentication.

* Reformat LDAP code; use ldap in Git HTTP

* lint

---------

Co-authored-by: Santhosh Raju <santhosh.raju@gmail.com>
2025-05-09 19:32:22 +02:00
Johannes Kirchner
72e02700ec
fix: Correct German spelling, use consistent wording (#468) 2025-05-05 15:04:28 +02:00
Thomas Miceli
dc43fccc04
Style preference tab for user (#467) 2025-05-05 01:31:42 +02:00
Sergey Ryazanov
0e9b778b45
Fix Gitlab avatar (#461)
* Fix GitLab user avatar method

* Fix size of Gitlab avatar
2025-05-05 00:46:29 +02:00
Johannes Kirchner
3c940cd81f
feat: read psql sslmode from db uri (#462) 2025-05-05 00:29:13 +02:00
Thomas Miceli
de144d09d3
Update README.md 2025-04-09 15:45:38 +02:00
Thomas Miceli
fde8a85e2b v1.10.0 2025-04-07 16:31:45 +02:00
Thomas Miceli
b82b3d9e0e
Update Go deps (#455) 2025-04-06 01:11:44 +02:00
Thomas Miceli
9e69677f58
Add Helm Chart (#454) 2025-04-06 00:51:38 +02:00
Thomas Miceli
2d8debecbe
Translations update from Opengist (#438)
* Added translation using Weblate (Japanese)

* Translated using Weblate (Japanese)

Currently translated at 15.8% (47 of 297 strings)

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

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (297 of 297 strings)

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

---------

Co-authored-by: YoshichikaAAA <isthisyourpen@gmail.com>
Co-authored-by: Ricky <1173024819@qq.com>
2025-04-06 00:51:18 +02:00
Johannes Kirchner
8cfaceb303
feat: read admin group from OIDC token claim (#445) 2025-04-02 13:38:11 +02:00
jmjl
7907c7bc1e
Fix gist.html using relative URL (#451)
Due to the fact the file templates/base/base_header.html contains a
<base> element, all relative URLs are interpreted as dependant on the
base.[1]

I've noticed the base isn't the current page, but the element linking to
anchor identifier isn't using the complete URL to the gist page, which
means that if you go to a gist, and try to click on the link that leads
you to the file (which would make browsers automatically go down if it's
a file that has a lot of lines), you get taken to the homepage, and
unless you look at the URL closely you wouldn't notice the
fragment/anchor part.

I'm sure there's a better way of dealing with this, such as removing
<base> from the template mentioned above, but due to the fact I'd like
to have this work, I've made it put the full URL to this page.

Something that might be good to do is making the relative URLs always be
absolute, by having the '{{ $.c.ExternalUrl }}' thing everywhere where a
relative URL would be, as that'd probably fix #415, and would allow for
this commit to be reverted if that's desired.

[1] https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
2025-03-31 23:07:01 +02:00
Philipp Eckel
e3aa994d30
fix: do not hide file delete button on gist edit page (#447) 2025-03-31 22:44:04 +02:00
Ross A. Baker
91df15f957
Allow lag between admin invitation creation and test assertion (#452) 2025-03-31 11:53:12 +02:00
Thomas Miceli
efba783c56
Add Meilisearch indexer (#444) 2025-03-19 23:28:04 +01:00
Philipp Eckel
dbdfcd4e85
feat: add option to name an OIDC provider (#435) 2025-03-17 17:19:48 +01:00
awkj
da0b440360
Fix garbled/mojibake text display issues for non-English Unicode characters in browsers. (#441)
* Update util.go

Fix garbled/mojibake text display issues for non-English Unicode characters in browsers.

* add Content-Disposition, help handle file name on download

Author:    awkj <hzzbiu@gmail.com>
2025-03-17 16:22:54 +01:00
Thomas Miceli
d53885c541
Fix test database with go command (#442) 2025-03-17 16:17:53 +01:00
Philipp Eckel
1ec026e191
feat: add Prometheus metrics (#439)
* feat: add Prometheus metrics

* setup metrics using Prometheus client under /metrics endpoint
* add configuration value for metrics
* configure Prometheus middleware for generic metrics
* provide metrics for totals of users, gists and SSH keys
* modify test request to optionally return the response
* provide integration test for Prometheus metrics
* update documentation

* chore: make fmt
2025-03-17 14:30:38 +01:00
Thomas Miceli
8c7e941182 v1.9.1 2025-02-04 21:22:47 +01:00
Thomas Miceli
26b5044380
Update go deps (#430) 2025-02-04 21:17:10 +01:00
Thomas Miceli
a2259d5c77
Translations update from Opengist (#401)
* Translated using Weblate (German)

Currently translated at 94.3% (265 of 281 strings)

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

* Translated using Weblate (German)

Currently translated at 99.6% (280 of 281 strings)

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

---------

Co-authored-by: Sangelo <minecraft.sangelo89@gmail.com>
Co-authored-by: m4skedbyte <m4skedbyte@protonmail.com>
2025-02-03 23:43:59 +01:00
Thomas Miceli
6fd7f77003
Fix user avatar on gist likes list (#425) 2025-02-03 23:43:43 +01:00
Thomas Miceli
87ae60ce4c
Fix SQL query for MySQL/Postgres on user profile (#424) 2025-02-03 23:29:34 +01:00
Thomas Miceli
c14380f4de v1.9.0 2025-02-02 20:48:40 +01:00
Thomas Miceli
da36e9eb55 Add Docker hub in release images registry CI 2025-02-02 20:11:58 +01:00
Thomas Miceli
7aa8f84eff
Search gists on user profile with title, visibility, language & topics (#422) 2025-02-02 18:14:03 +01:00
Thomas Miceli
76fc129c09
Remove memdb for gist init (#421) 2025-01-30 10:46:35 +01:00
Thomas Miceli
62d56cd1c7
Save content form on gist create error (#420) 2025-01-29 16:00:58 +01:00
Thomas Miceli
d363743203
Fix empty password error when trying to change the username (#418) 2025-01-27 00:57:46 +01:00
Thomas Miceli
28c7e75657
Use jdenticon for default avatars (#416) 2025-01-27 00:08:50 +01:00
soup
0609b64cff
feat: add MIME type support for raw file serving (#417) 2025-01-26 23:40:59 +01:00
Thomas Miceli
f5b8881d35
Add topics for Gists (#413) 2025-01-24 14:39:42 +01:00
gofastasf
8369cbf2f0
fix: replace path.Join with filepath.Join for file system paths (#414) 2025-01-21 07:46:59 +01:00
千橙
2ab9cf556f
Add git push option for description (#412) 2025-01-20 18:16:31 +01:00
Thomas Miceli
662f553d37
Remove CSRF check for Git HTTP packs (#408) 2025-01-20 03:18:28 +01:00
Andreas Jaggi
a752e0561d
Skip CSRF for embeds (#402)
* Skip CSRF for embeds

The CSRF middleware sets a _csrf cookie also for loading the embed
javascript on third-party sites. With this change no _csrf cookie is set
when loading the embed javascript (regardless if third-party site or
first-party).
2025-01-20 02:18:45 +01:00
Thomas Miceli
f935ee1a7e
Refactor server code (#407) 2025-01-20 01:57:39 +01:00
Thomas Miceli
4c5a7bda63 v1.8.4 2024-12-16 01:46:26 +01:00
Thomas Miceli
f6bf09d5c2
Translations update from Opengist (#398)
* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (281 of 281 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (281 of 281 strings)

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

---------

Co-authored-by: GabrielxD <gabrielxduo@outlook.com>
Co-authored-by: GGORG <GGORG0@protonmail.com>
2024-12-15 17:52:52 +01:00
Phani Rithvij
86dd59c695
fixup! esbuild for all other platforms (#395) 2024-12-15 17:52:33 +01:00
Sangelo
20aef5e694
feat: Add custom instance names (#399)
* Add custom name variable

* Add custom name variable usage to docs

* Remove leftover testing config options (oops)
2024-12-15 17:39:51 +01:00
soup
00951bf63b
feat(web): prevent password manager autofill on filename inputs (#357)
* feat(web): add data-1p-ignore attribute to ignore fields

* feat(web): extend password manager ignore attributes

- Add autocomplete="off" to prevent browser autofill
- Add data-lpignore for LastPass compatibility
- Add data-bwignore for Bitwarden compatibility
2024-12-15 17:35:08 +01:00
161 changed files with 9956 additions and 5772 deletions

53
.github/workflows/helm.yml vendored Normal file
View File

@ -0,0 +1,53 @@
name: Build / Deploy Helm Chart
on:
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v4.3.0
with:
version: 'latest'
- name: Update Helm chart dependencies
run: |
cd ./helm/opengist
helm dependency update
- name: Package Helm chart
run: |
cd ./helm
helm package ./opengist
# First time, create the index
wget -q https://helm.opengist.io/index.yaml
if [ ! -f index.yaml ]; then
helm repo index --url https://helm.opengist.io .
else
# For subsequent runs, merge with existing index
helm repo index --url https://helm.opengist.io --merge index.yaml .
fi
- name: Deploy to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "./helm/*.tgz,./helm/index.yaml"
target: ${{ secrets.HELM_SERVER_PATH }}
- name: Update remote helm repository
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
${{ secrets.UPDATE_HELM_REPO }}

View File

@ -46,6 +46,7 @@ jobs:
with:
images: |
ghcr.io/thomiceli/opengist
docker.io/thomiceli/opengist
tags: |
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
type=semver,pattern={{major}}
@ -65,6 +66,12 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
@ -74,4 +81,4 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max

4
.gitignore vendored
View File

@ -1,11 +1,15 @@
node_modules/
gist.db
.idea/
.vscode/
.DS_Store
/**/.DS_Store
public/assets/*
public/manifest.json
./opengist
opengist
build/
docs/.vitepress/dist/
docs/.vitepress/cache/
helm/opengist/charts/
vendor/

View File

@ -1,5 +1,77 @@
# Changelog
## [1.10.0](https://github.com/thomiceli/opengist/compare/v1.9.1...v1.10.0) - 2025-04-07
See here how to [update](https://opengist.io/docs/update) Opengist.
### 🔴 Deprecations
_Removed in the next SemVer MAJOR version of Opengist._
* Use the configuration option `index`/`OG_INDEX` **instead of** `index.enabled`/`OG_INDEX_ENABLED`. The default value is `bleve`.
* The configuration `index.dirname`/`OG_INDEX_DIRNAME` will be removed. If you're using Bleve, the path of the index will be `opengist.index`.
### Added
- Helm Chart (#454)
- Meilisearch indexer (#444)
- Prometheus metrics (#439)
- Config to name the OIDC provider (#435)
- Read admin group from OIDC token claim (#445)
- More translation strings (#438)
### Fixed
- Garbled text display issues for non-English Unicode characters in browsers (#441)
- Test database when running `go test` (#442)
- Allow lag between admin invitation creation and test assertion (#452)
- gist.html using relative URL (#451)
- Do not hide file delete button on gist edit page (#447)
### Other
- Update deps Golang & JS deps (#455)
## [1.9.1](https://github.com/thomiceli/opengist/compare/v1.9.0...v1.9.1) - 2025-02-04
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- More translation strings (#401)
### Fixed
- SQL query for MySQL/Postgres on user profile (#424)
- User avatar on gist likes list (#425)
### Other
- Update deps Golang & JS deps (#430)
## [1.9.0](https://github.com/thomiceli/opengist/compare/v1.8.4...v1.9.0) - 2025-02-02
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- Topics (tags) for Gists (#413)
- Gist languages saved in database (#422)
- Search gists on user profile with title, visibility, language & topics (#422)
- Jdenticon for default avatars (#416)
- Git push option for description (#412)
- MIME type support for raw file serving (#417)
### Fixed
- Skip CSRF for embed gists (#402)
- Remove CSRF check for Git HTTP packs (#408)
- Replace path.Join with filepath.Join for file system paths (#414)
- Empty password error when trying to change the username (#418)
- Save content form on gist create error (#420)
### Other
- Refactor server code (#407)
- Remove memdb for gist init (#421)
- Added Opengist Docker images to Docker Hub
## [1.8.4](https://github.com/thomiceli/opengist/compare/v1.8.3...v1.8.4) - 2024-12-15
See here how to [update](/docs/update.md) Opengist.
### Added
- More translation strings (#398)
- Custom instance names (#399)
### Fixed
- Prevent passwords managers autofill on filename inputs (#357)
## [1.8.3](https://github.com/thomiceli/opengist/compare/v1.8.2...v1.8.3) - 2024-11-26
See here how to [update](/docs/update.md) Opengist.

View File

@ -21,13 +21,14 @@ It is similar to [GitHub Gist](https://gist.github.com/), but open-source and co
* [Init](/docs/usage/init-via-git.md) / Clone / Pull / Push snippets **via Git** over HTTP or SSH
* Syntax highlighting ; markdown & CSV support
* Search code in snippets; browse users snippets, likes and forks
* Add topics to snippets
* Embed snippets in other websites
* Revisions history
* Like / Fork snippets
* Download raw files or as a ZIP archive
* OAuth2 login with GitHub, GitLab, Gitea, and OpenID Connect
* Restrict or unrestrict snippets visibility to anonymous users
* Docker support
* Docker support / Helm Chart
* [More...](/docs/introduction.md#features)
## Quick start
@ -37,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.8
docker pull ghcr.io/thomiceli/opengist:1.10
```
It can be used in a `docker-compose.yml` file :
@ -49,7 +50,7 @@ It can be used in a `docker-compose.yml` file :
```yml
services:
opengist:
image: ghcr.io/thomiceli/opengist:1.8
image: ghcr.io/thomiceli/opengist:1.10
container_name: opengist
restart: unless-stopped
ports:
@ -76,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.8.3/opengist1.8.3-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.10.0/opengist1.10.0-linux-amd64.tar.gz
tar xzvf opengist1.8.3-linux-amd64.tar.gz
tar xzvf opengist1.10.0-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`

View File

@ -23,11 +23,14 @@ secret-key:
# MySQL/MariaDB: mysql://user:password@host:port/database
db-uri: opengist.db
# Enable or disable the code search index (either `true` or `false`). Default: true
index.enabled: true
# Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). Default: bleve
index: bleve
# Name of the directory where the code search index is stored. Default: opengist.index
index.dirname: opengist.index
# Set the host for the Meiliseach server
index.meili.host:
# Set the API key for the Meiliseach server
index.meili.api-key:
# 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
@ -38,7 +41,6 @@ git.default-branch:
# For SQLite databases only.
sqlite.journal-mode: WAL
# HTTP server configuration
# Host to bind to. Default: 0.0.0.0
http.host: 0.0.0.0
@ -49,6 +51,9 @@ http.port: 6157
# Enable or disable git operations (clone, pull, push) via HTTP (either `true` or `false`). Default: true
http.git-enabled: true
# Enable or disable the metrics endpoint (either `true` or `false`). Default: false
metrics.enabled: false
# SSH built-in server configuration
# Note: it is not using the SSH daemon from your machine (yet)
@ -72,7 +77,6 @@ ssh.external-domain:
# Path or alias to ssh-keygen executable. Default: ssh-keygen
ssh.keygen-executable: ssh-keygen
# OAuth2 configuration
# The callback/redirect URL must be http://opengist.url/oauth/<github|gitlab|gitea|openid-connect>/callback
@ -97,11 +101,31 @@ gitea.url: https://gitea.com/
gitea.name: Gitea
# To create a new OAuth2 application using OpenID Connect:
oidc.provider-name:
oidc.client-key:
oidc.secret:
# Discovery endpoint of the OpenID provider. Generally something like http://auth.example.com/.well-known/openid-configuration
oidc.discovery-url:
# The name of the claim containing the groups
oidc.group-claim-name:
# The name of the group that should receive admin rights
oidc.admin-group:
# LDAP authentication configuration
# URL of the LDAP instance e.g: ldap://ldap.example.com:389 ; if not set, LDAP authentication is disabled
ldap.url:
# Bind DN to authenticate against the LDAP e.g: cn=read-only-admin,dc=example,dc=com
ldap.bind-dn:
# The password for the Bind DN.
ldap.bind-credentials:
# The Base DN to start search from e.g: ou=People,dc=example,dc=com
ldap.search-base:
# The filter to search against (the format string %s will be replaced with the username) e.g: (uid=%s)
ldap.search-filter:
# Instance name
# Set your own custom name to be displayed instead of 'Opengist'
custom.name:
# Custom assets
# Add your own custom assets, that are files relatives to $opengist-home/custom/

View File

@ -1,75 +0,0 @@
# kustomize
## Simple
`kustomization.yaml`:
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
metadata:
name: opengist
resources:
- https://github.com/thomiceli/opengist/deploy/
```
## Full example
`kustomization.yaml`:
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
metadata:
name: opengist
namespace: opengist
resources:
- namespace.yaml
- https://github.com/thomiceli/opengist/deploy/?ref:v1.8.3
images:
- name: ghcr.io/thomiceli/opengist
newTag: 1.8.3
patches:
# Add your ingress
- path: ingress.yaml
- patch: |-
- op: add
path: /spec/rules/0/host
value: opengist.mydomain.com
target:
group: networking.k8s.io
version: v1
kind: Ingress
name: opengist
```
`namespace.yaml`:
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: opengist
```
`ingress.yaml`:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: opengist
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
spec:
ingressClassName: nginx
tls:
- hosts:
- opengist.mydomain.com
secretName: opengist-tls
```

View File

@ -1,29 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: opengist
spec:
selector:
matchLabels:
app.kubernetes.io/name: opengist
template:
metadata:
labels:
app.kubernetes.io/name: opengist
spec:
containers:
- name: opengist
image: ghcr.io/thomiceli/opengist
ports:
- name: http
containerPort: 6157
- name: ssh
containerPort: 2222
volumeMounts:
- mountPath: /opengist
name: data
volumes:
- name: data
persistentVolumeClaim:
claimName: opengist-data

View File

@ -1,20 +0,0 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: opengist
labels:
app.kubernetes.io/name: opengist
app.kubernetes.io/component: ingress
spec:
rules:
- host: opengist.local
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: opengist
port:
name: http

View File

@ -1,11 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
metadata:
name: opengist
resources:
- deployment.yaml
- pvc.yaml
- ingress.yaml
- service.yaml

View File

@ -1,15 +0,0 @@
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: opengist-data
labels:
app.kubernetes.io/name: opengist
app.kubernetes.io/component: data
spec:
resources:
requests:
storage: 1Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce

View File

@ -1,14 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: opengist
labels:
app.kubernetes.io/name: opengist
spec:
selector:
app.kubernetes.io/name: opengist
ports:
- port: 80
targetPort: http
name: http

View File

@ -25,6 +25,7 @@ export default defineConfig({
{text: 'Introduction', link: '/docs'},
{text: 'Installation', link: '/docs/installation', items: [
{text: 'Docker', link: '/docs/installation/docker'},
{text: 'Kubernetes', link: '/docs/installation/kubernetes'},
{text: 'Binary', link: '/docs/installation/binary'},
{text: 'Source', link: '/docs/installation/source'},
],
@ -46,6 +47,7 @@ export default defineConfig({
{text: 'Custom assets', link: '/custom-assets'},
{text: 'Custom links', link: '/custom-links'},
{text: 'Cheat Sheet', link: '/cheat-sheet'},
{text: 'Metrics', link: '/metrics'},
{text: 'Admin panel', link: '/admin-panel'},
], collapsed: false
},

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/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.8.3</span>
<span class="pr-1">Released 1.10</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
</svg>

View File

@ -12,13 +12,15 @@ aside: false
| opengist-home | OG_OPENGIST_HOME | home directory | Path to the directory where Opengist stores its data. |
| secret-key | OG_SECRET_KEY | randomized 32 bytes | Secret key used for session store & encrypt MFA data on database. |
| db-uri | OG_DB_URI | `opengist.db` | URI of the database. |
| index.enabled | OG_INDEX_ENABLED | `true` | Enable or disable the code search index (`true` or `false`) |
| index.dirname | OG_INDEX_DIRNAME | `opengist.index` | Name of the directory where the code search index is stored. |
| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. |
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics endpoint at `/metrics` (`true` or `false`) |
| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) |
| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. |
| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. |
@ -34,9 +36,16 @@ aside: false
| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. |
| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. |
| gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. |
| oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider |
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
| ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled |
| ldap.bind-dn | OG_LDAP_BIND_DN | none | Bind DN to authenticate against the LDAP. e.g: cn=read-only-admin,dc=example,dc=com |
| ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. |
| ldap.search-base | OG_LDAP_SEARCH_BASE | none | The Base DN to start search from. e.g: ou=People,dc=example,dc=com |
| ldap.search-filter | OG_LDAP_SEARCH_FILTER | none | The filter to search against (the format string %s will be replaced with the username). e.g: (uid=%s) |
| custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title |
| custom.logo | OG_CUSTOM_LOGO | none | Path to an image, relative to $opengist-home/custom. |
| custom.favicon | OG_CUSTOM_FAVICON | none | Path to an image, relative to $opengist-home/custom. |
| custom.static-links | OG_CUSTOM_STATIC_LINK_#_(PATH,NAME) | none | Path and name to custom links, more info [here](custom-links.md). |

View File

@ -28,4 +28,18 @@ custom.favicon: favicon.png
#### Environment variable
```sh
export OG_CUSTOM_FAVICON=favicon.png
```
```
### Instance Name
It is also possible to set a name for your instance, that would be displayed in the title bar instead of 'Opengist'.
#### YAML
```yaml
custom.name: My Gists
```
#### Environment variable
```sh
export OG_CUSTOM_NAME="My Gists"
```

View File

@ -0,0 +1,49 @@
# Metrics
Opengist offers built-in support for Prometheus metrics to help you monitor the performance and usage of your instance. These metrics provide insights into application health, user activity, and database statistics.
## Enabling metrics
By default, the metrics endpoint is disabled for security and performance reasons. To enable it, update your configuration as stated in the [configuration cheat sheet](cheat-sheet.md):
```yaml
metrics.enabled = true
```
Alternatively, you can use the environment variable:
```bash
OG_METRICS_ENABLED=true
```
Once enabled, metrics are available at the /metrics endpoint.
## Available metrics
### Opengist-specific metrics
| Metric Name | Type | Description |
|-------------|------|-------------|
| `opengist_users_total` | Gauge | Total number of registered users |
| `opengist_gists_total` | Gauge | Total number of gists in the system |
| `opengist_ssh_keys_total` | Gauge | Total number of SSH keys added by users |
### Standard HTTP metrics
In addition to the Opengist-specific metrics, standard Prometheus HTTP metrics are also available through the Echo Prometheus middleware. These include request durations, request counts, and request/response sizes.
These standard metrics follow the Prometheus naming convention and include labels for HTTP method, status code, and handler path.
## Security Considerations
The metrics endpoint exposes information about your Opengist instance that might be sensitive in some environments. Consider using a reverse proxy with authentication for the `/metrics` endpoint if your Opengist instance is publicly accessible.
Example with Nginx:
```shell
location /metrics {
auth_basic "Metrics";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://localhost:6157/metrics;
}
```

View File

@ -63,15 +63,32 @@ Opengist can be configured to use OAuth to authenticate users, with GitHub, Gite
* Set 'Redirect URI' to `http://opengist.url/oauth/openid-connect/callback`
* Copy the 'Client ID', 'Client Secret', and the discovery endpoint, and add them to the [configuration](cheat-sheet.md) :
```yaml
oidc.provider-name: <provider-name>
oidc.client-key: <key>
oidc.secret: <secret>
# Discovery endpoint of the OpenID provider. Generally something like http://auth.example.com/.well-known/openid-configuration
oidc.discovery-url: http://auth.example.com/.well-known/openid-configuration
```
```shell
OG_OIDC_PROVIDER_NAME=<provider-name>
OG_OIDC_CLIENT_KEY=<key>
OG_OIDC_SECRET=<secret>
# Discovery endpoint of the OpenID provider. Generally something like http://auth.example.com/.well-known/openid-configuration
OG_OIDC_DISCOVERY_URL=http://auth.example.com/.well-known/openid-configuration
```
### OIDC Admin Group
OpenGist supports automatic admin privilege assignment based on OIDC group claims. To configure this feature:
```yaml
oidc.group-claim-name: groups # Name of the claim containing the groups
oidc.admin-group: admin-group-name # Name of the group that should receive admin rights
```
```shell
OG_OIDC_GROUP_CLAIM_NAME=groups
OG_OIDC_ADMIN_GROUP=admin-group-name
```
The `group-claim-name` must match the name of the claim in your JWT token that contains the groups.
Users who are members of the configured `admin-group` will automatically receive admin privileges in OpenGist. These privileges are synchronized on every login.

View File

@ -4,3 +4,4 @@ The following is a list of resources made by happy users of Opengist. Feel free
- [Aetherinox/opengist-debian](https://github.com/Aetherinox/opengist-debian) - A Debian package for Opengist
- [How to Install Opengist on Your Synology NAS](https://mariushosting.com/how-to-install-opengist-on-your-synology-nas/) - A guide to install Opengist on a Synology NAS
- [Proxmox VE Helper-Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=opengist) - A script to install Opengist on Proxmox VE

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

View File

@ -0,0 +1,15 @@
# Install on Kubernetes
A [Helm](https://helm.sh) chart is available to install Opengist on a Kubernetes cluster.
Check the [Helm documentation](https://helm.sh/docs/) for more information on how to use Helm.
A non-customized installation of Opengist can be done with:
```bash
helm repo add opengist https://helm.opengist.io
helm install opengist opengist/opengist
```
Refer to the [Opengist chart](https://github.com/thomiceli/opengist/tree/master/helm/opengist) for more information
about the chart and to customize your installation.

View File

@ -10,7 +10,7 @@ Requirements:
git clone https://github.com/thomiceli/opengist
cd opengist
git checkout v1.8.3 # optional, to checkout the latest release
git checkout v1.10.0 # optional, to checkout the latest release
make
./opengist

View File

@ -15,6 +15,7 @@ Written in [Go](https://go.dev), Opengist aims to be fast and easy to deploy.
* [Init](usage/init-via-git.md) / Clone / Pull / Push snippets **via Git** over HTTP or SSH
* Syntax highlighting ; markdown & CSV support
* Search code in snippets ; browse users snippets, likes and forks
* Add topics to snippets
* Embed snippets in other websites
* Revisions history
* Like / Fork snippets

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

View File

@ -17,6 +17,12 @@ git push -o title=Gist123
git push -o title="My Gist 123"
```
## Change description
```shell
git push -o description="This is my gist description"
```
## Change visibility
```shell

121
go.mod
View File

@ -1,97 +1,112 @@
module github.com/thomiceli/opengist
go 1.23
go 1.23.0
require (
github.com/Kunde21/markdownfmt/v3 v3.1.0
github.com/alecthomas/chroma/v2 v2.14.0
github.com/blevesearch/bleve/v2 v2.4.3
github.com/alecthomas/chroma/v2 v2.16.0
github.com/blevesearch/bleve/v2 v2.5.0
github.com/dustin/go-humanize v1.0.1
github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.23.0
github.com/go-webauthn/webauthn v0.11.2
github.com/go-ldap/ldap/v3 v3.4.8
github.com/go-playground/validator/v10 v10.26.0
github.com/go-webauthn/webauthn v0.12.3
github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.4.1
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/hashicorp/go-memdb v1.3.4
github.com/labstack/echo/v4 v4.12.0
github.com/markbates/goth v1.80.0
github.com/labstack/echo-contrib v0.17.3
github.com/labstack/echo/v4 v4.13.3
github.com/markbates/goth v1.81.0
github.com/meilisearch/meilisearch-go v0.31.0
github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.33.0
github.com/prometheus/client_golang v1.21.1
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.5
github.com/urfave/cli/v2 v2.27.6
github.com/yuin/goldmark v1.7.8
github.com/yuin/goldmark-emoji v1.0.4
github.com/yuin/goldmark-emoji v1.0.5
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/mermaid v0.5.0
golang.org/x/crypto v0.29.0
golang.org/x/text v0.20.0
golang.org/x/crypto v0.36.0
golang.org/x/text v0.23.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.10
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/RoaringBitmap/roaring v1.9.4 // indirect
github.com/bits-and-blooms/bitset v1.17.0 // indirect
github.com/blevesearch/bleve_index_api v1.1.13 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blevesearch/bleve_index_api v1.2.7 // indirect
github.com/blevesearch/geo v0.1.20 // indirect
github.com/blevesearch/go-faiss v1.0.23 // indirect
github.com/blevesearch/go-faiss v1.0.25 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.2.16 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.0.11 // indirect
github.com/blevesearch/zapx/v11 v11.3.10 // indirect
github.com/blevesearch/zapx/v12 v12.3.10 // indirect
github.com/blevesearch/zapx/v13 v13.3.10 // indirect
github.com/blevesearch/zapx/v14 v14.3.10 // indirect
github.com/blevesearch/zapx/v15 v15.3.16 // indirect
github.com/blevesearch/zapx/v16 v16.1.8 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.1 // indirect
github.com/blevesearch/zapx/v12 v12.4.1 // indirect
github.com/blevesearch/zapx/v13 v13.4.1 // indirect
github.com/blevesearch/zapx/v14 v14.4.1 // indirect
github.com/blevesearch/zapx/v15 v15.4.1 // indirect
github.com/blevesearch/zapx/v16 v16.2.2 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-chi/chi/v5 v5.2.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-webauthn/x v0.1.15 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/go-webauthn/x v0.1.20 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-tpm v0.9.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect
github.com/jackc/pgx/v5 v5.7.4 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@ -99,16 +114,16 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/protobuf v1.35.2 // indirect
modernc.org/libc v1.61.2 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.34.1 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
modernc.org/sqlite v1.37.0 // indirect
)

370
go.sum
View File

@ -1,59 +1,69 @@
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 v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ=
github.com/RoaringBitmap/roaring v1.9.4/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA=
github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.17.0 h1:1X2TS7aHz1ELcC0yU1y2stUs/0ig5oMU6STFZGrhvHI=
github.com/bits-and-blooms/bitset v1.17.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.4.3 h1:XDYj+1prgX84L2Cf+V3ojrOPqXxy0qxyd2uLMmeuD+4=
github.com/blevesearch/bleve/v2 v2.4.3/go.mod h1:hEPDPrbYw3vyrm5VOa36GyS4bHWuIf4Fflp7460QQXY=
github.com/blevesearch/bleve_index_api v1.1.13 h1:+nrA6oRJr85aCPyqaeZtsruObwKojutfonHJin/BP48=
github.com/blevesearch/bleve_index_api v1.1.13/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.5.0 h1:HzYqBy/5/M9Ul9ESEmXzN/3Jl7YpmWBdHM/+zzv/3k4=
github.com/blevesearch/bleve/v2 v2.5.0/go.mod h1:PcJzTPnEynO15dCf9isxOga7YFRa/cMSsbnRwnszXUk=
github.com/blevesearch/bleve_index_api v1.2.7 h1:c8r9vmbaYQroAMSGag7zq5gEVPiuXrUQDqfnj7uYZSY=
github.com/blevesearch/bleve_index_api v1.2.7/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
github.com/blevesearch/go-faiss v1.0.23 h1:Wmc5AFwDLKGl2L6mjLX1Da3vCL0EKa2uHHSorcIS1Uc=
github.com/blevesearch/go-faiss v1.0.23/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U=
github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.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.2.16 h1:uGvKVvG7zvSxCwcm4/ehBa9cCEuZVE+/zvrSl57QUVY=
github.com/blevesearch/scorch_segment_api/v2 v2.2.16/go.mod h1:VF5oHVbIFTu+znY1v30GjSpT5+9YFs9dV2hjvuh34F0=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9 h1:X6nJXnNHl7nasXW+U6y2Ns2Aw8F9STszkYkyBfQ+p0o=
github.com/blevesearch/scorch_segment_api/v2 v2.3.9/go.mod h1:IrzspZlVjhf4X29oJiEhBxEteTqOY9RlYlk1lCmYHr4=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.0.11 h1:SJI97toEFTtA9WsDZxkyGTaBWFdWl1n2LEDCXLCq/AU=
github.com/blevesearch/vellum v1.0.11/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zapx/v11 v11.3.10 h1:hvjgj9tZ9DeIqBCxKhi70TtSZYMdcFn7gDb71Xo/fvk=
github.com/blevesearch/zapx/v11 v11.3.10/go.mod h1:0+gW+FaE48fNxoVtMY5ugtNHHof/PxCqh7CnhYdnMzQ=
github.com/blevesearch/zapx/v12 v12.3.10 h1:yHfj3vXLSYmmsBleJFROXuO08mS3L1qDCdDK81jDl8s=
github.com/blevesearch/zapx/v12 v12.3.10/go.mod h1:0yeZg6JhaGxITlsS5co73aqPtM04+ycnI6D1v0mhbCs=
github.com/blevesearch/zapx/v13 v13.3.10 h1:0KY9tuxg06rXxOZHg3DwPJBjniSlqEgVpxIqMGahDE8=
github.com/blevesearch/zapx/v13 v13.3.10/go.mod h1:w2wjSDQ/WBVeEIvP0fvMJZAzDwqwIEzVPnCPrz93yAk=
github.com/blevesearch/zapx/v14 v14.3.10 h1:SG6xlsL+W6YjhX5N3aEiL/2tcWh3DO75Bnz77pSwwKU=
github.com/blevesearch/zapx/v14 v14.3.10/go.mod h1:qqyuR0u230jN1yMmE4FIAuCxmahRQEOehF78m6oTgns=
github.com/blevesearch/zapx/v15 v15.3.16 h1:Ct3rv7FUJPfPk99TI/OofdC+Kpb4IdyfdMH48sb+FmE=
github.com/blevesearch/zapx/v15 v15.3.16/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg=
github.com/blevesearch/zapx/v16 v16.1.8 h1:Bxzpw6YQpFs7UjoCV1+RvDw6fmAT2GZxldwX8b3wVBM=
github.com/blevesearch/zapx/v16 v16.1.8/go.mod h1:JqQlOqlRVaYDkpLIl3JnKql8u4zKTNlVEa3nLsi0Gn8=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zapx/v11 v11.4.1 h1:qFCPlFbsEdwbbckJkysptSQOsHn4s6ZOHL5GMAIAVHA=
github.com/blevesearch/zapx/v11 v11.4.1/go.mod h1:qNOGxIqdPC1MXauJCD9HBG487PxviTUUbmChFOAosGs=
github.com/blevesearch/zapx/v12 v12.4.1 h1:K77bhypII60a4v8mwvav7r4IxWA8qxhNjgF9xGdb9eQ=
github.com/blevesearch/zapx/v12 v12.4.1/go.mod h1:QRPrlPOzAxBNMI0MkgdD+xsTqx65zbuPr3Ko4Re49II=
github.com/blevesearch/zapx/v13 v13.4.1 h1:EnkEMZFUK0lsW/jOJJF2xOcp+W8TjEsyeN5BeAZEYYE=
github.com/blevesearch/zapx/v13 v13.4.1/go.mod h1:e6duBMlCvgbH9rkzNMnUa9hRI9F7ri2BRcHfphcmGn8=
github.com/blevesearch/zapx/v14 v14.4.1 h1:G47kGCshknBZzZAtjcnIAMn3oNx8XBLxp8DMq18ogyE=
github.com/blevesearch/zapx/v14 v14.4.1/go.mod h1:O7sDxiaL2r2PnCXbhh1Bvm7b4sP+jp4unE9DDPWGoms=
github.com/blevesearch/zapx/v15 v15.4.1 h1:B5IoTMUCEzFdc9FSQbhVOxAY+BO17c05866fNruiI7g=
github.com/blevesearch/zapx/v15 v15.4.1/go.mod h1:b/MreHjYeQoLjyY2+UaM0hGZZUajEbE0xhnr1A2/Q6Y=
github.com/blevesearch/zapx/v16 v16.2.2 h1:MifKJVRTEhMTgSlle2bDRTb39BGc9jXFRLPZc6r0Rzk=
github.com/blevesearch/zapx/v16 v16.2.2/go.mod h1:B9Pk4G1CqtErgQV9DyCSA9Lb7WZe4olYfGw7fVDZ4sk=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 h1:wMSvdj3BswqfQOXp2R1bJOAE7xIQLt2dlMQDMf836VY=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
@ -61,41 +71,47 @@ github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5D
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/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.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.15 h1:eG1OhggBJTkDE8gUeOlGRbRe8E/PSVG26YG4AyFbwkU=
github.com/go-webauthn/x v0.1.15/go.mod h1:pf7VI23raFLHPO9VVIs9/u1etqwAOP0S2KoHGL6WbZ8=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY=
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw=
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
@ -103,54 +119,63 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I=
github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3 h1:8COTSTFIIXnaD81+kfCw4dRANNAKuCp06EdYLqwX30g=
github.com/golang/geo v0.0.0-20250404181303-07d601f131f3/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/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=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c=
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@ -159,28 +184,37 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo-contrib v0.17.3 h1:hj+qXksKZG1scSe9ksUXMtv7fZYN+PtQT+bPcYA3/TY=
github.com/labstack/echo-contrib v0.17.3/go.mod h1:TcRBrzW8jcC4JD+5Dc/pvOyAps0rtgzj7oBqoR3nYsc=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/markbates/goth v1.80.0 h1:NnvatczZDzOs1hn9Ug+dVYf2Viwwkp/ZDX5K+GLjan8=
github.com/markbates/goth v1.80.0/go.mod h1:4/GYHo+W6NWisrMPZnq0Yr2Q70UntNLn7KXEFhrIdAY=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE=
github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/meilisearch/meilisearch-go v0.31.0 h1:yZRhY1qJqdH8h6GFZALGtkDLyj8f9v5aJpsNMyrUmnY=
github.com/meilisearch/meilisearch-go v0.31.0/go.mod h1:aNtyuwurDg/ggxQIcKqWH6G9g2ptc8GyY7PLY4zMn/g=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -190,6 +224,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -197,6 +233,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@ -204,18 +248,25 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
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=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@ -224,80 +275,129 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.4 h1:vCwMkPZSNefSUnOW2ZKRUjBSD5Ok3W78IXhGxxAEF90=
github.com/yuin/goldmark-emoji v1.0.4/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.abhg.dev/goldmark/mermaid v0.5.0 h1:mDkykpSPJ+5wCQ8bSXgzJ2KQskjXkI5Ndxz7JYDHW38=
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs=
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.10 h1:7Lggqempgy496c0WfHXsYWxk3Th+ZcW66/21QhVFdeE=
gorm.io/driver/postgres v1.5.10/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8=
modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.22.3 h1:C7AW89Zw3kygesTQWBzApwIn9ldM+cb/plrTIKq41Os=
modernc.org/ccgo/v4 v4.22.3/go.mod h1:Dz7n0/UkBbH3pnYaxgi1mFSfF4REqUOZNziphZASx6k=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.61.2 h1:dkO4DlowfClcJYsvf/RiK6fUwvzCQTmB34bJLt0CAGQ=
modernc.org/libc v1.61.2/go.mod h1:4QGjNyX3h+rn7V5oHpJY2yH0QN6frt1X+5BkXzwLPCo=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/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=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

23
helm/opengist/.helmignore Normal file
View File

@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

9
helm/opengist/Chart.lock Normal file
View File

@ -0,0 +1,9 @@
dependencies:
- name: postgresql
repository: oci://registry-1.docker.io/bitnamicharts
version: 16.5.6
- name: meilisearch
repository: https://meilisearch.github.io/meilisearch-kubernetes
version: 0.12.0
digest: sha256:31084e570aa16e3a26317aeb6d0d5dec62540c314ee4f703374e6e7827399fa6
generated: "2025-03-27T11:34:51.840778733+01:00"

19
helm/opengist/Chart.yaml Normal file
View File

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

81
helm/opengist/README.md Normal file
View File

@ -0,0 +1,81 @@
# Opengist Helm Chart
![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![AppVersion: 1.10.0](https://img.shields.io/badge/AppVersion-1.10.0-informational?style=flat-square)
Opengist Helm chart for Kubernetes.
* [Install](#install)
* [Configuration](#configuration)
* [Dependencies](#dependencies)
* [Meilisearch Indexer](#meilisearch-indexer)
* [PostgreSQL Database](#postgresql-database)
## Install
```bash
helm repo add opengist https://helm.opengist.io
helm install opengist opengist/opengist
```
## Configuration
This part explains how to configure the Opengist instance using the Helm chart. The `config.yml` file used by Opengist
is mounted from a Kubernetes Secret with a key `config.yml` and the values formatted as YAML.
### Using values
Using Helm values, you can define the values from a key name `config`
```yaml
config:
log-level: "warn"
log-output: "stdout"
```
This will create a Kubernetes secret named `opengist` mounted to the pod as a file with the YAML content of the secret,
used by Opengist.
### Using an existing secret
If you wish to not store sensitive data in your Helm values, you can create a Kubernetes secret with a key `config.yml`
and values formatted as YAML. You can then reference this secret in the Helm chart with the `configExistingSecret` key.
If defined, this existing secret will be used instead of creating a new one.
```yaml
configExistingSecret: <name of the secret>
```
## Dependencies
### Meilisearch Indexer
By default, Opengist uses the `bleve` indexer. **It is NOT available** if there is multiple replicas of the opengist pod (only one pod can open the index at the same time).
Instead, for multiple replicas setups, you **MUST** use the `meilisearch` indexer.
By setting `meilisearch.enabled: true`, the [Meilisearch chart](https://github.com/meilisearch/meilisearch-kubernetes) will be deployed aswell.
You must define the `meilisearch.host` (Kubernetes Service) and `meilisearch.key` (value created by Meilisearch) values to connect to the Meilisearch instance in your Opengist config :
```yaml
index: meilisearch
index.meili.host: http://opengist-meilisearch:7700 # pointing to the K8S Service
index.meili.api-key: MASTER_KEY # generated by Meilisearch
```
If you want to use the `bleve` indexer, you need to set the `replicas` to `1`.
### PostgreSQL Database
By default, Opengist uses the `sqlite` database. If needed, this chart also deploys a PostgreSQL instance.
By setting `postgresql.enabled: true`, the [Bitnami PostgreSQL chart](https://github.com/bitnami/charts/tree/main/bitnami/postgresql) will be deployed aswell.
You must define the `postgresql.host`, `postgresql.port`, `postgresql.database`, `postgresql.username` and `postgresql.password` values to connect to the PostgreSQL instance.
Then define the connection string in your Opengist config:
```yaml
db-uri: postgres://user:password@opengist-postgresql:5432/opengist
```
Note: `opengist-postgresql` is the name of the K8S Service deployed by this chart.

View File

@ -0,0 +1,22 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.http.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "opengist.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.http.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "opengist.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "opengist.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.http.port }}
{{- else if contains "ClusterIP" .Values.service.http.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "opengist.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@ -0,0 +1,85 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "opengist.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "opengist.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "opengist.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "opengist.labels" -}}
helm.sh/chart: {{ include "opengist.chart" . }}
app: {{ include "opengist.name" . }}
{{ include "opengist.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "opengist.selectorLabels" -}}
app.kubernetes.io/name: {{ include "opengist.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "opengist.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "opengist.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Create image URI
*/}}
{{- define "opengist.image" -}}
{{- if .Values.image.digest -}}
{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}}
{{- else -}}
{{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) -}}
{{- end -}}
{{- end -}}
{{/*
Create secret name
*/}}
{{- define "opengist.secretName" -}}
{{- if .Values.configExistingSecret -}}
{{- printf "%s" (tpl .Values.configExistingSecret $) -}}
{{- else -}}
{{- printf "%s" (include "opengist.fullname" .) -}}
{{- end -}}
{{- end -}}

View File

@ -0,0 +1,122 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "opengist.fullname" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- if .Values.deployment.labels }}
{{- toYaml .Values.deployment.labels | nindent 4 }}
{{- end }}
{{- with .Values.deployment.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "opengist.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- if .Values.deployment.terminationGracePeriodSeconds }}
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "opengist.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
initContainers:
- name: init-config
image: busybox:1.37
imagePullPolicy: IfNotPresent
command: ['sh', '-c', 'cp /init/config/config.yml /config-volume/config.yml']
volumeMounts:
- name: config-secret
mountPath: /init/config
- name: config-volume
mountPath: /config-volume
{{- if .Values.deployment.env }}
env:
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.http.port }}
protocol: TCP
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}
httpGet:
port: http
path: /healthcheck
{{- end }}
{{- if .Values.readinessProbe.enabled }}
readinessProbe:
{{- toYaml (omit .Values.readinessProbe "enabled") | nindent 12 }}
httpGet:
port: http
path: /healthcheck
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: config-volume
mountPath: /config.yml
subPath: config.yml
- name: opengist-data
mountPath: /opengist
{{- if gt (len .Values.extraVolumeMounts) 0 }}
{{- toYaml .Values.extraVolumeMounts | nindent 12 }}
{{- end }}
volumes:
- name: opengist-data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "opengist.fullname" . }}-data
{{- else }}
emptyDir: {}
{{- end }}
- name: config-secret
secret:
secretName: {{ include "opengist.secretName" . }}
defaultMode: 511
- name: config-volume
emptyDir: {}
{{- if gt (len .Values.extraVolumes) 0 }}
{{- toYaml .Values.extraVolumes | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@ -0,0 +1,37 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "opengist.fullname" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- with .Values.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "opengist.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,47 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "opengist.fullname" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- if .Values.ingress.labels }}
{{- toYaml .Values.service.http.labels | nindent 4 }}
{{- end }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- with .pathType }}
pathType: {{ . }}
{{- end }}
backend:
service:
name: {{ include "opengist.fullname" $ }}-http
port:
number: {{ $.Values.service.http.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,14 @@
{{- if .Values.podDisruptionBudget -}}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "opengist.fullname" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
spec:
selector:
matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }}
{{- toYaml .Values.podDisruptionBudget | nindent 2 }}
{{- end -}}

View File

@ -0,0 +1,28 @@
{{- if .Values.persistence.enabled }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ include "opengist.fullname" . }}-data
namespace: {{ .Release.Namespace }}
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- if .Values.persistence.labels }}
{{- toYaml .Values.persistence.labels | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- if gt .Values.replicaCount 1.0 }}
- ReadWriteMany
{{- else }}
{{- .Values.persistence.accessModes | toYaml | nindent 4 }}
{{- end }}
volumeMode: Filesystem
storageClassName: {{ .Values.persistence.storageClass | quote }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}

View File

@ -0,0 +1,13 @@
{{- if (not .Values.configExistingSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "opengist.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{ include "opengist.labels" . | indent 4 }}
type: Opaque
stringData:
config.yml: |-
{{- .Values.config | toYaml | nindent 4 }}
{{- end }}

View File

@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "opengist.serviceAccountName" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,47 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "opengist.fullname" . }}-http
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- if .Values.service.http.labels }}
{{- toYaml .Values.service.http.labels | nindent 4 }}
{{- end }}
{{- with .Values.service.http.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.http.type }}
{{- if eq .Values.service.http.type "LoadBalancer" }}
{{- if and .Values.service.http.loadBalancerIP }}
loadBalancerIP: {{ .Values.service.http.loadBalancerIP }}
{{- end }}
{{- if .Values.service.http.loadBalancerSourceRanges }}
loadBalancerSourceRanges:
{{- range .Values.service.http.loadBalancerSourceRanges }}
- {{ . }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.service.http.externalIPs }}
externalIPs:
{{- toYaml .Values.service.http.externalIPs | nindent 4 }}
{{- end }}
{{- if .Values.service.http.externalTrafficPolicy }}
externalTrafficPolicy: {{ .Values.service.http.externalTrafficPolicy }}
{{- end }}
{{- if and .Values.service.http.clusterIP (eq .Values.service.http.type "ClusterIP") }}
clusterIP: {{ .Values.service.http.clusterIP }}
{{- end }}
ports:
- name: http
port: {{ .Values.service.http.port }}
{{- if .Values.service.http.nodePort }}
nodePort: {{ .Values.service.http.nodePort }}
{{- end }}
targetPort: {{ index .Values.config "http.port" }}
selector:
{{- include "opengist.selectorLabels" . | nindent 4 }}

View File

@ -0,0 +1,64 @@
{{- if .Values.service.ssh.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "opengist.fullname" . }}-ssh
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- if .Values.service.ssh.labels }}
{{- toYaml .Values.service.ssh.labels | nindent 4 }}
{{- end }}
{{- with .Values.service.http.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.ssh.type }}
{{- if eq .Values.service.ssh.type "LoadBalancer" }}
{{- if .Values.service.ssh.loadBalancerClass }}
loadBalancerClass: {{ .Values.service.ssh.loadBalancerClass }}
{{- end }}
{{- if .Values.service.ssh.loadBalancerIP }}
loadBalancerIP: {{ .Values.service.ssh.loadBalancerIP }}
{{- end -}}
{{- if .Values.service.ssh.loadBalancerSourceRanges }}
loadBalancerSourceRanges:
{{- range .Values.service.ssh.loadBalancerSourceRanges }}
- {{ . }}
{{- end }}
{{- end }}
{{- end }}
{{- if and .Values.service.ssh.clusterIP (eq .Values.service.ssh.type "ClusterIP") }}
clusterIP: {{ .Values.service.ssh.clusterIP }}
{{- end }}
{{- if .Values.service.ssh.externalIPs }}
externalIPs:
{{- toYaml .Values.service.ssh.externalIPs | nindent 4 }}
{{- end }}
{{- if .Values.service.ssh.ipFamilyPolicy }}
ipFamilyPolicy: {{ .Values.service.ssh.ipFamilyPolicy }}
{{- end }}
{{- with .Values.service.ssh.ipFamilies }}
ipFamilies:
{{- toYaml . | nindent 4 }}
{{- end -}}
{{- if .Values.service.ssh.externalTrafficPolicy }}
externalTrafficPolicy: {{ .Values.service.ssh.externalTrafficPolicy }}
{{- end }}
ports:
- name: ssh
port: {{ .Values.service.ssh.port }}
{{- if .Values.service.ssh.nodePort }}
nodePort: {{ .Values.service.ssh.nodePort }}
{{- end }}
{{- if index .Values.config "ssh.port" }}
targetPort: {{ index .Values.config "ssh.port" }}
{{- else }}
targetPort: 2222
{{- end }}
protocol: TCP
selector:
{{- include "opengist.selectorLabels" . | nindent 4 }}
{{- end }}

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "opengist.fullname" . }}-test-connection"
labels:
{{- include "opengist.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "opengist.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

201
helm/opengist/values.yaml Normal file
View File

@ -0,0 +1,201 @@
## Kubernetes workload configuration for Opengist
nameOverride: ""
fullnameOverride: ""
namespace: ""
## Opengist YAML Application Config. See more at https://opengist.io/docs/configuration/cheat-sheet.html
## This will create a Kubernetes secret with the key `config.yml` containing the YAML configuration mounted in the pod.
config:
log-level: "warn"
log-output: "stdout"
## 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.
configExistingSecret: ""
## Define the image repository and tag to use.
image:
repository: ghcr.io/thomiceli/opengist
pullPolicy: Always
tag: "1.10.0"
digest: ""
imagePullSecrets: []
# - name: "image-pull-secret"
## Define the deployment replica count
replicaCount: 1
## Define the deployment strategy type
strategy:
type: "RollingUpdate"
rollingUpdate:
maxSurge: "100%"
maxUnavailable: 0
## Security Context settings
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
podSecurityContext:
fsGroup: 1000
securityContext: {}
# allowPrivilegeEscalation: false
## Pod Disruption Budget settings
## ref: https://kubernetes.io/docs/tasks/run-application/configure-pdb/
podDisruptionBudget: {}
# maxUnavailable: 1
# minAvailable: 1
## Set the Kubernetes service type
## ref: https://kubernetes.io/docs/concepts/services-networking/service/
service:
http:
type: ClusterIP
clusterIP:
port: 6157
nodePort:
loadBalancerIP:
externalIPs: []
labels: {}
annotations: {}
loadBalancerSourceRanges: []
externalTrafficPolicy:
ssh:
enabled: true
type: ClusterIP
clusterIP:
port: 2222
nodePort:
loadBalancerIP:
externalIPs: []
labels: {}
annotations: {}
loadBalancerSourceRanges: []
externalTrafficPolicy:
## HTTP Ingress for Opengist
## ref: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress:
enabled: false
className: ""
labels: {}
# node-role.kubernetes.io/ingress: platform
annotations: {}
# kubernetes.io/ingress.class: nginx
hosts:
- host: opengist.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: opengist-tls
# hosts:
# - opengist.example.com
## Service Account for Opengist pods
## ref: https://kubernetes.io/docs/concepts/security/service-accounts/
serviceAccount:
create: true
annotations: {}
name: ""
## Set persistence using a Persistent Volume Claim
## If more than 2 replicas are set, the access mode must be ReadWriteMany
## ref: https://kubernetes.io/docs/concepts/storage/persistent-volumes/
persistence:
enabled: true
existingClaim: ""
storageClass: ""
labels: {}
annotations:
helm.sh/resource-policy: keep
size: 5Gi
accessModes:
- ReadWriteOnce
subPath: ""
extraVolumes: []
extraVolumeMounts: []
## Additional pod labels and annotations
podLabels: {}
podAnnotations: {}
## Configure resource requests and limits
## ref: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
## Configure the liveness and readiness probes
## ref: https://kubernetes.io/docs/concepts/configuration/liveness-readiness-startup-probes/
livenessProbe:
enabled: true
initialDelaySeconds: 200
timeoutSeconds: 1
periodSeconds: 10
successThreshold: 1
failureThreshold: 5
readinessProbe:
enabled: true
initialDelaySeconds: 5
timeoutSeconds: 1
periodSeconds: 10
successThreshold: 1
failureThreshold: 3
## Define autoscaling configuration using Horizontal Pod Autoscaler
## ref: https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
annotations: {}
## Additional deployment configuration
deployment:
env: []
terminationGracePeriodSeconds: 60
labels: {}
annotations: {}
## Set pod assignment with node labels
## ref: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/
nodeSelector: {}
tolerations: []
affinity: {}
## Use PostgreSQL as a database, using Bitnami's PostgreSQL Helm chart
## ref: https://artifacthub.io/packages/helm/bitnami/postgresql/16.5.6
postgresql:
enabled: false
global:
postgresql:
auth:
username: opengist
password: opengist
database: opengist
service:
ports:
postgresql: 5432
primary:
persistence:
size: 10Gi
## Use Meilisearch as a code indexer, using Meilisearch's Helm chart
## ref: https://github.com/meilisearch/meilisearch-kubernetes/tree/meilisearch-0.12.0
meilisearch:
enabled: false
environment:
MEILI_ENV: "production"
auth:
existingMasterKeySecret:

View File

@ -23,6 +23,7 @@ const (
SyncGistPreviews
ResetHooks
IndexGists
SyncGistLanguages
)
var (
@ -73,6 +74,8 @@ func Run(actionType int) {
functionToRun = resetHooks
case IndexGists:
functionToRun = indexGists
case SyncGistLanguages:
functionToRun = syncGistLanguages
default:
log.Error().Msg("Unknown action type")
}
@ -141,17 +144,8 @@ func syncGistPreviews() {
func resetHooks() {
log.Info().Msg("Resetting Git server hooks for all repositories...")
entries, err := filepath.Glob(filepath.Join(config.GetHomeDir(), "repos", "*", "*"))
if err != nil {
log.Error().Err(err).Msg("Cannot read repos directories")
return
}
for _, e := range entries {
path := strings.Split(e, string(os.PathSeparator))
if err := git.CreateDotGitFiles(path[len(path)-2], path[len(path)-1]); err != nil {
log.Error().Err(err).Msgf("Cannot reset hooks for repository %s/%s", path[len(path)-2], path[len(path)-1])
}
if err := git.ResetHooks(); err != nil {
log.Error().Err(err).Msg("Error resetting hooks for repositories")
}
}
@ -175,3 +169,17 @@ func indexGists() {
}
}
}
func syncGistLanguages() {
log.Info().Msg("Syncing all Gist languages...")
gists, err := db.GetAllGistsRows()
if err != nil {
log.Error().Err(err).Msg("Cannot get gists")
return
}
for _, gist := range gists {
log.Info().Msgf("Syncing languages for gist %d", gist.ID)
gist.UpdateLanguages()
}
}

View File

@ -1,4 +1,4 @@
package utils
package auth
import (
"crypto/aes"

View File

@ -1,4 +1,4 @@
package utils
package auth
import (
"crypto/rand"
@ -10,7 +10,7 @@ import (
"strings"
)
type Argon2ID struct {
type argon2ID struct {
format string
version int
time uint32
@ -20,7 +20,7 @@ type Argon2ID struct {
threads uint8
}
var Argon2id = Argon2ID{
var Argon2id = argon2ID{
format: "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
version: argon2.Version,
time: 1,
@ -30,7 +30,7 @@ var Argon2id = Argon2ID{
threads: 4,
}
func (a Argon2ID) Hash(plain string) (string, error) {
func (a argon2ID) Hash(plain string) (string, error) {
salt := make([]byte, a.saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
@ -44,7 +44,7 @@ func (a Argon2ID) Hash(plain string) (string, error) {
), nil
}
func (a Argon2ID) Verify(plain, hash string) (bool, error) {
func (a argon2ID) Verify(plain, hash string) (bool, error) {
if hash == "" {
return false, nil
}

View File

@ -0,0 +1,64 @@
package ldap
import (
"fmt"
"github.com/go-ldap/ldap/v3"
"github.com/thomiceli/opengist/internal/config"
)
func Enabled() bool {
return config.C.LDAPUrl != ""
}
// Authenticate attempts to authenticate a user against the configured LDAP instance.
func Authenticate(username, password string) (bool, error) {
l, err := ldap.DialURL(config.C.LDAPUrl)
if err != nil {
return false, fmt.Errorf("unable to connect to URI: %v", config.C.LDAPUrl)
}
defer func(l *ldap.Conn) {
_ = l.Close()
}(l)
// First bind with a read only user
err = l.Bind(config.C.LDAPBindDn, config.C.LDAPBindCredentials)
if err != nil {
return false, err
}
searchFilter := fmt.Sprintf(config.C.LDAPSearchFilter, username)
searchRequest := ldap.NewSearchRequest(
config.C.LDAPSearchBase,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0,
0,
false,
searchFilter,
[]string{"dn"},
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
return false, err
}
if len(sr.Entries) != 1 {
return false, nil
}
// Bind as the user to verify their password
err = l.Bind(sr.Entries[0].DN, password)
if err != nil {
return false, nil
}
// Rebind as the read only user for any further queries
err = l.Bind(config.C.LDAPBindDn, config.C.LDAPBindCredentials)
if err != nil {
return false, err
}
return true, nil
}

View File

@ -0,0 +1,117 @@
package oauth
import (
gocontext "context"
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 {
Provider
URL string
}
func (p *GiteaProvider) RegisterProvider() error {
goth.UseProviders(
gitea.NewCustomisedURL(
config.C.GiteaClientKey,
config.C.GiteaSecret,
urlJoin(p.URL, "/oauth/gitea/callback"),
urlJoin(config.C.GiteaUrl, "/login/oauth/authorize"),
urlJoin(config.C.GiteaUrl, "/login/oauth/access_token"),
urlJoin(config.C.GiteaUrl, "/api/v1/user"),
),
)
return nil
}
func (p *GiteaProvider) BeginAuthHandler(ctx *context.Context) {
ctxValue := gocontext.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, GiteaProviderString)
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
gothic.BeginAuthHandler(ctx.Response(), ctx.Request())
}
func (p *GiteaProvider) UserHasProvider(user *db.User) bool {
return user.GiteaID != ""
}
func NewGiteaProvider(url string) *GiteaProvider {
return &GiteaProvider{
URL: url,
}
}
type GiteaCallbackProvider struct {
CallbackProvider
User *goth.User
}
func (p *GiteaCallbackProvider) GetProvider() string {
return GiteaProviderString
}
func (p *GiteaCallbackProvider) GetProviderUser() *goth.User {
return p.User
}
func (p *GiteaCallbackProvider) GetProviderUserID(user *db.User) bool {
return user.GiteaID != ""
}
func (p *GiteaCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
resp, err := http.Get(urlJoin(config.C.GiteaUrl, p.User.NickName+".keys"))
if err != nil {
return nil, err
}
defer resp.Body.Close()
return readKeys(resp)
}
func (p *GiteaCallbackProvider) UpdateUserDB(user *db.User) {
user.GiteaID = p.User.UserID
resp, err := http.Get(urlJoin(config.C.GiteaUrl, "/api/v1/users/", p.User.UserID))
if err != nil {
log.Error().Err(err).Msg("Cannot get user from Gitea")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("Cannot read Gitea response body")
return
}
var result map[string]interface{}
err = gojson.Unmarshal(body, &result)
if err != nil {
log.Error().Err(err).Msg("Cannot unmarshal Gitea response body")
return
}
field, ok := result["avatar_url"]
if !ok {
log.Error().Msg("Field 'avatar_url' not found in Gitea JSON response")
return
}
user.AvatarURL = field.(string)
}
func NewGiteaCallbackProvider(user *goth.User) CallbackProvider {
return &GiteaCallbackProvider{
User: user,
}
}

View File

@ -0,0 +1,84 @@
package oauth
import (
gocontext "context"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/github"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"net/http"
)
type GitHubProvider struct {
Provider
URL string
}
func (p *GitHubProvider) RegisterProvider() error {
goth.UseProviders(
github.New(
config.C.GithubClientKey,
config.C.GithubSecret,
urlJoin(p.URL, "/oauth/github/callback"),
),
)
return nil
}
func (p *GitHubProvider) BeginAuthHandler(ctx *context.Context) {
ctxValue := gocontext.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, GitHubProviderString)
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
gothic.BeginAuthHandler(ctx.Response(), ctx.Request())
}
func (p *GitHubProvider) UserHasProvider(user *db.User) bool {
return user.GithubID != ""
}
func NewGitHubProvider(url string) *GitHubProvider {
return &GitHubProvider{
URL: url,
}
}
type GitHubCallbackProvider struct {
CallbackProvider
User *goth.User
}
func (p *GitHubCallbackProvider) GetProvider() string {
return GitHubProviderString
}
func (p *GitHubCallbackProvider) GetProviderUser() *goth.User {
return p.User
}
func (p *GitHubCallbackProvider) GetProviderUserID(user *db.User) bool {
return user.GithubID != ""
}
func (p *GitHubCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
resp, err := http.Get("https://github.com/" + p.User.NickName + ".keys")
if err != nil {
return nil, err
}
defer resp.Body.Close()
return readKeys(resp)
}
func (p *GitHubCallbackProvider) UpdateUserDB(user *db.User) {
user.GithubID = p.User.UserID
user.AvatarURL = "https://avatars.githubusercontent.com/u/" + p.User.UserID + "?v=4"
}
func NewGitHubCallbackProvider(user *goth.User) CallbackProvider {
return &GitHubCallbackProvider{
User: user,
}
}

View File

@ -0,0 +1,118 @@
package oauth
import (
gocontext "context"
gojson "encoding/json"
"io"
"net/http"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/gitlab"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
)
type GitLabProvider struct {
Provider
URL string
}
func (p *GitLabProvider) RegisterProvider() error {
goth.UseProviders(
gitlab.NewCustomisedURL(
config.C.GitlabClientKey,
config.C.GitlabSecret,
urlJoin(p.URL, "/oauth/gitlab/callback"),
urlJoin(config.C.GitlabUrl, "/oauth/authorize"),
urlJoin(config.C.GitlabUrl, "/oauth/token"),
urlJoin(config.C.GitlabUrl, "/api/v4/user"),
),
)
return nil
}
func (p *GitLabProvider) BeginAuthHandler(ctx *context.Context) {
ctxValue := gocontext.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, GitLabProviderString)
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
gothic.BeginAuthHandler(ctx.Response(), ctx.Request())
}
func (p *GitLabProvider) UserHasProvider(user *db.User) bool {
return user.GitlabID != ""
}
func NewGitLabProvider(url string) *GitLabProvider {
return &GitLabProvider{
URL: url,
}
}
type GitLabCallbackProvider struct {
CallbackProvider
User *goth.User
}
func (p *GitLabCallbackProvider) GetProvider() string {
return GitLabProviderString
}
func (p *GitLabCallbackProvider) GetProviderUser() *goth.User {
return p.User
}
func (p *GitLabCallbackProvider) GetProviderUserID(user *db.User) bool {
return user.GitlabID != ""
}
func (p *GitLabCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
resp, err := http.Get(urlJoin(config.C.GitlabUrl, p.User.NickName+".keys"))
if err != nil {
return nil, err
}
defer resp.Body.Close()
return readKeys(resp)
}
func (p *GitLabCallbackProvider) UpdateUserDB(user *db.User) {
user.GitlabID = p.User.UserID
resp, err := http.Get(urlJoin(config.C.GitlabUrl, "/api/v4/avatar?size=400&email=", p.User.Email))
if err != nil {
log.Error().Err(err).Msg("Cannot get user avatar from GitLab")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Err(err).Msg("Cannot read Gitlab response body")
return
}
var result map[string]interface{}
err = gojson.Unmarshal(body, &result)
if err != nil {
log.Error().Err(err).Msg("Cannot unmarshal Gitlab response body")
return
}
field, ok := result["avatar_url"]
if !ok {
log.Error().Msg("Field 'avatar_url' not found in Gitlab JSON response")
return
}
user.AvatarURL = field.(string)
}
func NewGitLabCallbackProvider(user *goth.User) CallbackProvider {
return &GitLabCallbackProvider{
User: user,
}
}

View File

@ -0,0 +1,85 @@
package oauth
import (
gocontext "context"
"errors"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/openidConnect"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
)
type OIDCProvider struct {
Provider
URL string
}
func (p *OIDCProvider) RegisterProvider() error {
oidcProvider, err := openidConnect.New(
config.C.OIDCClientKey,
config.C.OIDCSecret,
urlJoin(p.URL, "/oauth/openid-connect/callback"),
config.C.OIDCDiscoveryUrl,
"openid",
"email",
"profile",
)
if err != nil {
return errors.New("Cannot create OIDC provider: " + err.Error())
}
goth.UseProviders(oidcProvider)
return nil
}
func (p *OIDCProvider) BeginAuthHandler(ctx *context.Context) {
ctxValue := gocontext.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, OpenIDConnectString)
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
gothic.BeginAuthHandler(ctx.Response(), ctx.Request())
}
func (p *OIDCProvider) UserHasProvider(user *db.User) bool {
return user.OIDCID != ""
}
func NewOIDCProvider(url string) *OIDCProvider {
return &OIDCProvider{
URL: url,
}
}
type OIDCCallbackProvider struct {
CallbackProvider
User *goth.User
}
func (p *OIDCCallbackProvider) GetProvider() string {
return OpenIDConnectString
}
func (p *OIDCCallbackProvider) GetProviderUser() *goth.User {
return p.User
}
func (p *OIDCCallbackProvider) GetProviderUserID(user *db.User) bool {
return user.OIDCID != ""
}
func (p *OIDCCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
return nil, nil
}
func (p *OIDCCallbackProvider) UpdateUserDB(user *db.User) {
user.OIDCID = p.User.UserID
user.AvatarURL = p.User.AvatarURL
}
func NewOIDCCallbackProvider(user *goth.User) CallbackProvider {
return &OIDCCallbackProvider{
User: user,
}
}

View File

@ -0,0 +1,93 @@
package oauth
import (
"fmt"
"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 (
GitHubProviderString = "github"
GitLabProviderString = "gitlab"
GiteaProviderString = "gitea"
OpenIDConnectString = "openid-connect"
)
type Provider interface {
RegisterProvider() error
BeginAuthHandler(ctx *context.Context)
UserHasProvider(user *db.User) bool
}
type CallbackProvider interface {
GetProvider() string
GetProviderUser() *goth.User
GetProviderUserID(user *db.User) bool
GetProviderUserSSHKeys() ([]string, error)
UpdateUserDB(user *db.User)
}
func DefineProvider(provider string, url string) (Provider, error) {
switch provider {
case GitHubProviderString:
return NewGitHubProvider(url), nil
case GitLabProviderString:
return NewGitLabProvider(url), nil
case GiteaProviderString:
return NewGiteaProvider(url), nil
case OpenIDConnectString:
return NewOIDCProvider(url), nil
}
return nil, fmt.Errorf("unsupported provider %s", provider)
}
func CompleteUserAuth(ctx *context.Context) (CallbackProvider, error) {
user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request())
if err != nil {
return nil, err
}
switch user.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", user.Provider)
}
func urlJoin(base string, elem ...string) string {
joined, err := url.JoinPath(base, elem...)
if err != nil {
log.Error().Err(err).Msg("Cannot join url")
}
return joined
}
func readKeys(response *http.Response) ([]string, error) {
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("could not get user keys %v", err)
}
keys := strings.Split(string(body), "\n")
if len(keys[len(keys)-1]) == 0 {
keys = keys[:len(keys)-1]
}
return keys, nil
}

View File

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

View File

@ -2,8 +2,8 @@ package cli
import (
"fmt"
"github.com/thomiceli/opengist/internal/auth/password"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/utils"
"github.com/urfave/cli/v2"
)
@ -33,7 +33,7 @@ var CmdAdminResetPassword = cli.Command{
fmt.Printf("Cannot get user %s: %s\n", username, err)
return err
}
password, err := utils.Argon2id.Hash(plainPassword)
password, err := password.HashPassword(plainPassword)
if err != nil {
fmt.Printf("Cannot hash password for user %s: %s\n", username, err)
return err

View File

@ -50,7 +50,7 @@ func initialize(ctx *cli.Context) {
config.InitLog()
db.DeprecationDBFilename()
if err := db.Setup(config.C.DBUri, false); err != nil {
if err := db.Setup(config.C.DBUri); err != nil {
log.Fatal().Err(err).Msg("Failed to initialize database in hooks")
}
}

View File

@ -8,9 +8,8 @@ import (
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/index"
"github.com/thomiceli/opengist/internal/memdb"
"github.com/thomiceli/opengist/internal/ssh"
"github.com/thomiceli/opengist/internal/web"
"github.com/thomiceli/opengist/internal/web/server"
"github.com/urfave/cli/v2"
"os"
"os/signal"
@ -37,7 +36,7 @@ var CmdStart = cli.Command{
Initialize(ctx)
go web.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false).Start()
go server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false).Start()
go ssh.Start()
<-stopCtx.Done()
@ -117,21 +116,17 @@ func Initialize(ctx *cli.Context) {
}
db.DeprecationDBFilename()
if err := db.Setup(config.C.DBUri, false); err != nil {
if err := db.Setup(config.C.DBUri); err != nil {
log.Fatal().Err(err).Msg("Failed to initialize database")
}
if err := memdb.Setup(); err != nil {
log.Fatal().Err(err).Msg("Failed to initialize in memory database")
}
if err := webauthn.Init(config.C.ExternalUrl); err != nil {
log.Error().Err(err).Msg("Failed to initialize WebAuthn")
}
if config.C.IndexEnabled {
log.Info().Msg("Index directory: " + filepath.Join(homePath, config.C.IndexDirname))
index.Init(filepath.Join(homePath, config.C.IndexDirname))
index.DepreactionIndexDirname()
if index.IndexEnabled() {
go index.NewIndexer(index.IndexType())
}
}
@ -141,7 +136,7 @@ func shutdown() {
log.Error().Err(err).Msg("Failed to close database")
}
if config.C.IndexEnabled {
if index.IndexEnabled() {
log.Info().Msg("Shutting down index...")
index.Close()
}

View File

@ -2,6 +2,7 @@ package config
import (
"fmt"
"github.com/thomiceli/opengist/internal/session"
"io"
"net/url"
"os"
@ -14,7 +15,6 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/utils"
"gopkg.in/yaml.v3"
)
@ -37,8 +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"`
IndexDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"`
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"`
@ -67,10 +70,22 @@ type config struct {
GiteaUrl string `yaml:"gitea.url" env:"OG_GITEA_URL"`
GiteaName string `yaml:"gitea.name" env:"OG_GITEA_NAME"`
OIDCClientKey string `yaml:"oidc.client-key" env:"OG_OIDC_CLIENT_KEY"`
OIDCSecret string `yaml:"oidc.secret" env:"OG_OIDC_SECRET"`
OIDCDiscoveryUrl string `yaml:"oidc.discovery-url" env:"OG_OIDC_DISCOVERY_URL"`
OIDCProviderName string `yaml:"oidc.provider-name" env:"OG_OIDC_PROVIDER_NAME"`
OIDCClientKey string `yaml:"oidc.client-key" env:"OG_OIDC_CLIENT_KEY"`
OIDCSecret string `yaml:"oidc.secret" env:"OG_OIDC_SECRET"`
OIDCDiscoveryUrl string `yaml:"oidc.discovery-url" env:"OG_OIDC_DISCOVERY_URL"`
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"`
LDAPUrl string `yaml:"ldap.url" env:"OG_LDAP_URL"`
LDAPBindDn string `yaml:"ldap.bind-dn" env:"OG_LDAP_BIND_DN"`
LDAPBindCredentials string `yaml:"ldap.bind-credentials" env:"OG_LDAP_BIND_CREDENTIALS"`
LDAPSearchBase string `yaml:"ldap.search-base" env:"OG_LDAP_SEARCH_BASE"`
LDAPSearchFilter string `yaml:"ldap.search-filter" env:"OG_LDAP_SEARCH_FILTER"`
CustomName string `yaml:"custom.name" env:"OG_CUSTOM_NAME"`
CustomLogo string `yaml:"custom.logo" env:"OG_CUSTOM_LOGO"`
CustomFavicon string `yaml:"custom.favicon" env:"OG_CUSTOM_FAVICON"`
StaticLinks []StaticLink `yaml:"custom.static-links" env:"OG_CUSTOM_STATIC_LINK"`
@ -90,8 +105,7 @@ func configWithDefaults() (*config, error) {
c.LogOutput = "stdout,file"
c.OpengistHome = ""
c.DBUri = "opengist.db"
c.IndexEnabled = true
c.IndexDirname = "opengist.index"
c.Index = "bleve"
c.SqliteJournalMode = "WAL"
@ -109,6 +123,8 @@ func configWithDefaults() (*config, error) {
c.GiteaUrl = "https://gitea.com"
c.GiteaName = "Gitea"
c.MetricsEnabled = false
return c, nil
}
@ -164,9 +180,9 @@ func InitLog() {
}
var logWriters []io.Writer
logOutputTypes := utils.RemoveDuplicates[string](
strings.Split(strings.ToLower(C.LogOutput), ","),
)
logOutputTypes := strings.Split(strings.ToLower(C.LogOutput), ",")
slices.Sort(logOutputTypes)
logOutputTypes = slices.Compact(logOutputTypes)
consoleWriter := zerolog.NewConsoleWriter(
func(w *zerolog.ConsoleWriter) {
@ -244,7 +260,7 @@ func GetHomeDir() string {
func SetupSecretKey() {
if C.SecretKey == "" {
path := filepath.Join(GetHomeDir(), "opengist-secret.key")
SecretKey, _ = utils.GenerateSecretKey(path)
SecretKey, _ = session.GenerateSecretKey(path)
} else {
SecretKey = []byte(C.SecretKey)
}

View File

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

View File

@ -39,6 +39,7 @@ type databaseInfo struct {
User string
Password string
Database string
SSLMode string
}
var DatabaseInfo *databaseInfo
@ -46,6 +47,14 @@ var DatabaseInfo *databaseInfo
func parseDBURI(uri string) (*databaseInfo, error) {
info := &databaseInfo{}
info.SSLMode = "disable"
if uri == ":memory:" {
info.Type = SQLite
info.Database = uri
return info, nil
}
u, err := url.Parse(uri)
if err != nil {
return nil, fmt.Errorf("invalid URI: %v", err)
@ -79,6 +88,13 @@ func parseDBURI(uri string) (*databaseInfo, error) {
info.Password, _ = u.User.Password()
}
if u.RawQuery != "" {
q, _ := url.ParseQuery(u.RawQuery)
if sslmode := q.Get("sslmode"); sslmode != "" && info.Type == PostgreSQL {
info.SSLMode = sslmode
}
}
switch info.Type {
case PostgreSQL, MySQL:
info.Database = strings.TrimPrefix(u.Path, "/")
@ -91,14 +107,14 @@ func parseDBURI(uri string) (*databaseInfo, error) {
return info, nil
}
func Setup(dbUri string, sharedCache bool) error {
func Setup(dbUri string) error {
dbInfo, err := parseDBURI(dbUri)
if err != nil {
return err
}
log.Info().Msgf("Setting up a %s database connection", dbInfo.Type)
var setupFunc func(databaseInfo, bool) error
var setupFunc func(databaseInfo) error
switch dbInfo.Type {
case SQLite:
setupFunc = setupSQLite
@ -114,7 +130,7 @@ func Setup(dbUri string, sharedCache bool) error {
retryInterval := 1 * time.Second
for attempt := 1; attempt <= maxAttempts; attempt++ {
err = setupFunc(*dbInfo, sharedCache)
err = setupFunc(*dbInfo)
if err == nil {
log.Info().Msg("Database connection established")
break
@ -138,11 +154,11 @@ func Setup(dbUri string, sharedCache bool) error {
return err
}
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}); err != nil {
return err
}
if err = applyMigrations(db, dbInfo); err != nil {
if err = applyMigrations(dbInfo); err != nil {
return err
}
@ -183,39 +199,40 @@ func Ping() error {
return sql.Ping()
}
func setupSQLite(dbInfo databaseInfo, sharedCache bool) error {
func setupSQLite(dbInfo databaseInfo) error {
var err error
var dsn string
journalMode := strings.ToUpper(config.C.SqliteJournalMode)
if !slices.Contains([]string{"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"}, journalMode) {
log.Warn().Msg("Invalid SQLite journal mode: " + journalMode)
}
u, err := url.Parse(dbInfo.Database)
if err != nil {
return err
}
if dbInfo.Database == ":memory:" {
dsn = ":memory:?_fk=true&cache=shared"
} else {
u, err := url.Parse(dbInfo.Database)
if err != nil {
return err
}
u.Scheme = "file"
q := u.Query()
q.Set("_fk", "true")
q.Set("_journal_mode", journalMode)
if sharedCache {
q.Set("cache", "shared")
u.Scheme = "file"
q := u.Query()
q.Set("_pragma", "foreign_keys(1)")
q.Set("_journal_mode", journalMode)
u.RawQuery = q.Encode()
dsn = u.String()
}
u.RawQuery = q.Encode()
dsn := u.String()
db, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
TranslateError: true,
})
return err
}
func setupPostgres(dbInfo databaseInfo, sharedCache bool) error {
func setupPostgres(dbInfo databaseInfo) error {
var err error
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", dbInfo.Host, dbInfo.Port, dbInfo.User, dbInfo.Password, dbInfo.Database)
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", dbInfo.Host, dbInfo.Port, dbInfo.User, dbInfo.Password, dbInfo.Database, dbInfo.SSLMode)
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
@ -225,7 +242,7 @@ func setupPostgres(dbInfo databaseInfo, sharedCache bool) error {
return err
}
func setupMySQL(dbInfo databaseInfo, sharedCache bool) error {
func setupMySQL(dbInfo databaseInfo) error {
var err error
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", dbInfo.User, dbInfo.Password, dbInfo.Host, dbInfo.Port, dbInfo.Database)
@ -251,5 +268,5 @@ func DeprecationDBFilename() {
}
func TruncateDatabase() error {
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{})
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{})
}

View File

@ -1,9 +1,12 @@
package db
import (
"bytes"
"encoding/gob"
"fmt"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
@ -37,6 +40,10 @@ func (v Visibility) String() string {
}
}
func (v Visibility) Uint() uint {
return uint(v)
}
func (v Visibility) Next() Visibility {
switch v {
case PublicVisibility:
@ -48,16 +55,16 @@ func (v Visibility) Next() Visibility {
}
}
func ParseVisibility[T string | int](v T) (Visibility, error) {
func ParseVisibility[T string | int](v T) Visibility {
switch s := fmt.Sprint(v); s {
case "0", "public":
return PublicVisibility, nil
return PublicVisibility
case "1", "unlisted":
return UnlistedVisibility, nil
return UnlistedVisibility
case "2", "private":
return PrivateVisibility, nil
return PrivateVisibility
default:
return -1, fmt.Errorf("unknown visibility %q", s)
return PublicVisibility
}
}
@ -81,6 +88,9 @@ type Gist struct {
Likes []User `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Forked *Gist `gorm:"foreignKey:ForkedID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
ForkedID uint
Topics []GistTopic `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages []GistLanguage `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type Like struct {
@ -100,7 +110,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").
err := db.Preload("User").Preload("Forked.User").Preload("Topics").
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
@ -110,7 +120,7 @@ func GetGist(user string, gistUuid string) (*Gist, error) {
func GetGistByID(gistId string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Preload("Forked.User").
err := db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("gists.id = ?", gistId).
First(&gist).Error
@ -119,7 +129,9 @@ func GetGistByID(gistId string) (*Gist, error) {
func GetAllGistsForCurrentUser(currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
var gists []*Gist
err := db.Preload("User").Preload("Forked.User").
err := db.Preload("User").
Preload("Forked.User").
Preload("Topics").
Where("gists.private = 0 or gists.user_id = ?", currentUserId).
Limit(11).
Offset(offset * 10).
@ -140,12 +152,18 @@ func GetAllGists(offset int) ([]*Gist, error) {
return gists, err
}
func GetAllGistsFromSearch(currentUserId uint, query string, offset int, sort string, order string) ([]*Gist, error) {
func GetAllGistsFromSearch(currentUserId uint, query string, offset int, sort string, order string, topic string) ([]*Gist, error) {
var gists []*Gist
err := db.Preload("User").Preload("Forked.User").
tx := db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("gists.title like ? or gists.description like ?", "%"+query+"%", "%"+query+"%").
Limit(11).
Where("gists.title like ? or gists.description like ?", "%"+query+"%", "%"+query+"%")
if topic != "" {
tx = tx.Joins("join gist_topics on gists.id = gist_topics.gist_id").
Where("gist_topics.topic = ?", topic)
}
err := tx.Limit(11).
Offset(offset * 10).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
@ -154,20 +172,47 @@ func GetAllGistsFromSearch(currentUserId uint, query string, offset int, sort st
}
func gistsFromUserStatement(fromUserId uint, currentUserId uint) *gorm.DB {
return db.Preload("User").Preload("Forked.User").
return db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("users.id = ?", fromUserId).
Joins("join users on gists.user_id = users.id")
}
func GetAllGistsFromUser(fromUserId uint, currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
func GetAllGistsFromUser(fromUserId uint, currentUserId uint, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) {
var gists []*Gist
err := gistsFromUserStatement(fromUserId, currentUserId).Limit(11).
var count int64
baseQuery := gistsFromUserStatement(fromUserId, currentUserId).Model(&Gist{})
if title != "" {
baseQuery = baseQuery.Where("gists.title like ?", "%"+title+"%")
}
if language != "" {
baseQuery = baseQuery.Joins("join gist_languages on gists.id = gist_languages.gist_id").
Where("gist_languages.language = ?", language)
}
if visibility != "" {
baseQuery = baseQuery.Where("gists.private = ?", ParseVisibility(visibility))
}
if len(topics) > 0 {
baseQuery = baseQuery.Joins("join gist_topics on gists.id = gist_topics.gist_id").
Where("gist_topics.topic in ?", topics)
}
err := baseQuery.Count(&count).Error
if err != nil {
return nil, 0, err
}
err = baseQuery.Limit(11).
Offset(offset * 10).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
return gists, count, err
}
func CountAllGistsFromUser(fromUserId uint, currentUserId uint) (int64, error) {
@ -177,7 +222,7 @@ func CountAllGistsFromUser(fromUserId uint, currentUserId uint) (int64, error) {
}
func likedStatement(fromUserId uint, currentUserId uint) *gorm.DB {
return db.Preload("User").Preload("Forked.User").
return db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("likes.user_id = ?", fromUserId).
Joins("join likes on gists.id = likes.gist_id").
@ -200,7 +245,7 @@ func CountAllGistsLikedByUser(fromUserId uint, currentUserId uint) (int64, error
}
func forkedStatement(fromUserId uint, currentUserId uint) *gorm.DB {
return db.Preload("User").Preload("Forked.User").
return db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("gists.forked_id is not null and ((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("gists.user_id = ?", fromUserId).
Joins("join users on gists.user_id = users.id")
@ -242,11 +287,22 @@ func GetAllGistsVisibleByUser(userId uint) ([]uint, error) {
func GetAllGistsByIds(ids []uint) ([]*Gist, error) {
var gists []*Gist
err := db.Preload("User").Preload("Forked.User").
err := db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("id in ?", ids).
Find(&gists).Error
return gists, err
// keep order
ordered := make([]*Gist, 0, len(ids))
for _, wantedId := range ids {
for _, gist := range gists {
if gist.ID == wantedId {
ordered = append(ordered, gist)
break
}
}
}
return ordered, err
}
func (gist *Gist) Create() error {
@ -259,6 +315,12 @@ func (gist *Gist) CreateForked() error {
}
func (gist *Gist) Update() error {
// reset the topics
err := db.Model(&GistTopic{}).Where("gist_id = ?", gist.ID).Delete(&GistTopic{}).Error
if err != nil {
return err
}
return db.Omit("forked_id").Save(&gist).Error
}
@ -535,6 +597,113 @@ func (gist *Gist) GetLanguagesFromFiles() ([]string, error) {
return languages, nil
}
func (gist *Gist) GetTopics() ([]string, error) {
var topics []string
err := db.Model(&GistTopic{}).
Where("gist_id = ?", gist.ID).
Pluck("topic", &topics).Error
return topics, err
}
func (gist *Gist) TopicsSlice() []string {
topics := make([]string, 0, len(gist.Topics))
for _, topic := range gist.Topics {
topics = append(topics, topic.Topic)
}
return topics
}
func (gist *Gist) SerialiseInitRepository() error {
var gobBuffer bytes.Buffer
encoder := gob.NewEncoder(&gobBuffer)
if err := encoder.Encode(gist); err != nil {
return fmt.Errorf("gob encoding error: %v", err)
}
return git.SerialiseInitRepository(gist.User.Username, gobBuffer.Bytes())
}
func DeserialiseInitRepository(user string) (*Gist, error) {
data, err := git.DeserialiseInitRepository(user)
if err != nil {
return nil, err
}
var gist Gist
decoder := gob.NewDecoder(bytes.NewReader(data))
if err := decoder.Decode(&gist); err != nil {
return nil, fmt.Errorf("gob decoding error: %v", err)
}
return &gist, nil
}
func (gist *Gist) UpdateLanguages() {
languages, err := gist.GetLanguagesFromFiles()
if err != nil {
log.Error().Err(err).Msgf("Cannot get languages for gist %d", gist.ID)
return
}
slices.Sort(languages)
languages = slices.Compact(languages)
tx := db.Begin()
if tx.Error != nil {
log.Error().Err(tx.Error).Msgf("Cannot start transaction for gist %d", gist.ID)
return
}
if err := tx.Where("gist_id = ?", gist.ID).Delete(&GistLanguage{}).Error; err != nil {
tx.Rollback()
log.Error().Err(err).Msgf("Cannot delete languages for gist %d", gist.ID)
return
}
for _, language := range languages {
gistLanguage := &GistLanguage{
GistID: gist.ID,
Language: language,
}
if err := tx.Create(gistLanguage).Error; err != nil {
tx.Rollback()
log.Error().Err(err).Msgf("Cannot create gist language %s for gist %d", language, gist.ID)
return
}
}
if err := tx.Commit().Error; err != nil {
tx.Rollback()
log.Error().Err(err).Msgf("Cannot commit transaction for gist %d", gist.ID)
return
}
}
func (gist *Gist) ToDTO() (*GistDTO, error) {
files, err := gist.Files("HEAD", false)
if err != nil {
return nil, err
}
fileDTOs := make([]FileDTO, 0, len(files))
for _, file := range files {
fileDTOs = append(fileDTOs, FileDTO{
Filename: file.Filename,
Content: file.Content,
})
}
return &GistDTO{
Title: gist.Title,
Description: gist.Description,
URL: gist.URL,
Files: fileDTOs,
VisibilityDTO: VisibilityDTO{
Private: gist.Private,
},
Topics: strings.Join(gist.TopicsSlice(), " "),
}, nil
}
// -- DTO -- //
type GistDTO struct {
@ -544,9 +713,14 @@ type GistDTO struct {
Files []FileDTO `validate:"min=1,dive"`
Name []string `form:"name"`
Content []string `form:"content"`
Topics string `validate:"gisttopics" form:"topics"`
VisibilityDTO
}
func (dto *GistDTO) HasMetadata() bool {
return dto.Title != "" || dto.Description != "" || dto.URL != "" || dto.Topics != ""
}
type VisibilityDTO struct {
Private Visibility `validate:"number,min=0,max=2" form:"private"`
}
@ -562,6 +736,7 @@ func (dto *GistDTO) ToGist() *Gist {
Description: dto.Description,
Private: dto.Private,
URL: dto.URL,
Topics: dto.TopicStrToSlice(),
}
}
@ -569,9 +744,19 @@ func (dto *GistDTO) ToExistingGist(gist *Gist) *Gist {
gist.Title = dto.Title
gist.Description = dto.Description
gist.URL = dto.URL
gist.Topics = dto.TopicStrToSlice()
return gist
}
func (dto *GistDTO) TopicStrToSlice() []GistTopic {
topics := strings.Fields(dto.Topics)
gistTopics := make([]GistTopic, 0, len(topics))
for _, topic := range topics {
gistTopics = append(gistTopics, GistTopic{Topic: topic})
}
return gistTopics
}
// -- Index -- //
func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
@ -584,6 +769,9 @@ func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
wholeContent := ""
for _, file := range files {
wholeContent += file.Content
if !strings.HasSuffix(wholeContent, "\n") {
wholeContent += "\n"
}
exts = append(exts, filepath.Ext(file.Filename))
}
@ -597,14 +785,22 @@ func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
return nil, err
}
topics, err := gist.GetTopics()
if err != nil {
return nil, err
}
indexedGist := &index.Gist{
GistID: gist.ID,
UserID: gist.UserID,
Visibility: gist.Private.Uint(),
Username: gist.User.Username,
Title: gist.Title,
Content: wholeContent,
Filenames: fileNames,
Extensions: exts,
Languages: langs,
Topics: topics,
CreatedAt: gist.CreatedAt,
UpdatedAt: gist.UpdatedAt,
}
@ -613,7 +809,7 @@ func (gist *Gist) ToIndexedGist() (*index.Gist, error) {
}
func (gist *Gist) AddInIndex() {
if !index.Enabled() {
if !index.IndexEnabled() {
return
}
@ -631,7 +827,7 @@ func (gist *Gist) AddInIndex() {
}
func (gist *Gist) RemoveFromIndex() {
if !index.Enabled() {
if !index.IndexEnabled() {
return
}

View File

@ -0,0 +1,29 @@
package db
type GistLanguage struct {
GistID uint `gorm:"primaryKey"`
Language string `gorm:"primaryKey;size:100"`
}
func GetGistLanguagesForUser(fromUserId, currentUserId uint) ([]struct {
Language string
Count int64
}, error) {
var results []struct {
Language string
Count int64
}
err := db.Model(&GistLanguage{}).
Select("language, count(*) as count").
Joins("JOIN gists ON gists.id = gist_languages.gist_id").
Joins("JOIN users ON gists.user_id = users.id").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("users.id = ?", fromUserId).
Group("language").
Order("count DESC").
Limit(15).
Find(&results).Error
return results, err
}

View File

@ -0,0 +1,6 @@
package db
type GistTopic struct {
GistID uint `gorm:"primaryKey"`
Topic string `gorm:"primaryKey;size:50"`
}

View File

@ -3,7 +3,6 @@ package db
import (
"fmt"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type MigrationVersion struct {
@ -11,10 +10,10 @@ type MigrationVersion struct {
Version uint
}
func applyMigrations(db *gorm.DB, dbInfo *databaseInfo) error {
func applyMigrations(dbInfo *databaseInfo) error {
switch dbInfo.Type {
case SQLite:
return applySqliteMigrations(db)
return applySqliteMigrations()
case PostgreSQL, MySQL:
return nil
default:
@ -23,7 +22,7 @@ func applyMigrations(db *gorm.DB, dbInfo *databaseInfo) error {
}
func applySqliteMigrations(db *gorm.DB) 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")
@ -37,7 +36,7 @@ func applySqliteMigrations(db *gorm.DB) error {
// Define migrations
migrations := []struct {
Version uint
Func func(*gorm.DB) error
Func func() error
}{
{1, v1_modifyConstraintToSSHKeys},
{2, v2_lowercaseEmails},
@ -53,7 +52,7 @@ func applySqliteMigrations(db *gorm.DB) error {
return err
}
if err := m.Func(db); err != nil {
if err := m.Func(); err != nil {
log.Fatal().Err(err).Msg(fmt.Sprintf("Error applying migration %d:", m.Version))
tx.Rollback()
return err
@ -73,7 +72,7 @@ func applySqliteMigrations(db *gorm.DB) error {
}
// Modify the constraint on the ssh_keys table to use ON DELETE CASCADE
func v1_modifyConstraintToSSHKeys(db *gorm.DB) error {
func v1_modifyConstraintToSSHKeys() error {
createSQL := `
CREATE TABLE ssh_keys_temp (
id integer primary key,
@ -108,7 +107,7 @@ func v1_modifyConstraintToSSHKeys(db *gorm.DB) error {
return db.Exec(renameSQL).Error
}
func v2_lowercaseEmails(db *gorm.DB) error {
func v2_lowercaseEmails() error {
// Copy the lowercase emails into the new column
copySQL := `UPDATE users SET email = lower(email);`
return db.Exec(copySQL).Error

View File

@ -6,9 +6,10 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"github.com/thomiceli/opengist/internal/auth"
"github.com/thomiceli/opengist/internal/auth/password"
ogtotp "github.com/thomiceli/opengist/internal/auth/totp"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/utils"
"slices"
)
@ -30,7 +31,7 @@ func GetTOTPByUserID(userID uint) (*TOTP, error) {
func (totp *TOTP) StoreSecret(secret string) error {
secretBytes := []byte(secret)
encrypted, err := utils.AESEncrypt(config.SecretKey, secretBytes)
encrypted, err := auth.AESEncrypt(config.SecretKey, secretBytes)
if err != nil {
return err
}
@ -45,7 +46,7 @@ func (totp *TOTP) ValidateCode(code string) (bool, error) {
return false, err
}
secretBytes, err := utils.AESDecrypt(config.SecretKey, ciphertext)
secretBytes, err := auth.AESDecrypt(config.SecretKey, ciphertext)
if err != nil {
return false, err
}
@ -60,7 +61,7 @@ func (totp *TOTP) ValidateRecoveryCode(code string) (bool, error) {
}
for i, hashedCode := range hashedCodes {
ok, err := utils.Argon2id.Verify(code, hashedCode)
ok, err := password.VerifyPassword(code, hashedCode)
if err != nil {
return false, err
}
@ -106,7 +107,7 @@ func generateRandomCodes() ([]string, []string, error) {
hexCode := hex.EncodeToString(bytes)
code := fmt.Sprintf("%s-%s", hexCode[:length/2], hexCode[length/2:])
plainCodes[i] = code
hashed, err := utils.Argon2id.Hash(code)
hashed, err := password.HashPassword(code)
if err != nil {
return nil, nil, err
}

View File

@ -1,22 +1,25 @@
package db
import (
"encoding/json"
"github.com/thomiceli/opengist/internal/git"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex,size:191"`
Password string
IsAdmin bool
CreatedAt int64
Email string
MD5Hash string // for gravatar, if no Email is specified, the value is random
AvatarURL string
GithubID string
GitlabID string
GiteaID string
OIDCID string `gorm:"column:oidc_id"`
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"`
@ -25,31 +28,36 @@ type User struct {
}
func (user *User) BeforeDelete(tx *gorm.DB) error {
// Decrement likes counter for all gists liked by this user
// The likes will be automatically deleted by the foreign key constraint
err := tx.Model(&Gist{}).
Omit("updated_at").
Where("id IN (?)", tx.
Select("gist_id").
Table("likes").
Where("user_id = ?", user.ID),
).
UpdateColumn("nb_likes", gorm.Expr("nb_likes - 1")).
Error
// Decrement likes counter using derived table
err := tx.Exec(`
UPDATE gists
SET nb_likes = nb_likes - 1
WHERE id IN (
SELECT gist_id
FROM (
SELECT gist_id
FROM likes
WHERE user_id = ?
) AS derived_likes
)
`, user.ID).Error
if err != nil {
return err
}
// Decrement forks counter for all gists forked by this user
err = tx.Model(&Gist{}).
Omit("updated_at").
Where("id IN (?)", tx.
Select("forked_id").
Table("gists").
Where("user_id = ?", user.ID),
).
UpdateColumn("nb_forks", gorm.Expr("nb_forks - 1")).
Error
// Decrement forks counter using derived table
err = tx.Exec(`
UPDATE gists
SET nb_forks = nb_forks - 1
WHERE id IN (
SELECT forked_id
FROM (
SELECT forked_id
FROM gists
WHERE user_id = ? AND forked_id IS NOT NULL
) AS derived_forks
)
`, user.ID).Error
if err != nil {
return err
}
@ -64,8 +72,17 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
return err
}
// Delete all gists created by this user
return tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
err = tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
if err != nil {
return err
}
// Delete user directory
if err = git.DeleteUserDirectory(user.Username); err != nil {
return err
}
return nil
}
func UserExists(username string) (bool, error) {
@ -219,6 +236,15 @@ func (user *User) HasMFA() (bool, bool, error) {
return webauthn, totp, err
}
func (user *User) GetStyle() *UserStyleDTO {
style := new(UserStyleDTO)
err := json.Unmarshal([]byte(user.StylePreferences), style)
if err != nil {
return nil
}
return style
}
// -- DTO -- //
type UserDTO struct {
@ -232,3 +258,22 @@ func (dto *UserDTO) ToUser() *User {
Password: dto.Password,
}
}
type UserUsernameDTO struct {
Username string `form:"username" validate:"required,max=24,alphanumdash,notreserved"`
}
type UserStyleDTO struct {
SoftWrap bool `form:"softwrap" json:"soft_wrap"`
RemovedLineColor string `form:"removedlinecolor" json:"removed_line_color" validate:"min=0,max=7"`
AddedLineColor string `form:"addedlinecolor" json:"added_line_color" validate:"min=0,max=7"`
GitLineColor string `form:"gitlinecolor" json:"git_line_color" validate:"min=0,max=7"`
}
func (dto *UserStyleDTO) ToJson() string {
data, err := json.Marshal(dto)
if err != nil {
return "{}"
}
return string(data)
}

View File

@ -73,8 +73,7 @@ func GetUserByCredentialID(credID binaryData) (*User, error) {
if err = db.Preload("User").Where("credential_id = decode(?, 'hex')", hexCredID).First(&credential).Error; err != nil {
return nil, err
}
case "mysql":
case "sqlite":
case "mysql", "sqlite":
hexCredID := hex.EncodeToString(credID)
if err = db.Preload("User").Where("credential_id = unhex(?)", hexCredID).First(&credential).Error; err != nil {
return nil, err
@ -100,8 +99,7 @@ func GetCredentialByID(id binaryData) (*WebAuthnCredential, error) {
if err = db.Where("credential_id = decode(?, 'hex')", hexCredID).First(&cred).Error; err != nil {
return nil, err
}
case "mysql":
case "sqlite":
case "mysql", "sqlite":
hexCredID := hex.EncodeToString(id)
if err = db.Where("credential_id = unhex(?)", hexCredID).First(&cred).Error; err != nil {
return nil, err

View File

@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"net/url"
@ -38,6 +39,10 @@ func RepositoryPath(user string, gist string) string {
return filepath.Join(config.GetHomeDir(), ReposDirectory, strings.ToLower(user), gist)
}
func UserRepositoriesPath(user string) string {
return filepath.Join(config.GetHomeDir(), ReposDirectory, strings.ToLower(user))
}
func RepositoryUrl(ctx echo.Context, user string, gist string) string {
httpProtocol := "http"
if ctx.Request().TLS != nil || ctx.Request().Header.Get("X-Forwarded-Proto") == "https" {
@ -485,6 +490,22 @@ func GcRepos() error {
return err
}
func ResetHooks() error {
entries, err := filepath.Glob(filepath.Join(config.GetHomeDir(), ReposDirectory, "*", "*"))
if err != nil {
return err
}
for _, e := range entries {
repoPath := strings.Split(e, string(os.PathSeparator))
if err := CreateDotGitFiles(repoPath[len(repoPath)-2], repoPath[len(repoPath)-1]); err != nil {
log.Error().Err(err).Msgf("Cannot reset hooks for repository %s/%s", repoPath[len(repoPath)-2], repoPath[len(repoPath)-1])
}
}
return nil
}
func HasNoCommits(user string, gist string) (bool, error) {
repositoryPath := RepositoryPath(user, gist)
@ -540,6 +561,54 @@ func CreateDotGitFiles(user string, gist string) error {
return nil
}
func DeleteUserDirectory(user string) error {
return os.RemoveAll(filepath.Join(config.GetHomeDir(), ReposDirectory, user))
}
func SerialiseInitRepository(user string, serialized []byte) error {
userRepositoryPath := UserRepositoriesPath(user)
initPath := filepath.Join(userRepositoryPath, "_init")
f, err := os.OpenFile(initPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
encodedData := base64.StdEncoding.EncodeToString(serialized)
_, err = f.Write(append([]byte(encodedData), '\n'))
return err
}
func DeserialiseInitRepository(user string) ([]byte, error) {
initPath := filepath.Join(UserRepositoriesPath(user), "_init")
content, err := os.ReadFile(initPath)
if err != nil {
return nil, err
}
idx := bytes.Index(content, []byte{'\n'})
if idx == -1 {
return base64.StdEncoding.DecodeString(string(content))
}
firstLine := content[:idx]
remaining := content[idx+1:]
if len(remaining) == 0 {
if err := os.Remove(initPath); err != nil {
return nil, fmt.Errorf("failed to remove file: %v", err)
}
} else {
if err := os.WriteFile(initPath, remaining, 0644); err != nil {
return nil, fmt.Errorf("failed to write remaining content: %v", err)
}
}
return base64.StdEncoding.DecodeString(string(firstLine))
}
func createDotGitHookFile(repositoryPath string, hook string, content string) error {
preReceiveDst, err := os.OpenFile(filepath.Join(repositoryPath, "hooks", hook), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0744)
if err != nil {
@ -603,7 +672,7 @@ func convertUTF8ToOctal(name string) string {
}
func convertURLToOctal(name string) string {
decoded, err := url.QueryUnescape(name)
decoded, err := url.PathUnescape(name)
if err != nil {
return name
}

View File

@ -5,7 +5,7 @@ import (
"fmt"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/utils"
validatorpkg "github.com/thomiceli/opengist/internal/validator"
"io"
"os"
"os/exec"
@ -18,7 +18,7 @@ func PostReceive(in io.Reader, out, er io.Writer) error {
newGist := false
opts := pushOptions()
gistUrl := os.Getenv("OPENGIST_REPOSITORY_URL_INTERNAL")
validator := utils.NewValidator()
validator := validatorpkg.NewValidator()
scanner := bufio.NewScanner(in)
for scanner.Scan() {
@ -46,7 +46,7 @@ func PostReceive(in io.Reader, out, er io.Writer) error {
}
if slices.Contains([]string{"public", "unlisted", "private"}, opts["visibility"]) {
gist.Private, _ = db.ParseVisibility(opts["visibility"])
gist.Private = db.ParseVisibility(opts["visibility"])
outputSb.WriteString(fmt.Sprintf("Gist visibility set to %s\n\n", opts["visibility"]))
}
@ -65,6 +65,11 @@ func PostReceive(in io.Reader, out, er io.Writer) error {
outputSb.WriteString(fmt.Sprintf("Gist title set to \"%s\"\n\n", opts["title"]))
}
if opts["description"] != "" && validator.Var(opts["description"], "max=1000") == nil {
gist.Description = opts["description"]
outputSb.WriteString(fmt.Sprintf("Gist description set to \"%s\"\n\n", opts["description"]))
}
if hasNoCommits, err := git.HasNoCommits(gist.User.Username, gist.Uuid); err != nil {
_, _ = fmt.Fprintln(er, "Failed to check if gist has no commits")
return fmt.Errorf("failed to check if gist has no commits: %w", err)

View File

@ -21,7 +21,7 @@ gist.header.embed: 'Einbetten'
gist.header.embed-help: 'Bette diese Gist in deine Webseite ein.'
gist.header.download-zip: 'ZIP Herunterladen'
gist.raw: 'Orginalformat'
gist.raw: 'Originalformat'
gist.file-truncated: 'Diese Datei wurde abgeschnitten.'
gist.watch-full-file: 'Die gesamte Datei anzeigen.'
gist.file-not-valid: 'Diese Datei ist keine korrekte CSV Datei.'
@ -37,7 +37,7 @@ gist.new.indent-mode-space: 'Leerzeichen'
gist.new.indent-mode-tab: 'Tab'
gist.new.indent-size: 'Einrückungs Größe'
gist.new.wrap-mode: 'Textumbruch Modus'
gist.new.wrap-mode-no: 'kein Textumruch'
gist.new.wrap-mode-no: 'kein Textumbruch'
gist.new.wrap-mode-soft: 'weicher Zeilenumbruch'
gist.new.add-file: 'Datei hinzufügen'
gist.new.create-public-button: 'Öffentliche Gist erstellen'
@ -46,14 +46,14 @@ gist.new.create-private-button: 'Private Gist erstellen'
gist.new.preview: 'Vorschau'
gist.new.create-a-new-gist: 'Neue Gist erstellen'
gist.edit.editing: 'Bearbeiten'
gist.edit.editing: 'In Bearbeitung'
gist.edit.edit-gist: '%s bearbeiten'
gist.edit.change-visibility: 'Sichtbarkeit ändern'
gist.edit.delete: 'Löschen'
gist.edit.cancel: 'Abbrechen'
gist.edit.save: 'Speichern'
gist.list.joined: 'Gemeinsam'
gist.list.joined: 'Beigetreten'
gist.list.all: 'Alle Gists'
gist.list.search-results: 'Suchergebnisse'
gist.list.sort: 'Sortieren'
@ -61,17 +61,17 @@ gist.list.sort-by-created: 'erstellt'
gist.list.sort-by-updated: 'bearbeitet'
gist.list.order-by-asc: 'Älteste'
gist.list.order-by-desc: 'Neueste'
gist.list.select-tab: 'Tab Auswählen'
gist.list.select-tab: 'Tab auswählen'
gist.list.liked: 'Favorisiert'
gist.list.likes: 'Favoriten'
gist.list.forked: 'Forked'
gist.list.forked-from: 'Forked von'
gist.list.forked: 'Geforkt'
gist.list.forked-from: 'Geforkt von'
gist.list.forks: 'Forks'
gist.list.files: 'Dateien'
gist.list.last-active: 'Zuletzt aktiv'
gist.list.no-gists: 'Keine Gists'
gist.list.all-liked-by: 'Alle Gists favorisiert von %s'
gist.list.all-forked-by: 'Alle Gists geforked von %s'
gist.list.all-forked-by: 'Alle Gists geforkt von %s'
gist.list.all-from: 'Alle Gists von %s'
gist.search.found: 'Gists gefunden'
@ -89,7 +89,7 @@ gist.forks.for: 'Fork für %s'
gist.likes: 'Favoriten'
gist.likes.no: 'Keine Favorisierungen'
gist.likes.for: 'Favortitisiert für %s'
gist.likes.for: 'Favorisiert für %s'
gist.revisions: 'Revisionen'
gist.revision.revised: 'hat die Gist bearbeitet'
@ -112,7 +112,7 @@ settings.link-accounts: 'Accounts verlinken'
settings.link-github-account: 'GitHub-Account verlinken'
settings.link-gitlab-account: 'GitLab-Account verlinken'
settings.link-gitea-account: 'Gitea-Account verlinken'
settings.unlink-github-account: 'Github-Account Verlinkung aufheben'
settings.unlink-github-account: 'GitHub-Account Verlinkung aufheben'
settings.unlink-gitlab-account: 'GitLab-Account Verlinkung aufheben'
settings.unlink-gitea-account: 'Gitea-Account Verlinkung aufheben'
settings.delete-account: 'Account löschen'
@ -142,7 +142,7 @@ auth.username: 'Benutzername'
auth.password: 'Passwort'
auth.register-instead: 'Stattdessen registrieren'
auth.login-instead: 'Stattdessen anmelden'
auth.oauth: 'Weiter mit %s Account'
auth.oauth: 'Weiter mit %s Konto'
error: 'Fehler'
error.page-not-found: 'Seite nicht gefunden'
@ -267,3 +267,39 @@ validation.not-enough: 'Nicht genug %s'
validation.invalid: 'Ungültiges %s'
html.title.admin-panel: 'Admin-Panel'
auth.totp.regenerate-recovery-codes: Wiederherstellungscodes neu generieren
auth.totp.already-enabled: TOTP ist bereits aktiv
auth.totp.invalid-secret: Ungültiger TOTP Sicherheitsschlüssel
auth.totp.disable: TOTP ausschalten
auth.mfa.passkey: Hauptschlüssel
auth.mfa.use-passkey: Hauptschlüssel nutzen
auth.mfa.use-passkey-to-finish: Nutzen Sie einen Hauptschlüssel, um Authentisierung abzuschliessen
auth.totp.invalid-code: Ungültiger TOTP Code
auth.mfa: Multi-Faktor Authentisierung
gist.delete.confirm: Sind Sie sich sicher, ob Sie dieses Gist löschen wollen?
auth.mfa.passkeys: Hauptschlüssel
auth.mfa.bind-passkey: Hauptschlüssel verbinden
auth.mfa.login-with-passkey: Mit Hauptschlüssel einloggen
auth.mfa.waiting-for-passkey-input: Auf Eingabe von Browser-Interaktion warten...
auth.mfa.passkey-name: Name
auth.mfa.delete-passkey: Löschen
auth.mfa.passkey-added-at: Hinzugefügt
auth.mfa.passkey-never-used: Niemals benutzt
auth.mfa.passkey-last-used: Zuletzt benutzt
auth.totp.code: Code
auth.totp.proceed: Fortfahren
admin.invitations.delete_confirm: Willst du diese Einladung löschen?
flash.auth.passkey-deleted: Hauptschlüssel gelöscht
flash.auth.passkey-registred: Hauptschlüssel %s wurde registriert
auth.totp: Zeitbasiertes Einmal-Passwort (TOTP)
auth.totp.use: TOTP einrichten
auth.totp.help: TOTP ist eine Zwei-Faktor-Authentifizierungsmethode, welche ein gemeinsames Geheimnis verwendet, um ein Einmal-Passwort zu generieren.
auth.totp.code-used: Der Wiederherstellungscode %s wurde verwendet. Dieser ist nun ungültig. Du solltest die Zwei-Faktor-Authentifizierung möglicherweise vorübergehend deaktivieren oder deine Codes neu generieren.
auth.totp.disabled: TOTP erfolgreich deaktiviert
auth.mfa.passkeys-help: Füge einen Passkey hinzu, um dich bei deinem Account anzumelden und ihn als Zwei-Faktor-Methode zu verwenden.
auth.mfa.delete-passkey-confirm: Löschen des Hauptschlüssels bestätigen
auth.totp.submit: Bestätigen
auth.totp.scan-qr-code: Scanne den unten stehenden QR-Code mit deiner Authentifizierungs-App, um die Zwei-Faktor-Authentifizierung zu aktivieren, oder gib den folgenden Schlüssel ein und bestätige anschliessend mit dem generierten Code.
auth.totp.enter-code: Gib den Code aus deiner Authentifizierungs-App ein
auth.totp.save-recovery-codes: Speichere deine Wiederherstellungscodes an einem sicheren Ort. Du kannst diese Codes verwenden, um wieder Zugang zu deinem Account zu erlangen, wenn du den Zugriff auf deine Authentifizierungs-App verloren hast.
error.not-in-mfa-session: Nutzer ist nicht in einer Zwei-Faktor-Sitzung

View File

@ -45,6 +45,7 @@ gist.new.create-unlisted-button: Create unlisted gist
gist.new.create-private-button: Create private gist
gist.new.preview: Preview
gist.new.create-a-new-gist: Create a new gist
gist.new.topics: Topics (separate with spaces)
gist.edit.editing: Editing
gist.edit.edit-gist: Edit %s
@ -74,6 +75,9 @@ gist.list.no-gists: No gists
gist.list.all-liked-by: All gists liked by %s
gist.list.all-forked-by: All gists forked by %s
gist.list.all-from: All gists from %s
gist.list.topic-results-topic: All gists matching topic %s
gist.list.topic-results: All gists matching topic
gist.search.found: gists found
gist.search.no-results: No gists found
@ -82,6 +86,16 @@ gist.search.help.title: gists with given title
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.placeholder.title: Title
gist.search.placeholder.visibility: Visibility
gist.search.placeholder.public: Public
gist.search.placeholder.unlisted: Unlisted
gist.search.placeholder.private: Private
gist.search.placeholder.language: Language
gist.search.placeholder.all: All
gist.search.placeholder.topics: Topics
gist.search.placeholder.search: Search
gist.forks: Forks
gist.forks.view: View fork
@ -134,6 +148,17 @@ settings.create-password-help: Create your password to login to Opengist via HTT
settings.change-password: Change password
settings.change-password-help: Change your password to login to Opengist via HTTP
settings.password-label-title: Password
settings.header.account: Account
settings.header.mfa: MFA
settings.header.ssh: SSH
settings.header.style: Style
settings.style.gist-code: Gist code
settings.style.no-soft-wrap: No Soft Wrap
settings.style.soft-wrap: Soft Wrap
settings.style.removed-lines-color: Removed lines color
settings.style.added-lines-color: Added lines color
settings.style.git-lines-color: Git lines color
settings.style.save-style: Save style
auth.signup-disabled: Administrator has disabled signing up
auth.login: Login
@ -228,6 +253,7 @@ 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: Index all gists
admin.actions.sync-gist-languages: Synchronize all gists languages
admin.id: ID
admin.user: User
admin.delete: Delete
@ -273,6 +299,7 @@ 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: Indexing all gists...
flash.admin.sync-gist-languages: Syncing Gist languages...
flash.auth.username-exists: Username already exists
flash.auth.invalid-credentials: Invalid credentials
@ -303,5 +330,6 @@ validation.should-only-contain-alphanumeric-characters: Field %s should only con
validation.should-only-contain-alphanumeric-characters-and-dashes: Field %s should only contain alphanumeric characters and dashes
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

View File

@ -0,0 +1,324 @@
gist.public: '全体に公開'
gist.unlisted: '限定公開'
gist.private: '非公開'
gist.header.like: 'いいね'
gist.header.unlike: 'よくないね'
gist.header.fork: 'フォーク'
gist.header.edit: '編集'
gist.header.delete: '削除'
gist.header.forked-from: 'フォーク元'
gist.header.last-active: '最終更新'
gist.header.select-tab: 'タブを選択'
gist.header.code: 'コード'
gist.header.revisions: '修正履歴'
gist.header.revision: '修正履歴'
gist.header.clone-http: '%sでクローン'
gist.header.clone-http-help: 'HTTP BASIC認証によりGitを使ってクローン'
gist.header.clone-ssh: 'SSHでクローン'
gist.header.clone-ssh-help: 'SSH鍵認証によりGitを使ってクローン'
gist.header.embed: '埋め込み'
gist.header.embed-help: 'gistをWebサイトに埋め込む'
gist.header.download-zip: 'ZIPでダウンロード'
gist.raw: 'Raw'
gist.file-truncated: ''
gist.watch-full-file: 'ファイル全体を見る'
gist.file-not-valid: '無効なCSVファイルです'
gist.no-content: 'ファイルがありません'
gist.new.new_gist: '新規gist'
gist.new.title: 'タイトル'
gist.new.description: '概要'
gist.new.url: ''
gist.new.filename-with-extension: 'ファイル名 (拡張子あり)'
gist.new.indent-mode: 'インデント'
gist.new.indent-mode-space: '空白'
gist.new.indent-mode-tab: 'タブ文字'
gist.new.indent-size: 'インデントサイズ'
gist.new.wrap-mode: '折り返し'
gist.new.wrap-mode-no: 'なし'
gist.new.wrap-mode-soft: '右端で折り返し'
gist.new.add-file: 'ファイルを追加'
gist.new.create-public-button: '公開gistを作成'
gist.new.create-unlisted-button: '限定公開gistを作成'
gist.new.create-private-button: '非公開gistを作成'
gist.new.preview: 'プレビュー'
gist.new.create-a-new-gist: '新規gistを作成'
gist.new.topics: 'トピック (スペースで区切り)'
gist.edit.editing: '編集中'
gist.edit.edit-gist: '編集 %s'
gist.edit.change-visibility: '作成'
gist.edit.delete: '削除'
gist.edit.cancel: ''
gist.edit.save: ''
gist.delete.confirm: ''
gist.list.joined: ''
gist.list.all: ''
gist.list.search-results: ''
gist.list.sort: ''
gist.list.sort-by-created: ''
gist.list.sort-by-updated: ''
gist.list.order-by-asc: ''
gist.list.order-by-desc: ''
gist.list.select-tab: ''
gist.list.liked: ''
gist.list.likes: ''
gist.list.forked: ''
gist.list.forked-from: ''
gist.list.forks: ''
gist.list.files: ''
gist.list.last-active: ''
gist.list.no-gists: ''
gist.list.all-liked-by: ''
gist.list.all-forked-by: ''
gist.list.all-from: ''
gist.list.topic-results-topic: ''
gist.list.topic-results: ''
gist.search.found: ''
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.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.forks: ''
gist.forks.view: ''
gist.forks.no: ''
gist.forks.for: ''
gist.likes: ''
gist.likes.no: ''
gist.likes.for: ''
gist.revisions: ''
gist.revision.revised: ''
gist.revision.go-to-revision: ''
gist.revision.file-created: ''
gist.revision.file-deleted: ''
gist.revision.file-renamed: ''
gist.revision.diff-truncated: ''
gist.revision.file-renamed-no-changes: ''
gist.revision.empty-file: ''
gist.revision.no-changes: ''
gist.revision.no-revisions: ''
gist.revision-of: ''
settings: ''
settings.email: ''
settings.email-help: ''
settings.email-set: ''
settings.link-accounts: ''
settings.link-github-account: ''
settings.link-gitlab-account: ''
settings.link-gitea-account: ''
settings.unlink-github-account: ''
settings.unlink-gitlab-account: ''
settings.unlink-gitea-account: ''
settings.delete-account: ''
settings.delete-account-confirm: ''
settings.add-ssh-key: ''
settings.add-ssh-key-help: ''
settings.add-ssh-key-title: ''
settings.add-ssh-key-content: ''
settings.delete-ssh-key: ''
settings.delete-ssh-key-confirm: ''
settings.ssh-key-added-at: ''
settings.ssh-key-never-used: ''
settings.ssh-key-last-used: ''
settings.ssh-key-exists: ''
settings.change-username: ''
settings.create-password: ''
settings.create-password-help: ''
settings.change-password: ''
settings.change-password-help: ''
settings.password-label-title: ''
auth.signup-disabled: ''
auth.login: ''
auth.signup: ''
auth.new-account: ''
auth.username: ''
auth.password: ''
auth.register-instead: ''
auth.login-instead: ''
auth.oauth: ''
auth.mfa: ''
auth.mfa.passkey: ''
auth.mfa.passkeys: ''
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: ''
auth.mfa.passkey-name: ''
auth.mfa.delete-passkey: ''
auth.mfa.passkey-added-at: ''
auth.mfa.passkey-never-used: ''
auth.mfa.passkey-last-used: ''
auth.mfa.delete-passkey-confirm: ''
auth.totp: ''
auth.totp.help: ''
auth.totp.use: ''
auth.totp.regenerate-recovery-codes: ''
auth.totp.already-enabled: ''
auth.totp.invalid-secret: ''
auth.totp.invalid-code: ''
auth.totp.code-used: ''
auth.totp.disabled: ''
auth.totp.disable: ''
auth.totp.enter-code: ''
auth.totp.enter-recovery-key: ''
auth.totp.code: ''
auth.totp.submit: ''
auth.totp.proceed: ''
auth.totp.save-recovery-codes: ''
auth.totp.scan-qr-code: ''
error: ''
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: ''
error.not-in-mfa-session: ''
header.menu.all: ''
header.menu.new: ''
header.menu.search: ''
header.menu.my-gists: ''
header.menu.liked: ''
header.menu.admin: ''
header.menu.settings: ''
header.menu.logout: ''
header.menu.register: ''
header.menu.login: ''
header.menu.light: ''
header.menu.dark: ''
header.menu.system: ''
footer.powered-by: ''
pagination.older: ''
pagination.newer: ''
pagination.previous: ''
pagination.next: ''
admin.admin_panel: ''
admin.general: ''
admin.users: ''
admin.gists: ''
admin.configuration: ''
admin.invitations: ''
admin.invitations.create: ''
admin.versions: ''
admin.ssh_keys: ''
admin.stats: ''
admin.actions: ''
admin.actions.sync-fs: ''
admin.actions.sync-db: ''
admin.actions.git-gc: ''
admin.actions.sync-previews: ''
admin.actions.reset-hooks: ''
admin.actions.index-gists: ''
admin.actions.sync-gist-languages: ''
admin.id: ''
admin.user: ''
admin.delete: ''
admin.created_at: ''
admin.config-link: ''
admin.config-link-overriden: ''
admin.disable-signup: ''
admin.disable-signup_help: ''
admin.require-login: ''
admin.require-login_help: ''
admin.allow-gists-without-login: ''
admin.allow-gists-without-login_help: ''
admin.disable-login: ''
admin.disable-login_help: ''
admin.disable-gravatar: ''
admin.disable-gravatar_help: ''
admin.users.delete_confirm: ''
admin.gists.title: ''
admin.gists.private: ''
admin.gists.nb-files: ''
admin.gists.nb-likes: ''
admin.gists.delete_confirm: ''
admin.invitations.help: ''
admin.invitations.max_uses: ''
admin.invitations.expires_at: ''
admin.invitations.code: ''
admin.invitations.copy_link: ''
admin.invitations.uses: ''
admin.invitations.expired: ''
admin.invitations.delete_confirm: ''
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.admin.sync-gist-languages: ''
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.auth.passkey-registred: ''
flash.auth.passkey-deleted: ''
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-be-empty: ''
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: ''
validation.invalid-gist-topics: ''
html.title.admin-panel: ''

View File

@ -304,3 +304,4 @@ validation.not-enough: 'Nie wystarczająco %s'
validation.invalid: 'Niepoprawny %s'
html.title.admin-panel: 'Panel administracyjny'
admin.invitations.delete_confirm: Czy chcesz usunąć to zaproszenie?

View File

@ -294,3 +294,20 @@ auth.totp.code: 代码
auth.totp.submit: 提交
auth.totp.proceed: 继续
auth.totp.save-recovery-codes: 请将您的恢复代码保存在安全的地方。在您无法访问身份验证应用程序时,可以使用这些代码恢复访问。
admin.invitations.delete_confirm: 您想要删除此邀请吗?
gist.new.topics: 主题(用空格分隔)
validation.invalid-gist-topics: 无效的 Gists 主题它们必须以字母或数字开头长度不超过50个字符并且可以包含连字符
gist.list.topic-results-topic: '%s 与主题匹配的所有 Gists'
admin.actions.sync-gist-languages: 同步所有 gists 语言
flash.admin.sync-gist-languages: 正在同步 Gist 语言...
gist.list.topic-results: 所有匹配主题的 Gist
gist.search.help.topic: 具有给定主题的 Gists
gist.search.placeholder.title: 标题
gist.search.placeholder.visibility: 可见性
gist.search.placeholder.private: 私密
gist.search.placeholder.language: 语言
gist.search.placeholder.all: 所有
gist.search.placeholder.topics: 话题
gist.search.placeholder.search: 搜索
gist.search.placeholder.public: 公开
gist.search.placeholder.unlisted: 未列出的

View File

@ -10,37 +10,39 @@ import (
"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"
"strconv"
"sync/atomic"
)
var atomicIndexer atomic.Pointer[Indexer]
type Indexer struct {
Index bleve.Index
type BleveIndexer struct {
index bleve.Index
path string
}
func Enabled() bool {
return config.C.IndexEnabled
func NewBleveIndexer(path string) *BleveIndexer {
return &BleveIndexer{path: path}
}
func Init(indexFilename string) {
atomicIndexer.Store(&Indexer{Index: nil})
func (i *BleveIndexer) Init() error {
errChan := make(chan error, 1)
go func() {
bleveIndex, err := open(indexFilename)
bleveIndex, err := i.open()
if err != nil {
log.Error().Err(err).Msg("Failed to open index")
(*atomicIndexer.Load()).close()
log.Error().Err(err).Msg("Failed to open Bleve index")
i.Close()
errChan <- err
return
}
atomicIndexer.Store(&Indexer{Index: bleveIndex})
log.Info().Msg("Indexer initialized")
i.index = bleveIndex
log.Info().Msg("Bleve indexer initialized")
errChan <- nil
}()
return <-errChan
}
func open(indexFilename string) (bleve.Index, error) {
bleveIndex, err := bleve.Open(indexFilename)
func (i *BleveIndexer) open() (bleve.Index, error) {
bleveIndex, err := bleve.Open(i.path)
if err == nil {
return bleveIndex, nil
}
@ -73,67 +75,33 @@ func open(indexFilename string) (bleve.Index, error) {
docMapping.DefaultAnalyzer = "gistAnalyser"
return bleve.New(indexFilename, mapping)
return bleve.New(i.path, mapping)
}
func Close() {
(*atomicIndexer.Load()).close()
}
func (i *Indexer) close() {
if i == nil || i.Index == nil {
func (i *BleveIndexer) Close() {
if i == nil || i.index == nil {
return
}
err := i.Index.Close()
err := i.index.Close()
if err != nil {
log.Error().Err(err).Msg("Failed to close bleve index")
log.Error().Err(err).Msg("Failed to close Bleve index")
}
log.Info().Msg("Indexer closed")
atomicIndexer.Store(&Indexer{Index: nil})
log.Info().Msg("Bleve indexer closed")
}
func checkForIndexer() error {
if (*atomicIndexer.Load()).Index == nil {
return errors.New("indexer is not initialized")
}
return nil
}
func AddInIndex(gist *Gist) error {
if !Enabled() {
return nil
}
if err := checkForIndexer(); err != nil {
return err
}
func (i *BleveIndexer) Add(gist *Gist) error {
if gist == nil {
return errors.New("failed to add nil gist to index")
}
return (*atomicIndexer.Load()).Index.Index(strconv.Itoa(int(gist.GistID)), gist)
return (*atomicIndexer.Load()).(*BleveIndexer).index.Index(strconv.Itoa(int(gist.GistID)), gist)
}
func RemoveFromIndex(gistID uint) error {
if !Enabled() {
return nil
}
if err := checkForIndexer(); err != nil {
return err
}
return (*atomicIndexer.Load()).Index.Delete(strconv.Itoa(int(gistID)))
func (i *BleveIndexer) Remove(gistID uint) error {
return (*atomicIndexer.Load()).(*BleveIndexer).index.Delete(strconv.Itoa(int(gistID)))
}
func SearchGists(queryStr string, queryMetadata SearchGistMetadata, gistsIds []uint, page int) ([]uint, uint64, map[string]int, error) {
if !Enabled() {
return nil, 0, nil, nil
}
if err := checkForIndexer(); err != nil {
return nil, 0, nil, err
}
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
if queryStr != "" {
@ -145,17 +113,16 @@ func SearchGists(queryStr string, queryMetadata SearchGistMetadata, gistsIds []u
indexerQuery = contentQuery
}
repoQueries := make([]query.Query, 0, len(gistsIds))
privateQuery := bleve.NewBoolFieldQuery(false)
privateQuery.SetField("Private")
userIdMatch := float64(userId)
truee := true
for _, id := range gistsIds {
f := float64(id)
qq := bleve.NewNumericRangeInclusiveQuery(&f, &f, &truee, &truee)
qq.SetField("GistID")
repoQueries = append(repoQueries, qq)
}
userIdQuery := bleve.NewNumericRangeInclusiveQuery(&userIdMatch, &userIdMatch, &truee, &truee)
userIdQuery.SetField("UserID")
indexerQuery = bleve.NewConjunctionQuery(bleve.NewDisjunctionQuery(repoQueries...), indexerQuery)
accessQuery := bleve.NewDisjunctionQuery(privateQuery, userIdQuery)
indexerQuery = bleve.NewConjunctionQuery(accessQuery, indexerQuery)
addQuery := func(field, value string) {
if value != "" && value != "." {
@ -170,18 +137,19 @@ func SearchGists(queryStr string, queryMetadata SearchGistMetadata, gistsIds []u
addQuery("Extensions", "."+queryMetadata.Extension)
addQuery("Filenames", queryMetadata.Filename)
addQuery("Languages", queryMetadata.Language)
addQuery("Topics", queryMetadata.Topic)
languageFacet := bleve.NewFacetRequest("Languages", 10)
perPage := 10
offset := (page - 1) * perPage
s := bleve.NewSearchRequestOptions(indexerQuery, perPage, offset, false)
s := bleve.NewSearchRequestOptions(indexerQuery, perPage+1, offset, false)
s.AddFacet("languageFacet", languageFacet)
s.Fields = []string{"GistID"}
s.IncludeLocations = false
results, err := (*atomicIndexer.Load()).Index.Search(s)
results, err := (*atomicIndexer.Load()).(*BleveIndexer).index.Search(s)
if err != nil {
return nil, 0, nil, err
}

View File

@ -2,12 +2,15 @@ package index
type Gist struct {
GistID uint
UserID uint
Visibility uint
Username string
Title string
Content string
Filenames []string
Extensions []string
Languages []string
Topics []string
CreatedAt int64
UpdatedAt int64
}
@ -18,4 +21,5 @@ type SearchGistMetadata struct {
Filename string
Extension string
Language string
Topic string
}

138
internal/index/indexer.go Normal file
View File

@ -0,0 +1,138 @@
package index
import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"path/filepath"
"sync/atomic"
)
var atomicIndexer atomic.Pointer[Indexer]
type Indexer interface {
Init() error
Close()
Add(gist *Gist) error
Remove(gistID uint) error
Search(query string, metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error)
}
type IndexerType string
const (
Bleve IndexerType = "bleve"
Meilisearch IndexerType = "meilisearch"
None IndexerType = ""
)
func IndexType() IndexerType {
switch config.C.Index {
case "bleve":
return Bleve
case "meilisearch":
return Meilisearch
default:
return None
}
}
func IndexEnabled() bool {
switch config.C.Index {
case "bleve", "meilisearch":
return true
default:
return false
}
}
func NewIndexer(idxType IndexerType) {
if !IndexEnabled() {
return
}
atomicIndexer.Store(nil)
var idx Indexer
switch idxType {
case Bleve:
idx = NewBleveIndexer(filepath.Join(config.GetHomeDir(), "opengist.index"))
case Meilisearch:
idx = NewMeiliIndexer(config.C.MeiliHost, config.C.MeiliAPIKey, "opengist")
default:
log.Warn().Msgf("Failed to create indexer, unknown indexer type: %s", idxType)
return
}
if err := idx.Init(); err != nil {
return
}
atomicIndexer.Store(&idx)
}
func Close() {
if !IndexEnabled() {
return
}
idx := atomicIndexer.Load()
if idx == nil {
return
}
(*idx).Close()
atomicIndexer.Store(nil)
}
func AddInIndex(gist *Gist) error {
if !IndexEnabled() {
return nil
}
idx := atomicIndexer.Load()
if idx == nil {
return fmt.Errorf("indexer is not initialized")
}
return (*idx).Add(gist)
}
func RemoveFromIndex(gistID uint) error {
if !IndexEnabled() {
return nil
}
idx := atomicIndexer.Load()
if idx == nil {
return fmt.Errorf("indexer is not initialized")
}
return (*idx).Remove(gistID)
}
func SearchGists(query string, metadata SearchGistMetadata, userId uint, page int) ([]uint, uint64, map[string]int, error) {
if !IndexEnabled() {
return nil, 0, nil, nil
}
idx := atomicIndexer.Load()
if idx == nil {
return nil, 0, nil, fmt.Errorf("indexer is not initialized")
}
return (*idx).Search(query, metadata, userId, page)
}
func DepreactionIndexDirname() {
if config.C.IndexEnabled {
log.Warn().Msg("The 'index.enabled'/'OG_INDEX_ENABLED' configuration option is deprecated and will be removed in a future version. Please use 'index'/'OG_INDEX' instead.")
}
if config.C.Index == "" {
config.C.Index = "bleve"
}
if config.C.BleveDirname != "" {
log.Warn().Msg("The 'index.dirname'/'OG_INDEX_DIRNAME' configuration option is deprecated and will be removed in a future version.")
}
}

View File

@ -0,0 +1,151 @@
package index
import (
"errors"
"fmt"
"github.com/meilisearch/meilisearch-go"
"github.com/rs/zerolog/log"
"strconv"
"strings"
)
type MeiliIndexer struct {
client meilisearch.ServiceManager
index meilisearch.IndexManager
indexName string
host string
apikey string
}
func NewMeiliIndexer(host, apikey, indexName string) *MeiliIndexer {
return &MeiliIndexer{
host: host,
apikey: apikey,
indexName: indexName,
}
}
func (i *MeiliIndexer) Init() error {
errChan := make(chan error, 1)
go func() {
meiliIndex, err := i.open()
if err != nil {
log.Error().Err(err).Msg("Failed to open Meilisearch index")
i.Close()
errChan <- err
return
}
i.index = meiliIndex
log.Info().Msg("Meilisearch indexer initialized")
errChan <- nil
}()
return <-errChan
}
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 {
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", "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) Close() {
if i.client != nil {
i.client.Close()
log.Info().Msg("Meilisearch indexer closed")
}
i.client = nil
}
func (i *MeiliIndexer) Add(gist *Gist) error {
if gist == nil {
return errors.New("failed to add nil gist to index")
}
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.AddDocuments(gist, "GistID")
return err
}
func (i *MeiliIndexer) Remove(gistID uint) error {
_, err := (*atomicIndexer.Load()).(*MeiliIndexer).index.DeleteDocument(strconv.Itoa(int(gistID)))
return err
}
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"},
}
var filters []string
filters = append(filters, fmt.Sprintf("(Visibility = 0 OR UserID = %d)", userId))
addFilter := func(field, value string) {
if value != "" && value != "." {
filters = append(filters, fmt.Sprintf("%s = \"%s\"", field, escapeFilterValue(value)))
}
}
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(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 gistID, ok := hit.(map[string]interface{})["GistID"].(float64); ok {
gistIds = append(gistIds, uint(gistID))
}
}
languageCounts := make(map[string]int)
if facets, ok := response.FacetDistribution.(map[string]interface{})["Languages"]; ok {
for language, count := range facets.(map[string]interface{}) {
if countValue, ok := count.(float64); ok {
languageCounts[language] = int(countValue)
}
}
}
return gistIds, uint64(response.EstimatedTotalHits), languageCounts, nil
}
func escapeFilterValue(value string) string {
escaped := strings.ReplaceAll(value, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
return escaped
}

View File

@ -1,72 +0,0 @@
package memdb
import "github.com/hashicorp/go-memdb"
import ogdb "github.com/thomiceli/opengist/internal/db"
var db *memdb.MemDB
type GistInit struct {
UserID uint
Gist *ogdb.Gist
}
func Setup() error {
var err error
schema := &memdb.DBSchema{
Tables: map[string]*memdb.TableSchema{
"gist_init": {
Name: "gist_init",
Indexes: map[string]*memdb.IndexSchema{
"id": {
Name: "id",
Unique: true,
Indexer: &memdb.UintFieldIndex{Field: "UserID"},
},
},
},
},
}
db, err = memdb.NewMemDB(schema)
if err != nil {
return err
}
return nil
}
func InsertGistInit(userId uint, gist *ogdb.Gist) error {
txn := db.Txn(true)
if err := txn.Insert("gist_init", &GistInit{
UserID: userId,
Gist: gist,
}); err != nil {
txn.Abort()
return err
}
txn.Commit()
return nil
}
func GetGistInitAndDelete(userId uint) (*GistInit, error) {
txn := db.Txn(true)
defer txn.Abort()
raw, err := txn.First("gist_init", "id", userId)
if err != nil {
return nil, err
}
if raw == nil {
return nil, nil
}
gistInit := raw.(*GistInit)
if err := txn.Delete("gist_init", gistInit); err != nil {
return nil, err
}
txn.Commit()
return gistInit, nil
}

View File

@ -12,15 +12,18 @@ import (
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
"go.abhg.dev/goldmark/mermaid"
"regexp"
)
func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
var buf bytes.Buffer
err := newMarkdown().Convert([]byte(gist.Preview), &buf)
// remove links in Markdown Preview, quick fix for now
re := regexp.MustCompile(`<a\b[^>]*>(.*?)</a>`)
return RenderedGist{
Gist: gist,
HTML: buf.String(),
HTML: re.ReplaceAllString(buf.String(), `$1`),
}, err
}

View File

@ -1,4 +1,4 @@
package utils
package session
import (
"github.com/gorilla/securecookie"

View File

@ -1,13 +0,0 @@
package utils
func RemoveDuplicates[T string | int](sliceList []T) []T {
allKeys := make(map[T]bool)
list := []T{}
for _, item := range sliceList {
if _, value := allKeys[item]; !value {
allKeys[item] = true
list = append(list, item)
}
}
return list
}

View File

@ -1,4 +1,4 @@
package utils
package validator
import (
"github.com/go-playground/validator/v10"
@ -16,6 +16,7 @@ func NewValidator() *OpengistValidator {
_ = v.RegisterValidation("notreserved", validateReservedKeywords)
_ = v.RegisterValidation("alphanumdash", validateAlphaNumDash)
_ = v.RegisterValidation("alphanumdashorempty", validateAlphaNumDashOrEmpty)
_ = v.RegisterValidation("gisttopics", validateGistTopics)
return &OpengistValidator{v}
}
@ -40,13 +41,14 @@ func ValidationMessages(err *error, locale *i18n.Locale) string {
messages[i] = locale.String("validation.should-not-include-sub-directory", e.Field())
case "alphanum":
messages[i] = locale.String("validation.should-only-contain-alphanumeric-characters", e.Field())
case "alphanumdash":
case "alphanumdashorempty":
case "alphanumdash", "alphanumdashorempty":
messages[i] = locale.String("validation.should-only-contain-alphanumeric-characters-and-dashes", e.Field())
case "min":
messages[i] = locale.String("validation.not-enough", e.Field())
case "notreserved":
messages[i] = locale.String("validation.invalid", e.Field())
case "gisttopics":
messages[i] = locale.String("validation.invalid-gist-topics")
}
}
@ -73,3 +75,27 @@ func validateAlphaNumDash(fl validator.FieldLevel) bool {
func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool {
return regexp.MustCompile(`^$|^[a-zA-Z0-9-]+$`).MatchString(fl.Field().String())
}
func validateGistTopics(fl validator.FieldLevel) bool {
topicsInput := fl.Field().String()
if topicsInput == "" {
return true
}
topics := strings.Fields(topicsInput)
if len(topics) > 10 {
return false
}
for _, tag := range topics {
if len(tag) > 50 {
return false
}
if !regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(tag) {
return false
}
}
return true
}

View File

@ -1,236 +0,0 @@
package web
import (
"github.com/labstack/echo/v4"
"github.com/thomiceli/opengist/internal/actions"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"runtime"
"strconv"
"time"
)
func adminIndex(ctx echo.Context) error {
setData(ctx, "htmlTitle", trH(ctx, "admin.admin_panel"))
setData(ctx, "adminHeaderPage", "index")
setData(ctx, "opengistVersion", config.OpengistVersion)
setData(ctx, "goVersion", runtime.Version())
gitVersion, err := git.GetGitVersion()
if err != nil {
return errorRes(500, "Cannot get git version", err)
}
setData(ctx, "gitVersion", gitVersion)
countUsers, err := db.CountAll(&db.User{})
if err != nil {
return errorRes(500, "Cannot count users", err)
}
setData(ctx, "countUsers", countUsers)
countGists, err := db.CountAll(&db.Gist{})
if err != nil {
return errorRes(500, "Cannot count gists", err)
}
setData(ctx, "countGists", countGists)
countKeys, err := db.CountAll(&db.SSHKey{})
if err != nil {
return errorRes(500, "Cannot count SSH keys", err)
}
setData(ctx, "countKeys", countKeys)
setData(ctx, "syncReposFromFS", actions.IsRunning(actions.SyncReposFromFS))
setData(ctx, "syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB))
setData(ctx, "gitGcRepos", actions.IsRunning(actions.GitGcRepos))
setData(ctx, "syncGistPreviews", actions.IsRunning(actions.SyncGistPreviews))
setData(ctx, "resetHooks", actions.IsRunning(actions.ResetHooks))
setData(ctx, "indexGists", actions.IsRunning(actions.IndexGists))
return html(ctx, "admin_index.html")
}
func adminUsers(ctx echo.Context) error {
setData(ctx, "htmlTitle", trH(ctx, "admin.users")+" - "+trH(ctx, "admin.admin_panel"))
setData(ctx, "adminHeaderPage", "users")
pageInt := getPage(ctx)
var data []*db.User
var err error
if data, err = db.GetAllUsers(pageInt - 1); err != nil {
return errorRes(500, "Cannot get users", err)
}
if err = paginate(ctx, data, pageInt, 10, "data", "admin-panel/users", 1); err != nil {
return errorRes(404, tr(ctx, "error.page-not-found"), nil)
}
return html(ctx, "admin_users.html")
}
func adminGists(ctx echo.Context) error {
setData(ctx, "htmlTitle", trH(ctx, "admin.gists")+" - "+trH(ctx, "admin.admin_panel"))
setData(ctx, "adminHeaderPage", "gists")
pageInt := getPage(ctx)
var data []*db.Gist
var err error
if data, err = db.GetAllGists(pageInt - 1); err != nil {
return errorRes(500, "Cannot get gists", err)
}
if err = paginate(ctx, data, pageInt, 10, "data", "admin-panel/gists", 1); err != nil {
return errorRes(404, tr(ctx, "error.page-not-found"), nil)
}
return html(ctx, "admin_gists.html")
}
func adminUserDelete(ctx echo.Context) error {
userId, _ := strconv.ParseUint(ctx.Param("user"), 10, 64)
user, err := db.GetUserById(uint(userId))
if err != nil {
return errorRes(500, "Cannot retrieve user", err)
}
if err := user.Delete(); err != nil {
return errorRes(500, "Cannot delete this user", err)
}
addFlash(ctx, tr(ctx, "flash.admin.user-deleted"), "success")
return redirect(ctx, "/admin-panel/users")
}
func adminGistDelete(ctx echo.Context) error {
gist, err := db.GetGistByID(ctx.Param("gist"))
if err != nil {
return errorRes(500, "Cannot retrieve gist", err)
}
if err = gist.DeleteRepository(); err != nil {
return errorRes(500, "Cannot delete the repository", err)
}
if err = gist.Delete(); err != nil {
return errorRes(500, "Cannot delete this gist", err)
}
gist.RemoveFromIndex()
addFlash(ctx, tr(ctx, "flash.admin.gist-deleted"), "success")
return redirect(ctx, "/admin-panel/gists")
}
func adminSyncReposFromFS(ctx echo.Context) error {
addFlash(ctx, tr(ctx, "flash.admin.sync-fs"), "success")
go actions.Run(actions.SyncReposFromFS)
return redirect(ctx, "/admin-panel")
}
func adminSyncReposFromDB(ctx echo.Context) error {
addFlash(ctx, tr(ctx, "flash.admin.sync-db"), "success")
go actions.Run(actions.SyncReposFromDB)
return redirect(ctx, "/admin-panel")
}
func adminGcRepos(ctx echo.Context) error {
addFlash(ctx, tr(ctx, "flash.admin.git-gc"), "success")
go actions.Run(actions.GitGcRepos)
return redirect(ctx, "/admin-panel")
}
func adminSyncGistPreviews(ctx echo.Context) error {
addFlash(ctx, tr(ctx, "flash.admin.sync-previews"), "success")
go actions.Run(actions.SyncGistPreviews)
return redirect(ctx, "/admin-panel")
}
func adminResetHooks(ctx echo.Context) error {
addFlash(ctx, tr(ctx, "flash.admin.reset-hooks"), "success")
go actions.Run(actions.ResetHooks)
return redirect(ctx, "/admin-panel")
}
func adminIndexGists(ctx echo.Context) error {
addFlash(ctx, tr(ctx, "flash.admin.index-gists"), "success")
go actions.Run(actions.IndexGists)
return redirect(ctx, "/admin-panel")
}
func adminConfig(ctx echo.Context) error {
setData(ctx, "htmlTitle", trH(ctx, "admin.configuration")+" - "+trH(ctx, "admin.admin_panel"))
setData(ctx, "adminHeaderPage", "config")
setData(ctx, "dbtype", db.DatabaseInfo.Type.String())
setData(ctx, "dbname", db.DatabaseInfo.Database)
return html(ctx, "admin_config.html")
}
func adminSetConfig(ctx echo.Context) error {
key := ctx.FormValue("key")
value := ctx.FormValue("value")
if err := db.UpdateSetting(key, value); err != nil {
return errorRes(500, "Cannot set setting", err)
}
return ctx.JSON(200, map[string]interface{}{
"success": true,
})
}
func adminInvitations(ctx echo.Context) error {
setData(ctx, "htmlTitle", trH(ctx, "admin.invitations")+" - "+trH(ctx, "admin.admin_panel"))
setData(ctx, "adminHeaderPage", "invitations")
var invitations []*db.Invitation
var err error
if invitations, err = db.GetAllInvitations(); err != nil {
return errorRes(500, "Cannot get invites", err)
}
setData(ctx, "invitations", invitations)
return html(ctx, "admin_invitations.html")
}
func adminInvitationsCreate(ctx echo.Context) error {
code := ctx.FormValue("code")
nbMax, err := strconv.ParseUint(ctx.FormValue("nbMax"), 10, 64)
if err != nil {
nbMax = 10
}
expiresAtUnix, err := strconv.ParseInt(ctx.FormValue("expiredAtUnix"), 10, 64)
if err != nil {
expiresAtUnix = time.Now().Unix() + 604800 // 1 week
}
invitation := &db.Invitation{
Code: code,
ExpiresAt: expiresAtUnix,
NbMax: uint(nbMax),
}
if err := invitation.Create(); err != nil {
return errorRes(500, "Cannot create invitation", err)
}
addFlash(ctx, tr(ctx, "flash.admin.invitation-created"), "success")
return redirect(ctx, "/admin-panel/invitations")
}
func adminInvitationsDelete(ctx echo.Context) error {
id, _ := strconv.ParseUint(ctx.Param("id"), 10, 64)
invitation, err := db.GetInvitationByID(uint(id))
if err != nil {
return errorRes(500, "Cannot retrieve invitation", err)
}
if err := invitation.Delete(); err != nil {
return errorRes(500, "Cannot delete this invitation", err)
}
addFlash(ctx, tr(ctx, "flash.admin.invitation-deleted"), "success")
return redirect(ctx, "/admin-panel/invitations")
}

View File

@ -1,815 +0,0 @@
package web
import (
"bytes"
"context"
"crypto/md5"
gojson "encoding/json"
"errors"
"fmt"
"github.com/labstack/echo/v4"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/gitea"
"github.com/markbates/goth/providers/github"
"github.com/markbates/goth/providers/gitlab"
"github.com/markbates/goth/providers/openidConnect"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/totp"
"github.com/thomiceli/opengist/internal/auth/webauthn"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/utils"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gorm.io/gorm"
"io"
"net/http"
"net/url"
"strings"
)
const (
GitHubProvider = "github"
GitLabProvider = "gitlab"
GiteaProvider = "gitea"
OpenIDConnect = "openid-connect"
)
func register(ctx echo.Context) error {
disableSignup := getData(ctx, "DisableSignup")
disableForm := getData(ctx, "DisableLoginForm")
code := ctx.QueryParam("code")
if code != "" {
if invitation, err := db.GetInvitationByCode(code); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errorRes(500, "Cannot check for invitation code", err)
} else if invitation != nil && invitation.IsUsable() {
disableSignup = false
}
}
setData(ctx, "title", trH(ctx, "auth.new-account"))
setData(ctx, "htmlTitle", trH(ctx, "auth.new-account"))
setData(ctx, "disableForm", disableForm)
setData(ctx, "disableSignup", disableSignup)
setData(ctx, "isLoginPage", false)
return html(ctx, "auth_form.html")
}
func processRegister(ctx echo.Context) error {
disableSignup := getData(ctx, "DisableSignup")
code := ctx.QueryParam("code")
invitation, err := db.GetInvitationByCode(code)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errorRes(500, "Cannot check for invitation code", err)
} else if invitation.ID != 0 && invitation.IsUsable() {
disableSignup = false
}
if disableSignup == true {
return errorRes(403, tr(ctx, "error.signup-disabled"), nil)
}
if getData(ctx, "DisableLoginForm") == true {
return errorRes(403, tr(ctx, "error.signup-disabled-form"), nil)
}
setData(ctx, "title", trH(ctx, "auth.new-account"))
setData(ctx, "htmlTitle", trH(ctx, "auth.new-account"))
sess := getSession(ctx)
dto := new(db.UserDTO)
if err := ctx.Bind(dto); err != nil {
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
}
if err := ctx.Validate(dto); err != nil {
addFlash(ctx, utils.ValidationMessages(&err, getData(ctx, "locale").(*i18n.Locale)), "error")
return html(ctx, "auth_form.html")
}
if exists, err := db.UserExists(dto.Username); err != nil || exists {
addFlash(ctx, tr(ctx, "flash.auth.username-exists"), "error")
return html(ctx, "auth_form.html")
}
user := dto.ToUser()
password, err := utils.Argon2id.Hash(user.Password)
if err != nil {
return errorRes(500, "Cannot hash password", err)
}
user.Password = password
if err = user.Create(); err != nil {
return errorRes(500, "Cannot create user", err)
}
if user.ID == 1 {
if err = user.SetAdmin(); err != nil {
return errorRes(500, "Cannot set user admin", err)
}
}
if invitation.ID != 0 {
if err := invitation.Use(); err != nil {
return errorRes(500, "Cannot use invitation", err)
}
}
sess.Values["user"] = user.ID
saveSession(sess, ctx)
return redirect(ctx, "/")
}
func login(ctx echo.Context) error {
setData(ctx, "title", trH(ctx, "auth.login"))
setData(ctx, "htmlTitle", trH(ctx, "auth.login"))
setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
setData(ctx, "isLoginPage", true)
return html(ctx, "auth_form.html")
}
func processLogin(ctx echo.Context) error {
if getData(ctx, "DisableLoginForm") == true {
return errorRes(403, tr(ctx, "error.login-disabled-form"), nil)
}
var err error
sess := getSession(ctx)
dto := &db.UserDTO{}
if err = ctx.Bind(dto); err != nil {
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
}
password := dto.Password
var user *db.User
if user, err = db.GetUserByUsername(dto.Username); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return errorRes(500, "Cannot get user", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
addFlash(ctx, tr(ctx, "flash.auth.invalid-credentials"), "error")
return redirect(ctx, "/login")
}
if ok, err := utils.Argon2id.Verify(password, user.Password); !ok {
if err != nil {
return errorRes(500, "Cannot check for password", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
addFlash(ctx, tr(ctx, "flash.auth.invalid-credentials"), "error")
return redirect(ctx, "/login")
}
// handle MFA
var hasWebauthn, hasTotp bool
if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
return errorRes(500, "Cannot check for user MFA", err)
}
if hasWebauthn || hasTotp {
sess.Values["mfaID"] = user.ID
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return redirect(ctx, "/mfa")
}
sess.Values["user"] = user.ID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
saveSession(sess, ctx)
deleteCsrfCookie(ctx)
return redirect(ctx, "/")
}
func mfa(ctx echo.Context) error {
var err error
user := db.User{ID: getSession(ctx).Values["mfaID"].(uint)}
var hasWebauthn, hasTotp bool
if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
return errorRes(500, "Cannot check for user MFA", err)
}
setData(ctx, "hasWebauthn", hasWebauthn)
setData(ctx, "hasTotp", hasTotp)
return html(ctx, "mfa.html")
}
func oauthCallback(ctx echo.Context) error {
user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request())
if err != nil {
return errorRes(400, tr(ctx, "error.complete-oauth-login", err.Error()), err)
}
currUser := getUserLogged(ctx)
if currUser != nil {
// if user is logged in, link account to user and update its avatar URL
updateUserProviderInfo(currUser, user.Provider, user)
if err = currUser.Update(); err != nil {
return errorRes(500, "Cannot update user "+cases.Title(language.English).String(user.Provider)+" id", err)
}
addFlash(ctx, tr(ctx, "flash.auth.account-linked-oauth", cases.Title(language.English).String(user.Provider)), "success")
return redirect(ctx, "/settings")
}
// if user is not in database, create it
userDB, err := db.GetUserByProvider(user.UserID, user.Provider)
if err != nil {
if getData(ctx, "DisableSignup") == true {
return errorRes(403, tr(ctx, "error.signup-disabled"), nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return errorRes(500, "Cannot get user", err)
}
if user.NickName == "" {
user.NickName = strings.Split(user.Email, "@")[0]
}
userDB = &db.User{
Username: user.NickName,
Email: user.Email,
MD5Hash: fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(user.Email))))),
}
// set provider id and avatar URL
updateUserProviderInfo(userDB, user.Provider, user)
if err = userDB.Create(); err != nil {
if db.IsUniqueConstraintViolation(err) {
addFlash(ctx, tr(ctx, "flash.auth.username-exists"), "error")
return redirect(ctx, "/login")
}
return errorRes(500, "Cannot create user", err)
}
if userDB.ID == 1 {
if err = userDB.SetAdmin(); err != nil {
return errorRes(500, "Cannot set user admin", err)
}
}
var resp *http.Response
switch user.Provider {
case GitHubProvider:
resp, err = http.Get("https://github.com/" + user.NickName + ".keys")
case GitLabProvider:
resp, err = http.Get(urlJoin(config.C.GitlabUrl, user.NickName+".keys"))
case GiteaProvider:
resp, err = http.Get(urlJoin(config.C.GiteaUrl, user.NickName+".keys"))
case OpenIDConnect:
err = errors.New("cannot get keys from OIDC provider")
}
if err == nil {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
addFlash(ctx, tr(ctx, "flash.auth.user-sshkeys-not-retrievable"), "error")
log.Error().Err(err).Msg("Could not get user keys")
}
keys := strings.Split(string(body), "\n")
if len(keys[len(keys)-1]) == 0 {
keys = keys[:len(keys)-1]
}
for _, key := range keys {
sshKey := db.SSHKey{
Title: "Added from " + user.Provider,
Content: key,
User: *userDB,
}
if err = sshKey.Create(); err != nil {
addFlash(ctx, tr(ctx, "flash.auth.user-sshkeys-not-created"), "error")
log.Error().Err(err).Msg("Could not create ssh key")
}
}
}
}
sess := getSession(ctx)
sess.Values["user"] = userDB.ID
saveSession(sess, ctx)
deleteCsrfCookie(ctx)
return redirect(ctx, "/")
}
func oauth(ctx echo.Context) error {
provider := ctx.Param("provider")
httpProtocol := "http"
if ctx.Request().TLS != nil || ctx.Request().Header.Get("X-Forwarded-Proto") == "https" {
httpProtocol = "https"
}
forwarded_hdr := ctx.Request().Header.Get("Forwarded")
if forwarded_hdr != "" {
fields := strings.Split(forwarded_hdr, ";")
fwd := make(map[string]string)
for _, v := range fields {
p := strings.Split(v, "=")
fwd[p[0]] = p[1]
}
val, ok := fwd["proto"]
if ok && val == "https" {
httpProtocol = "https"
}
}
var opengistUrl string
if config.C.ExternalUrl != "" {
opengistUrl = config.C.ExternalUrl
} else {
opengistUrl = httpProtocol + "://" + ctx.Request().Host
}
switch provider {
case GitHubProvider:
goth.UseProviders(
github.New(
config.C.GithubClientKey,
config.C.GithubSecret,
urlJoin(opengistUrl, "/oauth/github/callback"),
),
)
case GitLabProvider:
goth.UseProviders(
gitlab.NewCustomisedURL(
config.C.GitlabClientKey,
config.C.GitlabSecret,
urlJoin(opengistUrl, "/oauth/gitlab/callback"),
urlJoin(config.C.GitlabUrl, "/oauth/authorize"),
urlJoin(config.C.GitlabUrl, "/oauth/token"),
urlJoin(config.C.GitlabUrl, "/api/v4/user"),
),
)
case GiteaProvider:
goth.UseProviders(
gitea.NewCustomisedURL(
config.C.GiteaClientKey,
config.C.GiteaSecret,
urlJoin(opengistUrl, "/oauth/gitea/callback"),
urlJoin(config.C.GiteaUrl, "/login/oauth/authorize"),
urlJoin(config.C.GiteaUrl, "/login/oauth/access_token"),
urlJoin(config.C.GiteaUrl, "/api/v1/user"),
),
)
case OpenIDConnect:
oidcProvider, err := openidConnect.New(
config.C.OIDCClientKey,
config.C.OIDCSecret,
urlJoin(opengistUrl, "/oauth/openid-connect/callback"),
config.C.OIDCDiscoveryUrl,
"openid",
"email",
"profile",
)
if err != nil {
return errorRes(500, "Cannot create OIDC provider", err)
}
goth.UseProviders(oidcProvider)
}
ctxValue := context.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, provider)
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
if provider != GitHubProvider && provider != GitLabProvider && provider != GiteaProvider && provider != OpenIDConnect {
return errorRes(400, tr(ctx, "error.oauth-unsupported"), nil)
}
gothic.BeginAuthHandler(ctx.Response(), ctx.Request())
return nil
}
func oauthUnlink(ctx echo.Context) error {
provider := ctx.Param("provider")
currUser := getUserLogged(ctx)
// Map each provider to a function that checks the relevant ID in currUser
providerIDCheckMap := map[string]func() bool{
GitHubProvider: func() bool { return currUser.GithubID != "" },
GitLabProvider: func() bool { return currUser.GitlabID != "" },
GiteaProvider: func() bool { return currUser.GiteaID != "" },
OpenIDConnect: func() bool { return currUser.OIDCID != "" },
}
if checkFunc, exists := providerIDCheckMap[provider]; exists && checkFunc() {
if err := currUser.DeleteProviderID(provider); err != nil {
return errorRes(500, "Cannot unlink account from "+cases.Title(language.English).String(provider), err)
}
addFlash(ctx, tr(ctx, "flash.auth.account-unlinked-oauth", cases.Title(language.English).String(provider)), "success")
return redirect(ctx, "/settings")
}
return redirect(ctx, "/settings")
}
func beginWebAuthnBinding(ctx echo.Context) error {
credsCreation, jsonWaSession, err := webauthn.BeginBinding(getUserLogged(ctx))
if err != nil {
return errorRes(500, "Cannot begin WebAuthn registration", err)
}
sess := getSession(ctx)
sess.Values["webauthn_registration_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return ctx.JSON(200, credsCreation)
}
func finishWebAuthnBinding(ctx echo.Context) error {
sess := getSession(ctx)
jsonWaSession, ok := sess.Values["webauthn_registration_session"].([]byte)
if !ok {
return jsonErrorRes(401, "Cannot get WebAuthn registration session", nil)
}
user := getUserLogged(ctx)
// extract passkey name from request
body, err := io.ReadAll(ctx.Request().Body)
if err != nil {
return jsonErrorRes(400, "Failed to read request body", err)
}
ctx.Request().Body.Close()
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(body))
dto := new(db.CrendentialDTO)
_ = gojson.Unmarshal(body, &dto)
if err = ctx.Validate(dto); err != nil {
return jsonErrorRes(400, "Invalid request", err)
}
passkeyName := dto.PasskeyName
if passkeyName == "" {
passkeyName = "WebAuthn"
}
waCredential, err := webauthn.FinishBinding(user, jsonWaSession, ctx.Request())
if err != nil {
return jsonErrorRes(403, "Failed binding attempt for passkey", err)
}
if _, err = db.CreateFromCrendential(user.ID, passkeyName, waCredential); err != nil {
return jsonErrorRes(500, "Cannot create WebAuthn credential on database", err)
}
delete(sess.Values, "webauthn_registration_session")
saveSession(sess, ctx)
addFlash(ctx, tr(ctx, "flash.auth.passkey-registred", passkeyName), "success")
return json(ctx, []string{"OK"})
}
func beginWebAuthnLogin(ctx echo.Context) error {
credsCreation, jsonWaSession, err := webauthn.BeginDiscoverableLogin()
if err != nil {
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
}
sess := getSession(ctx)
sess.Values["webauthn_login_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return json(ctx, credsCreation)
}
func finishWebAuthnLogin(ctx echo.Context) error {
sess := getSession(ctx)
sessionData, ok := sess.Values["webauthn_login_session"].([]byte)
if !ok {
return jsonErrorRes(401, "Cannot get WebAuthn login session", nil)
}
userID, err := webauthn.FinishDiscoverableLogin(sessionData, ctx.Request())
if err != nil {
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
}
sess.Values["user"] = userID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "webauthn_login_session")
saveSession(sess, ctx)
return json(ctx, []string{"OK"})
}
func beginWebAuthnAssertion(ctx echo.Context) error {
sess := getSession(ctx)
ogUser, err := db.GetUserById(sess.Values["mfaID"].(uint))
if err != nil {
return jsonErrorRes(500, "Cannot get user", err)
}
credsCreation, jsonWaSession, err := webauthn.BeginLogin(ogUser)
if err != nil {
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
}
sess.Values["webauthn_assertion_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return json(ctx, credsCreation)
}
func finishWebAuthnAssertion(ctx echo.Context) error {
sess := getSession(ctx)
sessionData, ok := sess.Values["webauthn_assertion_session"].([]byte)
if !ok {
return jsonErrorRes(401, "Cannot get WebAuthn assertion session", nil)
}
userId := sess.Values["mfaID"].(uint)
ogUser, err := db.GetUserById(userId)
if err != nil {
return jsonErrorRes(500, "Cannot get user", err)
}
if err = webauthn.FinishLogin(ogUser, sessionData, ctx.Request()); err != nil {
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
}
sess.Values["user"] = userId
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "webauthn_assertion_session")
delete(sess.Values, "mfaID")
saveSession(sess, ctx)
return json(ctx, []string{"OK"})
}
func beginTotp(ctx echo.Context) error {
user := getUserLogged(ctx)
if _, hasTotp, err := user.HasMFA(); err != nil {
return errorRes(500, "Cannot check for user MFA", err)
} else if hasTotp {
addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error")
return redirect(ctx, "/settings")
}
ogUrl, err := url.Parse(getData(ctx, "baseHttpUrl").(string))
if err != nil {
return errorRes(500, "Cannot parse base URL", err)
}
sess := getSession(ctx)
generatedSecret, _ := sess.Values["generatedSecret"].([]byte)
totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(getUserLogged(ctx).Username, ogUrl.Hostname(), generatedSecret)
if err != nil {
return errorRes(500, "Cannot generate TOTP QR code", err)
}
sess.Values["totpSecret"] = totpSecret
sess.Values["generatedSecret"] = generatedSecret
saveSession(sess, ctx)
setData(ctx, "totpSecret", totpSecret)
setData(ctx, "totpQrcode", qrcode)
return html(ctx, "totp.html")
}
func finishTotp(ctx echo.Context) error {
user := getUserLogged(ctx)
if _, hasTotp, err := user.HasMFA(); err != nil {
return errorRes(500, "Cannot check for user MFA", err)
} else if hasTotp {
addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error")
return redirect(ctx, "/settings")
}
dto := &db.TOTPDTO{}
if err := ctx.Bind(dto); err != nil {
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
}
if err := ctx.Validate(dto); err != nil {
addFlash(ctx, "Invalid secret", "error")
return redirect(ctx, "/settings/totp/generate")
}
sess := getSession(ctx)
secret, ok := sess.Values["totpSecret"].(string)
if !ok {
return errorRes(500, "Cannot get TOTP secret from session", nil)
}
if !totp.Validate(dto.Code, secret) {
addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
return redirect(ctx, "/settings/totp/generate")
}
userTotp := &db.TOTP{
UserID: getUserLogged(ctx).ID,
}
if err := userTotp.StoreSecret(secret); err != nil {
return errorRes(500, "Cannot store TOTP secret", err)
}
if err := userTotp.Create(); err != nil {
return errorRes(500, "Cannot create TOTP", err)
}
addFlash(ctx, "TOTP successfully enabled", "success")
codes, err := userTotp.GenerateRecoveryCodes()
if err != nil {
return errorRes(500, "Cannot generate recovery codes", err)
}
delete(sess.Values, "totpSecret")
delete(sess.Values, "generatedSecret")
saveSession(sess, ctx)
setData(ctx, "recoveryCodes", codes)
return html(ctx, "totp.html")
}
func assertTotp(ctx echo.Context) error {
var err error
dto := &db.TOTPDTO{}
if err := ctx.Bind(dto); err != nil {
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
}
if err := ctx.Validate(dto); err != nil {
addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
return redirect(ctx, "/mfa")
}
sess := getSession(ctx)
userId := sess.Values["mfaID"].(uint)
var userTotp *db.TOTP
if userTotp, err = db.GetTOTPByUserID(userId); err != nil {
return errorRes(500, "Cannot get TOTP by UID", err)
}
redirectUrl := "/"
var validCode, validRecoveryCode bool
if validCode, err = userTotp.ValidateCode(dto.Code); err != nil {
return errorRes(500, "Cannot validate TOTP code", err)
}
if !validCode {
validRecoveryCode, err = userTotp.ValidateRecoveryCode(dto.Code)
if err != nil {
return errorRes(500, "Cannot validate TOTP code", err)
}
if !validRecoveryCode {
addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
return redirect(ctx, "/mfa")
}
addFlash(ctx, tr(ctx, "auth.totp.code-used", dto.Code), "warning")
redirectUrl = "/settings"
}
sess.Values["user"] = userId
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "mfaID")
saveSession(sess, ctx)
return redirect(ctx, redirectUrl)
}
func disableTotp(ctx echo.Context) error {
user := getUserLogged(ctx)
userTotp, err := db.GetTOTPByUserID(user.ID)
if err != nil {
return errorRes(500, "Cannot get TOTP by UID", err)
}
if err = userTotp.Delete(); err != nil {
return errorRes(500, "Cannot delete TOTP", err)
}
addFlash(ctx, tr(ctx, "auth.totp.disabled"), "success")
return redirect(ctx, "/settings")
}
func regenerateTotpRecoveryCodes(ctx echo.Context) error {
user := getUserLogged(ctx)
userTotp, err := db.GetTOTPByUserID(user.ID)
if err != nil {
return errorRes(500, "Cannot get TOTP by UID", err)
}
codes, err := userTotp.GenerateRecoveryCodes()
if err != nil {
return errorRes(500, "Cannot generate recovery codes", err)
}
setData(ctx, "recoveryCodes", codes)
return html(ctx, "totp.html")
}
func logout(ctx echo.Context) error {
deleteSession(ctx)
deleteCsrfCookie(ctx)
return redirect(ctx, "/all")
}
func urlJoin(base string, elem ...string) string {
joined, err := url.JoinPath(base, elem...)
if err != nil {
log.Error().Err(err).Msg("Cannot join url")
}
return joined
}
func updateUserProviderInfo(userDB *db.User, provider string, user goth.User) {
userDB.AvatarURL = getAvatarUrlFromProvider(provider, user.UserID)
switch provider {
case GitHubProvider:
userDB.GithubID = user.UserID
case GitLabProvider:
userDB.GitlabID = user.UserID
case GiteaProvider:
userDB.GiteaID = user.UserID
case OpenIDConnect:
userDB.OIDCID = user.UserID
userDB.AvatarURL = user.AvatarURL
}
}
func getAvatarUrlFromProvider(provider string, identifier string) string {
switch provider {
case GitHubProvider:
return "https://avatars.githubusercontent.com/u/" + identifier + "?v=4"
case GitLabProvider:
return urlJoin(config.C.GitlabUrl, "/uploads/-/system/user/avatar/", identifier, "/avatar.png") + "?width=400"
case GiteaProvider:
resp, err := http.Get(urlJoin(config.C.GiteaUrl, "/api/v1/users/", identifier))
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 ""
}
return field.(string)
}
return ""
}
type ContextAuthInfo struct {
context echo.Context
}
func (auth ContextAuthInfo) RequireLogin() (bool, error) {
return getData(auth.context, "RequireLogin") == true, nil
}
func (auth ContextAuthInfo) AllowGistsWithoutLogin() (bool, error) {
return getData(auth.context, "AllowGistsWithoutLogin") == true, nil
}

View File

@ -0,0 +1,148 @@
package context
import (
"context"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"html/template"
"net/http"
"sync"
)
type dataKey string
const DataKeyStr dataKey = "data"
type Context struct {
echo.Context
data echo.Map
lock sync.RWMutex
store *Store
User *db.User
}
func NewContext(c echo.Context, sessionPath string) *Context {
ctx := &Context{
Context: c,
data: make(echo.Map),
store: NewStore(sessionPath),
}
ctx.SetRequest(ctx.Request().WithContext(context.WithValue(ctx.Request().Context(), DataKeyStr, ctx.data)))
return ctx
}
func (ctx *Context) SetData(key string, value any) {
ctx.lock.Lock()
defer ctx.lock.Unlock()
ctx.data[key] = value
}
func (ctx *Context) GetData(key string) any {
ctx.lock.RLock()
defer ctx.lock.RUnlock()
return ctx.data[key]
}
func (ctx *Context) DataMap() echo.Map {
return ctx.data
}
func (ctx *Context) ErrorRes(code int, message string, err error) error {
if code >= 500 {
var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
skipLogger.Error().Err(err).Msg(message)
}
ctx.SetRequest(ctx.Request().WithContext(context.WithValue(ctx.Request().Context(), DataKeyStr, ctx.data)))
return &echo.HTTPError{Code: code, Message: message, Internal: err}
}
func (ctx *Context) RedirectTo(location string) error {
return ctx.Context.Redirect(302, config.C.ExternalUrl+location)
}
func (ctx *Context) Html(template string) error {
return ctx.HtmlWithCode(200, template)
}
func (ctx *Context) HtmlWithCode(code int, template string) error {
ctx.setErrorFlashes()
return ctx.Render(code, template, ctx.DataMap())
}
func (ctx *Context) Json(data any) error {
return ctx.JsonWithCode(200, data)
}
func (ctx *Context) JsonWithCode(code int, data any) error {
return ctx.JSON(code, data)
}
func (ctx *Context) PlainText(code int, message string) error {
return ctx.String(code, message)
}
func (ctx *Context) NotFound(message string) error {
return ctx.ErrorRes(404, message, nil)
}
func (ctx *Context) GetSession() *sessions.Session {
sess, _ := ctx.store.UserStore.Get(ctx.Request(), "session")
return sess
}
func (ctx *Context) SaveSession(sess *sessions.Session) {
_ = sess.Save(ctx.Request(), ctx.Response())
}
func (ctx *Context) DeleteSession() {
sess := ctx.GetSession()
sess.Options.MaxAge = -1
ctx.SaveSession(sess)
}
func (ctx *Context) AddFlash(flashMessage string, flashType string) {
sess, _ := ctx.store.flashStore.Get(ctx.Request(), "flash")
sess.AddFlash(flashMessage, flashType)
_ = sess.Save(ctx.Request(), ctx.Response())
}
func (ctx *Context) setErrorFlashes() {
sess, _ := ctx.store.flashStore.Get(ctx.Request(), "flash")
ctx.SetData("flashErrors", sess.Flashes("error"))
ctx.SetData("flashSuccess", sess.Flashes("success"))
ctx.SetData("flashWarnings", sess.Flashes("warning"))
_ = sess.Save(ctx.Request(), ctx.Response())
}
func (ctx *Context) DeleteCsrfCookie() {
ctx.SetCookie(&http.Cookie{Name: "_csrf", Path: "/", MaxAge: -1})
}
func (ctx *Context) TrH(key string, args ...any) template.HTML {
l := ctx.GetData("locale").(*i18n.Locale)
return l.Tr(key, args...)
}
func (ctx *Context) Tr(key string, args ...any) string {
l := ctx.GetData("locale").(*i18n.Locale)
return l.String(key, args...)
}
var ManifestEntries map[string]Asset
type Asset struct {
File string `json:"file"`
}

View File

@ -0,0 +1,28 @@
package context
import (
"github.com/gorilla/sessions"
"github.com/markbates/goth/gothic"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/session"
"path/filepath"
)
type Store struct {
sessionsPath string
flashStore *sessions.CookieStore
UserStore *sessions.FilesystemStore
}
func NewStore(sessionsPath string) *Store {
s := &Store{sessionsPath: sessionsPath}
s.flashStore = sessions.NewCookieStore([]byte("opengist"))
encryptKey, _ := session.GenerateSecretKey(filepath.Join(s.sessionsPath, "session-encrypt.key"))
s.UserStore = sessions.NewFilesystemStore(s.sessionsPath, config.SecretKey, encryptKey)
s.UserStore.MaxLength(10 * 1024)
gothic.Store = s.UserStore
return s
}

View File

@ -1,917 +0,0 @@
package web
import (
"archive/zip"
"bufio"
"bytes"
gojson "encoding/json"
"errors"
"fmt"
"html/template"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/index"
"github.com/thomiceli/opengist/internal/render"
"github.com/thomiceli/opengist/internal/utils"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"gorm.io/gorm"
)
func gistInit(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
currUser := getUserLogged(ctx)
userName := ctx.Param("user")
gistName := ctx.Param("gistname")
switch filepath.Ext(gistName) {
case ".js":
setData(ctx, "gistpage", "js")
gistName = strings.TrimSuffix(gistName, ".js")
case ".json":
setData(ctx, "gistpage", "json")
gistName = strings.TrimSuffix(gistName, ".json")
case ".git":
setData(ctx, "gistpage", "git")
gistName = strings.TrimSuffix(gistName, ".git")
}
gist, err := db.GetGist(userName, gistName)
if err != nil {
return notFound("Gist not found")
}
if gist.Private == db.PrivateVisibility {
if currUser == nil || currUser.ID != gist.UserID {
return notFound("Gist not found")
}
}
setData(ctx, "gist", gist)
if config.C.SshGit {
var sshDomain string
if config.C.SshExternalDomain != "" {
sshDomain = config.C.SshExternalDomain
} else {
sshDomain = strings.Split(ctx.Request().Host, ":")[0]
}
if config.C.SshPort == "22" {
setData(ctx, "sshCloneUrl", sshDomain+":"+userName+"/"+gistName+".git")
} else {
setData(ctx, "sshCloneUrl", "ssh://"+sshDomain+":"+config.C.SshPort+"/"+userName+"/"+gistName+".git")
}
}
baseHttpUrl := getData(ctx, "baseHttpUrl").(string)
if config.C.HttpGit {
setData(ctx, "httpCloneUrl", baseHttpUrl+"/"+userName+"/"+gistName+".git")
}
setData(ctx, "httpCopyUrl", baseHttpUrl+"/"+userName+"/"+gistName)
setData(ctx, "currentUrl", template.URL(ctx.Request().URL.Path))
setData(ctx, "embedScript", fmt.Sprintf(`<script src="%s"></script>`, baseHttpUrl+"/"+userName+"/"+gistName+".js"))
nbCommits, err := gist.NbCommits()
if err != nil {
return errorRes(500, "Error fetching number of commits", err)
}
setData(ctx, "nbCommits", nbCommits)
if currUser != nil {
hasLiked, err := currUser.HasLiked(gist)
if err != nil {
return errorRes(500, "Cannot get user like status", err)
}
setData(ctx, "hasLiked", hasLiked)
}
if gist.Private > 0 {
setData(ctx, "NoIndex", true)
}
return next(ctx)
}
}
// gistSoftInit try to load a gist (same as gistInit) but does not return a 404 if the gist is not found
// useful for git clients using HTTP to obfuscate the existence of a private gist
func gistSoftInit(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
userName := ctx.Param("user")
gistName := ctx.Param("gistname")
gistName = strings.TrimSuffix(gistName, ".git")
gist, _ := db.GetGist(userName, gistName)
setData(ctx, "gist", gist)
return next(ctx)
}
}
// gistNewPushInit has the same behavior as gistSoftInit but create a new gist empty instead
func gistNewPushSoftInit(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
setData(c, "gist", new(db.Gist))
return next(c)
}
}
func allGists(ctx echo.Context) error {
var err error
var urlPage string
fromUserStr := ctx.Param("user")
userLogged := getUserLogged(ctx)
pageInt := getPage(ctx)
sort := "created"
sortText := trH(ctx, "gist.list.sort-by-created")
order := "desc"
orderText := trH(ctx, "gist.list.order-by-desc")
if ctx.QueryParam("sort") == "updated" {
sort = "updated"
sortText = trH(ctx, "gist.list.sort-by-updated")
}
if ctx.QueryParam("order") == "asc" {
order = "asc"
orderText = trH(ctx, "gist.list.order-by-asc")
}
setData(ctx, "sort", sortText)
setData(ctx, "order", orderText)
var gists []*db.Gist
var currentUserId uint
if userLogged != nil {
currentUserId = userLogged.ID
} else {
currentUserId = 0
}
if fromUserStr == "" {
urlctx := ctx.Request().URL.Path
if strings.HasSuffix(urlctx, "search") {
setData(ctx, "htmlTitle", trH(ctx, "gist.list.search-results"))
setData(ctx, "mode", "search")
setData(ctx, "searchQuery", ctx.QueryParam("q"))
setData(ctx, "searchQueryUrl", template.URL("&q="+ctx.QueryParam("q")))
urlPage = "search"
gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order)
} else if strings.HasSuffix(urlctx, "all") {
setData(ctx, "htmlTitle", trH(ctx, "gist.list.all"))
setData(ctx, "mode", "all")
urlPage = "all"
gists, err = db.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order)
}
} else {
liked := false
forked := false
liked, err = regexp.MatchString(`/[^/]*/liked`, ctx.Request().URL.Path)
if err != nil {
return errorRes(500, "Error matching regexp", err)
}
forked, err = regexp.MatchString(`/[^/]*/forked`, ctx.Request().URL.Path)
if err != nil {
return errorRes(500, "Error matching regexp", err)
}
var fromUser *db.User
fromUser, err = db.GetUserByUsername(fromUserStr)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return notFound("User not found")
}
return errorRes(500, "Error fetching user", err)
}
setData(ctx, "fromUser", fromUser)
if countFromUser, err := db.CountAllGistsFromUser(fromUser.ID, currentUserId); err != nil {
return errorRes(500, "Error counting gists", err)
} else {
setData(ctx, "countFromUser", countFromUser)
}
if countLiked, err := db.CountAllGistsLikedByUser(fromUser.ID, currentUserId); err != nil {
return errorRes(500, "Error counting liked gists", err)
} else {
setData(ctx, "countLiked", countLiked)
}
if countForked, err := db.CountAllGistsForkedByUser(fromUser.ID, currentUserId); err != nil {
return errorRes(500, "Error counting forked gists", err)
} else {
setData(ctx, "countForked", countForked)
}
if liked {
urlPage = fromUserStr + "/liked"
setData(ctx, "htmlTitle", trH(ctx, "gist.list.all-liked-by", fromUserStr))
setData(ctx, "mode", "liked")
gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
} else if forked {
urlPage = fromUserStr + "/forked"
setData(ctx, "htmlTitle", trH(ctx, "gist.list.all-forked-by", fromUserStr))
setData(ctx, "mode", "forked")
gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
} else {
urlPage = fromUserStr
setData(ctx, "htmlTitle", trH(ctx, "gist.list.all-from", fromUserStr))
setData(ctx, "mode", "fromUser")
gists, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
}
}
renderedGists := make([]*render.RenderedGist, 0, len(gists))
for _, gist := range gists {
rendered, err := render.HighlightGistPreview(gist)
if err != nil {
log.Error().Err(err).Msg("Error rendering gist preview for " + gist.Identifier() + " - " + gist.PreviewFilename)
}
renderedGists = append(renderedGists, &rendered)
}
if err != nil {
return errorRes(500, "Error fetching gists", err)
}
if err = paginate(ctx, renderedGists, pageInt, 10, "gists", fromUserStr, 2, "&sort="+sort+"&order="+order); err != nil {
return errorRes(404, tr(ctx, "error.page-not-found"), nil)
}
setData(ctx, "urlPage", urlPage)
return html(ctx, "all.html")
}
func search(ctx echo.Context) error {
var err error
content, meta := parseSearchQueryStr(ctx.QueryParam("q"))
pageInt := getPage(ctx)
var currentUserId uint
userLogged := getUserLogged(ctx)
if userLogged != nil {
currentUserId = userLogged.ID
} else {
currentUserId = 0
}
var visibleGistsIds []uint
visibleGistsIds, err = db.GetAllGistsVisibleByUser(currentUserId)
if err != nil {
return errorRes(500, "Error fetching gists", err)
}
gistsIds, nbHits, langs, err := index.SearchGists(content, index.SearchGistMetadata{
Username: meta["user"],
Title: meta["title"],
Filename: meta["filename"],
Extension: meta["extension"],
Language: meta["language"],
}, visibleGistsIds, pageInt)
if err != nil {
return errorRes(500, "Error searching gists", err)
}
gists, err := db.GetAllGistsByIds(gistsIds)
if err != nil {
return errorRes(500, "Error fetching gists", err)
}
renderedGists := make([]*render.RenderedGist, 0, len(gists))
for _, gist := range gists {
rendered, err := render.HighlightGistPreview(gist)
if err != nil {
log.Error().Err(err).Msg("Error rendering gist preview for " + gist.Identifier() + " - " + gist.PreviewFilename)
}
renderedGists = append(renderedGists, &rendered)
}
if pageInt > 1 && len(renderedGists) != 0 {
setData(ctx, "prevPage", pageInt-1)
}
if 10*pageInt < int(nbHits) {
setData(ctx, "nextPage", pageInt+1)
}
setData(ctx, "prevLabel", trH(ctx, "pagination.previous"))
setData(ctx, "nextLabel", trH(ctx, "pagination.next"))
setData(ctx, "urlPage", "search")
setData(ctx, "urlParams", template.URL("&q="+ctx.QueryParam("q")))
setData(ctx, "htmlTitle", trH(ctx, "gist.list.search-results"))
setData(ctx, "nbHits", nbHits)
setData(ctx, "gists", renderedGists)
setData(ctx, "langs", langs)
setData(ctx, "searchQuery", ctx.QueryParam("q"))
return html(ctx, "search.html")
}
func gistIndex(ctx echo.Context) error {
if getData(ctx, "gistpage") == "js" {
return gistJs(ctx)
} else if getData(ctx, "gistpage") == "json" {
return gistJson(ctx)
}
gist := getData(ctx, "gist").(*db.Gist)
revision := ctx.Param("revision")
if revision == "" {
revision = "HEAD"
}
files, err := gist.Files(revision, true)
if _, ok := err.(*git.RevisionNotFoundError); ok {
return notFound("Revision not found")
} else if err != nil {
return errorRes(500, "Error fetching files", err)
}
renderedFiles := render.HighlightFiles(files)
setData(ctx, "page", "code")
setData(ctx, "commit", revision)
setData(ctx, "files", renderedFiles)
setData(ctx, "revision", revision)
setData(ctx, "htmlTitle", gist.Title)
return html(ctx, "gist.html")
}
func gistJson(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
files, err := gist.Files("HEAD", true)
if err != nil {
return errorRes(500, "Error fetching files", err)
}
renderedFiles := render.HighlightFiles(files)
setData(ctx, "files", renderedFiles)
htmlbuf := bytes.Buffer{}
w := bufio.NewWriter(&htmlbuf)
if err = ctx.Echo().Renderer.Render(w, "gist_embed.html", dataMap(ctx), ctx); err != nil {
return err
}
_ = w.Flush()
jsUrl, err := url.JoinPath(getData(ctx, "baseHttpUrl").(string), gist.User.Username, gist.Identifier()+".js")
if err != nil {
return errorRes(500, "Error joining js url", err)
}
cssUrl, err := url.JoinPath(getData(ctx, "baseHttpUrl").(string), manifestEntries["embed.css"].File)
if err != nil {
return errorRes(500, "Error joining css url", err)
}
return ctx.JSON(200, map[string]interface{}{
"owner": gist.User.Username,
"id": gist.Identifier(),
"uuid": gist.Uuid,
"title": gist.Title,
"description": gist.Description,
"created_at": time.Unix(gist.CreatedAt, 0).Format(time.RFC3339),
"visibility": gist.VisibilityStr(),
"files": renderedFiles,
"embed": map[string]string{
"html": htmlbuf.String(),
"css": cssUrl,
"js": jsUrl,
"js_dark": jsUrl + "?dark",
},
})
}
func gistJs(ctx echo.Context) error {
if _, exists := ctx.QueryParams()["dark"]; exists {
setData(ctx, "dark", "dark")
}
gist := getData(ctx, "gist").(*db.Gist)
files, err := gist.Files("HEAD", true)
if err != nil {
return errorRes(500, "Error fetching files", err)
}
renderedFiles := render.HighlightFiles(files)
setData(ctx, "files", renderedFiles)
htmlbuf := bytes.Buffer{}
w := bufio.NewWriter(&htmlbuf)
if err = ctx.Echo().Renderer.Render(w, "gist_embed.html", dataMap(ctx), ctx); err != nil {
return err
}
_ = w.Flush()
cssUrl, err := url.JoinPath(getData(ctx, "baseHttpUrl").(string), manifestEntries["embed.css"].File)
if err != nil {
return errorRes(500, "Error joining css url", err)
}
js, err := escapeJavaScriptContent(htmlbuf.String(), cssUrl)
if err != nil {
return errorRes(500, "Error escaping JavaScript content", err)
}
ctx.Response().Header().Set("Content-Type", "application/javascript")
return plainText(ctx, 200, js)
}
func revisions(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
userName := gist.User.Username
gistName := gist.Identifier()
pageInt := getPage(ctx)
commits, err := gist.Log((pageInt - 1) * 10)
if err != nil {
return errorRes(500, "Error fetching commits log", err)
}
if err := paginate(ctx, commits, pageInt, 10, "commits", userName+"/"+gistName+"/revisions", 2); err != nil {
return errorRes(404, tr(ctx, "error.page-not-found"), nil)
}
emailsSet := map[string]struct{}{}
for _, commit := range commits {
if commit.AuthorEmail == "" {
continue
}
emailsSet[strings.ToLower(commit.AuthorEmail)] = struct{}{}
}
emailsUsers, err := db.GetUsersFromEmails(emailsSet)
if err != nil {
return errorRes(500, "Error fetching users emails", err)
}
setData(ctx, "page", "revisions")
setData(ctx, "revision", "HEAD")
setData(ctx, "emails", emailsUsers)
setData(ctx, "htmlTitle", trH(ctx, "gist.revision-of", gist.Title))
return html(ctx, "revisions.html")
}
func create(ctx echo.Context) error {
setData(ctx, "htmlTitle", trH(ctx, "gist.new.create-a-new-gist"))
return html(ctx, "create.html")
}
func processCreate(ctx echo.Context) error {
isCreate := false
if ctx.Request().URL.Path == "/" {
isCreate = true
}
err := ctx.Request().ParseForm()
if err != nil {
return errorRes(400, tr(ctx, "error.bad-request"), err)
}
dto := new(db.GistDTO)
var gist *db.Gist
if isCreate {
setData(ctx, "htmlTitle", trH(ctx, "gist.new.create-a-new-gist"))
} else {
gist = getData(ctx, "gist").(*db.Gist)
setData(ctx, "htmlTitle", trH(ctx, "gist.edit.edit-gist", gist.Title))
}
if err := ctx.Bind(dto); err != nil {
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
}
dto.Files = make([]db.FileDTO, 0)
fileCounter := 0
for i := 0; i < len(ctx.Request().PostForm["content"]); i++ {
name := ctx.Request().PostForm["name"][i]
content := ctx.Request().PostForm["content"][i]
if name == "" {
fileCounter += 1
name = "gistfile" + strconv.Itoa(fileCounter) + ".txt"
}
escapedValue, err := url.QueryUnescape(content)
if err != nil {
return errorRes(400, tr(ctx, "error.invalid-character-unescaped"), err)
}
dto.Files = append(dto.Files, db.FileDTO{
Filename: strings.Trim(name, " "),
Content: escapedValue,
})
}
err = ctx.Validate(dto)
if err != nil {
addFlash(ctx, utils.ValidationMessages(&err, getData(ctx, "locale").(*i18n.Locale)), "error")
if isCreate {
return html(ctx, "create.html")
} else {
files, err := gist.Files("HEAD", false)
if err != nil {
return errorRes(500, "Error fetching files", err)
}
setData(ctx, "files", files)
return html(ctx, "edit.html")
}
}
if isCreate {
gist = dto.ToGist()
} else {
gist = dto.ToExistingGist(gist)
}
user := getUserLogged(ctx)
gist.NbFiles = len(dto.Files)
if isCreate {
uuidGist, err := uuid.NewRandom()
if err != nil {
return errorRes(500, "Error creating an UUID", err)
}
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
gist.UserID = user.ID
gist.User = *user
}
if gist.Title == "" {
if ctx.Request().PostForm["name"][0] == "" {
gist.Title = "gist:" + gist.Uuid
} else {
gist.Title = ctx.Request().PostForm["name"][0]
}
}
if len(dto.Files) > 0 {
split := strings.Split(dto.Files[0].Content, "\n")
if len(split) > 10 {
gist.Preview = strings.Join(split[:10], "\n")
} else {
gist.Preview = dto.Files[0].Content
}
gist.PreviewFilename = dto.Files[0].Filename
}
if err = gist.InitRepository(); err != nil {
return errorRes(500, "Error creating the repository", err)
}
if err = gist.AddAndCommitFiles(&dto.Files); err != nil {
return errorRes(500, "Error adding and committing files", err)
}
if isCreate {
if err = gist.Create(); err != nil {
return errorRes(500, "Error creating the gist", err)
}
} else {
if err = gist.Update(); err != nil {
return errorRes(500, "Error updating the gist", err)
}
}
gist.AddInIndex()
return redirect(ctx, "/"+user.Username+"/"+gist.Identifier())
}
func editVisibility(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
dto := new(db.VisibilityDTO)
if err := ctx.Bind(dto); err != nil {
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
}
gist.Private = dto.Private
if err := gist.UpdateNoTimestamps(); err != nil {
return errorRes(500, "Error updating this gist", err)
}
addFlash(ctx, tr(ctx, "flash.gist.visibility-changed"), "success")
return redirect(ctx, "/"+gist.User.Username+"/"+gist.Identifier())
}
func deleteGist(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
if err := gist.Delete(); err != nil {
return errorRes(500, "Error deleting this gist", err)
}
gist.RemoveFromIndex()
addFlash(ctx, tr(ctx, "flash.gist.deleted"), "success")
return redirect(ctx, "/")
}
func like(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
currentUser := getUserLogged(ctx)
hasLiked, err := currentUser.HasLiked(gist)
if err != nil {
return errorRes(500, "Error checking if user has liked a gist", err)
}
if hasLiked {
err = gist.RemoveUserLike(getUserLogged(ctx))
} else {
err = gist.AppendUserLike(getUserLogged(ctx))
}
if err != nil {
return errorRes(500, "Error liking/dislking this gist", err)
}
redirectTo := "/" + gist.User.Username + "/" + gist.Identifier()
if r := ctx.QueryParam("redirecturl"); r != "" {
redirectTo = r
}
return redirect(ctx, redirectTo)
}
func fork(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
currentUser := getUserLogged(ctx)
alreadyForked, err := gist.GetForkParent(currentUser)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errorRes(500, "Error checking if gist is already forked", err)
}
if gist.User.ID == currentUser.ID {
addFlash(ctx, tr(ctx, "flash.gist.fork-own-gist"), "error")
return redirect(ctx, "/"+gist.User.Username+"/"+gist.Identifier())
}
if alreadyForked.ID != 0 {
return redirect(ctx, "/"+alreadyForked.User.Username+"/"+alreadyForked.Identifier())
}
uuidGist, err := uuid.NewRandom()
if err != nil {
return errorRes(500, "Error creating an UUID", err)
}
newGist := &db.Gist{
Uuid: strings.Replace(uuidGist.String(), "-", "", -1),
Title: gist.Title,
Preview: gist.Preview,
PreviewFilename: gist.PreviewFilename,
Description: gist.Description,
Private: gist.Private,
UserID: currentUser.ID,
ForkedID: gist.ID,
NbFiles: gist.NbFiles,
}
if err = newGist.CreateForked(); err != nil {
return errorRes(500, "Error forking the gist in database", err)
}
if err = gist.ForkClone(currentUser.Username, newGist.Uuid); err != nil {
return errorRes(500, "Error cloning the repository while forking", err)
}
if err = gist.IncrementForkCount(); err != nil {
return errorRes(500, "Error incrementing the fork count", err)
}
addFlash(ctx, tr(ctx, "flash.gist.forked"), "success")
return redirect(ctx, "/"+currentUser.Username+"/"+newGist.Identifier())
}
func rawFile(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
file, err := gist.File(ctx.Param("revision"), ctx.Param("file"), false)
if err != nil {
return errorRes(500, "Error getting file content", err)
}
if file == nil {
return notFound("File not found")
}
return plainText(ctx, 200, file.Content)
}
func downloadFile(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
file, err := gist.File(ctx.Param("revision"), ctx.Param("file"), false)
if err != nil {
return errorRes(500, "Error getting file content", err)
}
if file == nil {
return notFound("File not found")
}
ctx.Response().Header().Set("Content-Type", "text/plain")
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+file.Filename)
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
_, err = ctx.Response().Write([]byte(file.Content))
if err != nil {
return errorRes(500, "Error downloading the file", err)
}
return nil
}
func edit(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
files, err := gist.Files("HEAD", false)
if err != nil {
return errorRes(500, "Error fetching files from repository", err)
}
setData(ctx, "files", files)
setData(ctx, "htmlTitle", trH(ctx, "gist.edit.edit-gist", gist.Title))
return html(ctx, "edit.html")
}
func downloadZip(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
revision := ctx.Param("revision")
files, err := gist.Files(revision, false)
if err != nil {
return errorRes(500, "Error fetching files from repository", err)
}
if len(files) == 0 {
return notFound("No files found in this revision")
}
zipFile := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipFile)
for _, file := range files {
fh := &zip.FileHeader{
Name: file.Filename,
Method: zip.Deflate,
}
f, err := zipWriter.CreateHeader(fh)
if err != nil {
return errorRes(500, "Error adding a file the to the zip archive", err)
}
_, err = f.Write([]byte(file.Content))
if err != nil {
return errorRes(500, "Error adding file content the to the zip archive", err)
}
}
err = zipWriter.Close()
if err != nil {
return errorRes(500, "Error closing the zip archive", err)
}
ctx.Response().Header().Set("Content-Type", "application/zip")
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+gist.Identifier()+".zip")
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(zipFile.Bytes())))
_, err = ctx.Response().Write(zipFile.Bytes())
if err != nil {
return errorRes(500, "Error writing the zip archive", err)
}
return nil
}
func likes(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
pageInt := getPage(ctx)
likers, err := gist.GetUsersLikes(pageInt - 1)
if err != nil {
return errorRes(500, "Error getting users who liked this gist", err)
}
if err = paginate(ctx, likers, pageInt, 30, "likers", gist.User.Username+"/"+gist.Identifier()+"/likes", 1); err != nil {
return errorRes(404, tr(ctx, "error.page-not-found"), nil)
}
setData(ctx, "htmlTitle", trH(ctx, "gist.likes.for", gist.Title))
setData(ctx, "revision", "HEAD")
return html(ctx, "likes.html")
}
func forks(ctx echo.Context) error {
gist := getData(ctx, "gist").(*db.Gist)
pageInt := getPage(ctx)
currentUser := getUserLogged(ctx)
var fromUserID uint = 0
if currentUser != nil {
fromUserID = currentUser.ID
}
forks, err := gist.GetForks(fromUserID, pageInt-1)
if err != nil {
return errorRes(500, "Error getting users who liked this gist", err)
}
if err = paginate(ctx, forks, pageInt, 30, "forks", gist.User.Username+"/"+gist.Identifier()+"/forks", 2); err != nil {
return errorRes(404, tr(ctx, "error.page-not-found"), nil)
}
setData(ctx, "htmlTitle", trH(ctx, "gist.forks.for", gist.Title))
setData(ctx, "revision", "HEAD")
return html(ctx, "forks.html")
}
func checkbox(ctx echo.Context) error {
filename := ctx.FormValue("file")
checkboxNb := ctx.FormValue("checkbox")
i, err := strconv.Atoi(checkboxNb)
if err != nil {
return errorRes(400, tr(ctx, "error.invalid-number"), nil)
}
gist := getData(ctx, "gist").(*db.Gist)
file, err := gist.File("HEAD", filename, false)
if err != nil {
return errorRes(500, "Error getting file content", err)
} else if file == nil {
return notFound("File not found")
}
markdown, err := render.Checkbox(file.Content, i)
if err != nil {
return errorRes(500, "Error checking checkbox", err)
}
if err = gist.AddAndCommitFile(&db.FileDTO{
Filename: filename,
Content: markdown,
}); err != nil {
return errorRes(500, "Error adding and committing files", err)
}
if err = gist.UpdatePreviewAndCount(true); err != nil {
return errorRes(500, "Error updating the gist", err)
}
return plainText(ctx, 200, "ok")
}
func preview(ctx echo.Context) error {
content := ctx.FormValue("content")
previewStr, err := render.MarkdownString(content)
if err != nil {
return errorRes(500, "Error rendering markdown", err)
}
return plainText(ctx, 200, previewStr)
}
func escapeJavaScriptContent(htmlContent, cssUrl string) (string, error) {
jsonContent, err := gojson.Marshal(htmlContent)
if err != nil {
return "", fmt.Errorf("failed to encode content: %w", err)
}
jsonCssUrl, err := gojson.Marshal(cssUrl)
if err != nil {
return "", fmt.Errorf("failed to encode CSS URL: %w", err)
}
js := fmt.Sprintf(`
document.write('<link rel="stylesheet" href=%s>');
document.write(%s);
`,
string(jsonCssUrl),
string(jsonContent),
)
return js, nil
}

View File

@ -0,0 +1,48 @@
package admin
import (
"github.com/thomiceli/opengist/internal/actions"
"github.com/thomiceli/opengist/internal/web/context"
)
func AdminSyncReposFromFS(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success")
go actions.Run(actions.SyncReposFromFS)
return ctx.RedirectTo("/admin-panel")
}
func AdminSyncReposFromDB(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success")
go actions.Run(actions.SyncReposFromDB)
return ctx.RedirectTo("/admin-panel")
}
func AdminGcRepos(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success")
go actions.Run(actions.GitGcRepos)
return ctx.RedirectTo("/admin-panel")
}
func AdminSyncGistPreviews(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success")
go actions.Run(actions.SyncGistPreviews)
return ctx.RedirectTo("/admin-panel")
}
func AdminResetHooks(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success")
go actions.Run(actions.ResetHooks)
return ctx.RedirectTo("/admin-panel")
}
func AdminIndexGists(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success")
go actions.Run(actions.IndexGists)
return ctx.RedirectTo("/admin-panel")
}
func AdminSyncGistLanguages(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-gist-languages"), "success")
go actions.Run(actions.SyncGistLanguages)
return ctx.RedirectTo("/admin-panel")
}

View File

@ -0,0 +1,204 @@
package admin
import (
"github.com/thomiceli/opengist/internal/actions"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
"runtime"
"strconv"
"time"
)
func AdminIndex(ctx *context.Context) error {
ctx.SetData("htmlTitle", ctx.TrH("admin.admin_panel"))
ctx.SetData("adminHeaderPage", "index")
ctx.SetData("opengistVersion", config.OpengistVersion)
ctx.SetData("goVersion", runtime.Version())
gitVersion, err := git.GetGitVersion()
if err != nil {
return ctx.ErrorRes(500, "Cannot get git version", err)
}
ctx.SetData("gitVersion", gitVersion)
countUsers, err := db.CountAll(&db.User{})
if err != nil {
return ctx.ErrorRes(500, "Cannot count users", err)
}
ctx.SetData("countUsers", countUsers)
countGists, err := db.CountAll(&db.Gist{})
if err != nil {
return ctx.ErrorRes(500, "Cannot count gists", err)
}
ctx.SetData("countGists", countGists)
countKeys, err := db.CountAll(&db.SSHKey{})
if err != nil {
return ctx.ErrorRes(500, "Cannot count SSH keys", err)
}
ctx.SetData("countKeys", countKeys)
ctx.SetData("syncReposFromFS", actions.IsRunning(actions.SyncReposFromFS))
ctx.SetData("syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB))
ctx.SetData("gitGcRepos", actions.IsRunning(actions.GitGcRepos))
ctx.SetData("syncGistPreviews", actions.IsRunning(actions.SyncGistPreviews))
ctx.SetData("resetHooks", actions.IsRunning(actions.ResetHooks))
ctx.SetData("indexGists", actions.IsRunning(actions.IndexGists))
ctx.SetData("syncGistLanguages", actions.IsRunning(actions.SyncGistLanguages))
return ctx.Html("admin_index.html")
}
func AdminUsers(ctx *context.Context) error {
ctx.SetData("htmlTitle", ctx.TrH("admin.users")+" - "+ctx.TrH("admin.admin_panel"))
ctx.SetData("adminHeaderPage", "users")
ctx.SetData("loadStartTime", time.Now())
pageInt := handlers.GetPage(ctx)
var data []*db.User
var err error
if data, err = db.GetAllUsers(pageInt - 1); err != nil {
return ctx.ErrorRes(500, "Cannot get users", err)
}
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "admin-panel/users", 1, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
return ctx.Html("admin_users.html")
}
func AdminGists(ctx *context.Context) error {
ctx.SetData("htmlTitle", ctx.TrH("admin.gists")+" - "+ctx.TrH("admin.admin_panel"))
ctx.SetData("adminHeaderPage", "gists")
pageInt := handlers.GetPage(ctx)
var data []*db.Gist
var err error
if data, err = db.GetAllGists(pageInt - 1); err != nil {
return ctx.ErrorRes(500, "Cannot get gists", err)
}
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "admin-panel/gists", 1, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
return ctx.Html("admin_gists.html")
}
func AdminUserDelete(ctx *context.Context) error {
userId, _ := strconv.ParseUint(ctx.Param("user"), 10, 64)
user, err := db.GetUserById(uint(userId))
if err != nil {
return ctx.ErrorRes(500, "Cannot retrieve user", err)
}
if err := user.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete this user", err)
}
ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success")
return ctx.RedirectTo("/admin-panel/users")
}
func AdminGistDelete(ctx *context.Context) error {
gist, err := db.GetGistByID(ctx.Param("gist"))
if err != nil {
return ctx.ErrorRes(500, "Cannot retrieve gist", err)
}
if err = gist.DeleteRepository(); err != nil {
return ctx.ErrorRes(500, "Cannot delete the repository", err)
}
if err = gist.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete this gist", err)
}
gist.RemoveFromIndex()
ctx.AddFlash(ctx.Tr("flash.admin.gist-deleted"), "success")
return ctx.RedirectTo("/admin-panel/gists")
}
func AdminConfig(ctx *context.Context) error {
ctx.SetData("htmlTitle", ctx.TrH("admin.configuration")+" - "+ctx.TrH("admin.admin_panel"))
ctx.SetData("adminHeaderPage", "config")
ctx.SetData("dbtype", db.DatabaseInfo.Type.String())
ctx.SetData("dbname", db.DatabaseInfo.Database)
return ctx.Html("admin_config.html")
}
func AdminSetConfig(ctx *context.Context) error {
key := ctx.FormValue("key")
value := ctx.FormValue("value")
if err := db.UpdateSetting(key, value); err != nil {
return ctx.ErrorRes(500, "Cannot set setting", err)
}
return ctx.JSON(200, map[string]interface{}{
"success": true,
})
}
func AdminInvitations(ctx *context.Context) error {
ctx.SetData("htmlTitle", ctx.TrH("admin.invitations")+" - "+ctx.TrH("admin.admin_panel"))
ctx.SetData("adminHeaderPage", "invitations")
var invitations []*db.Invitation
var err error
if invitations, err = db.GetAllInvitations(); err != nil {
return ctx.ErrorRes(500, "Cannot get invites", err)
}
ctx.SetData("invitations", invitations)
return ctx.Html("admin_invitations.html")
}
func AdminInvitationsCreate(ctx *context.Context) error {
code := ctx.FormValue("code")
nbMax, err := strconv.ParseUint(ctx.FormValue("nbMax"), 10, 64)
if err != nil {
nbMax = 10
}
expiresAtUnix, err := strconv.ParseInt(ctx.FormValue("expiredAtUnix"), 10, 64)
if err != nil {
expiresAtUnix = time.Now().Unix() + 604800 // 1 week
}
invitation := &db.Invitation{
Code: code,
ExpiresAt: expiresAtUnix,
NbMax: uint(nbMax),
}
if err := invitation.Create(); err != nil {
return ctx.ErrorRes(500, "Cannot create invitation", err)
}
ctx.AddFlash(ctx.Tr("flash.admin.invitation-created"), "success")
return ctx.RedirectTo("/admin-panel/invitations")
}
func AdminInvitationsDelete(ctx *context.Context) error {
id, _ := strconv.ParseUint(ctx.Param("id"), 10, 64)
invitation, err := db.GetInvitationByID(uint(id))
if err != nil {
return ctx.ErrorRes(500, "Cannot retrieve invitation", err)
}
if err := invitation.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete this invitation", err)
}
ctx.AddFlash(ctx.Tr("flash.admin.invitation-deleted"), "success")
return ctx.RedirectTo("/admin-panel/invitations")
}

View File

@ -0,0 +1,17 @@
package handlers
import (
"github.com/thomiceli/opengist/internal/web/context"
)
type ContextAuthInfo struct {
Context *context.Context
}
func (auth ContextAuthInfo) RequireLogin() (bool, error) {
return auth.Context.GetData("RequireLogin") == true, nil
}
func (auth ContextAuthInfo) AllowGistsWithoutLogin() (bool, error) {
return auth.Context.GetData("AllowGistsWithoutLogin") == true, nil
}

View File

@ -0,0 +1,22 @@
package auth
import (
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
)
func Mfa(ctx *context.Context) error {
var err error
user := db.User{ID: ctx.GetSession().Values["mfaID"].(uint)}
var hasWebauthn, hasTotp bool
if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
}
ctx.SetData("hasWebauthn", hasWebauthn)
ctx.SetData("hasTotp", hasTotp)
return ctx.Html("mfa.html")
}

View File

@ -0,0 +1,195 @@
package auth
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/web/context"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gorm.io/gorm"
)
func Oauth(ctx *context.Context) error {
providerStr := ctx.Param("provider")
httpProtocol := "http"
if ctx.Request().TLS != nil || ctx.Request().Header.Get("X-Forwarded-Proto") == "https" {
httpProtocol = "https"
}
forwarded_hdr := ctx.Request().Header.Get("Forwarded")
if forwarded_hdr != "" {
fields := strings.Split(forwarded_hdr, ";")
fwd := make(map[string]string)
for _, v := range fields {
p := strings.Split(v, "=")
fwd[p[0]] = p[1]
}
val, ok := fwd["proto"]
if ok && val == "https" {
httpProtocol = "https"
}
}
var opengistUrl string
if config.C.ExternalUrl != "" {
opengistUrl = config.C.ExternalUrl
} else {
opengistUrl = httpProtocol + "://" + ctx.Request().Host
}
provider, err := oauth.DefineProvider(providerStr, opengistUrl)
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.oauth-unsupported"), nil)
}
if err = provider.RegisterProvider(); err != nil {
return ctx.ErrorRes(500, "Cannot create provider", err)
}
provider.BeginAuthHandler(ctx)
return nil
}
func OauthCallback(ctx *context.Context) error {
provider, err := oauth.CompleteUserAuth(ctx)
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.complete-oauth-login", err.Error()), err)
}
currUser := ctx.User
// if user is logged in, link account to user and update its avatar URL
if currUser != nil {
provider.UpdateUserDB(currUser)
if err = currUser.Update(); err != nil {
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", 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, create it
if err != nil {
if ctx.GetData("DisableSignup") == true {
return ctx.ErrorRes(403, ctx.Tr("error.signup-disabled"), nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorRes(500, "Cannot get user", err)
}
if user.NickName == "" {
user.NickName = strings.Split(user.Email, "@")[0]
}
userDB = &db.User{
Username: user.NickName,
Email: user.Email,
MD5Hash: fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(user.Email))))),
}
// set provider id and avatar URL
provider.UpdateUserDB(userDB)
if err = userDB.Create(); err != nil {
if db.IsUniqueConstraintViolation(err) {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
return ctx.RedirectTo("/login")
}
return ctx.ErrorRes(500, "Cannot create user", err)
}
// 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)
}
}
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 {
for _, key := range keys {
sshKey := db.SSHKey{
Title: "Added from " + user.Provider,
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")
}
}
}
}
// 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
ctx.SaveSession(sess)
ctx.DeleteCsrfCookie()
return ctx.RedirectTo("/")
}
func OauthUnlink(ctx *context.Context) error {
providerStr := ctx.Param("provider")
provider, err := oauth.DefineProvider(ctx.Param("provider"), "")
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.oauth-unsupported"), nil)
}
currUser := ctx.User
if provider.UserHasProvider(currUser) {
if err := currUser.DeleteProviderID(providerStr); err != nil {
return ctx.ErrorRes(500, "Cannot unlink account from "+cases.Title(language.English).String(providerStr), err)
}
ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", cases.Title(language.English).String(providerStr)), "success")
return ctx.RedirectTo("/settings")
}
return ctx.RedirectTo("/settings")
}

View File

@ -0,0 +1,218 @@
package auth
import (
"errors"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/ldap"
passwordpkg "github.com/thomiceli/opengist/internal/auth/password"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context"
"gorm.io/gorm"
)
func Register(ctx *context.Context) error {
disableSignup := ctx.GetData("DisableSignup")
disableForm := ctx.GetData("DisableLoginForm")
code := ctx.QueryParam("code")
if code != "" {
if invitation, err := db.GetInvitationByCode(code); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorRes(500, "Cannot check for invitation code", err)
} else if invitation != nil && invitation.IsUsable() {
disableSignup = false
}
}
ctx.SetData("title", ctx.TrH("auth.new-account"))
ctx.SetData("htmlTitle", ctx.TrH("auth.new-account"))
ctx.SetData("disableForm", disableForm)
ctx.SetData("disableSignup", disableSignup)
ctx.SetData("isLoginPage", false)
return ctx.Html("auth_form.html")
}
func ProcessRegister(ctx *context.Context) error {
disableSignup := ctx.GetData("DisableSignup")
code := ctx.QueryParam("code")
invitation, err := db.GetInvitationByCode(code)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorRes(500, "Cannot check for invitation code", err)
} else if invitation.ID != 0 && invitation.IsUsable() {
disableSignup = false
}
if disableSignup == true {
return ctx.ErrorRes(403, ctx.Tr("error.signup-disabled"), nil)
}
if ctx.GetData("DisableLoginForm") == true {
return ctx.ErrorRes(403, ctx.Tr("error.signup-disabled-form"), nil)
}
ctx.SetData("title", ctx.TrH("auth.new-account"))
ctx.SetData("htmlTitle", ctx.TrH("auth.new-account"))
sess := ctx.GetSession()
dto := new(db.UserDTO)
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.Html("auth_form.html")
}
if exists, err := db.UserExists(dto.Username); err != nil || exists {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
return ctx.Html("auth_form.html")
}
user := dto.ToUser()
password, err := passwordpkg.HashPassword(user.Password)
if err != nil {
return ctx.ErrorRes(500, "Cannot hash password", err)
}
user.Password = password
if err = user.Create(); err != nil {
return ctx.ErrorRes(500, "Cannot create user", err)
}
if user.ID == 1 {
if err = user.SetAdmin(); err != nil {
return ctx.ErrorRes(500, "Cannot set user admin", err)
}
}
if invitation.ID != 0 {
if err := invitation.Use(); err != nil {
return ctx.ErrorRes(500, "Cannot use invitation", err)
}
}
sess.Values["user"] = user.ID
ctx.SaveSession(sess)
return ctx.RedirectTo("/")
}
func Login(ctx *context.Context) error {
ctx.SetData("title", ctx.TrH("auth.login"))
ctx.SetData("htmlTitle", ctx.TrH("auth.login"))
ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
ctx.SetData("isLoginPage", true)
return ctx.Html("auth_form.html")
}
func ProcessLogin(ctx *context.Context) error {
if ctx.GetData("DisableLoginForm") == true {
return ctx.ErrorRes(403, ctx.Tr("error.login-disabled-form"), nil)
}
var user *db.User
var err error
sess := ctx.GetSession()
dto := &db.UserDTO{}
if err = ctx.Bind(dto); err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
}
if ldap.Enabled() {
if user, err = tryLdapLogin(ctx, dto.Username, dto.Password); err != nil {
return err
}
}
if user == nil {
if user, err = tryDbLogin(ctx, dto.Username, dto.Password); user == nil {
return err
}
}
// handle MFA
var hasWebauthn, hasTotp bool
if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
}
if hasWebauthn || hasTotp {
sess.Values["mfaID"] = user.ID
sess.Options.MaxAge = 5 * 60 // 5 minutes
ctx.SaveSession(sess)
return ctx.RedirectTo("/mfa")
}
sess.Values["user"] = user.ID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
ctx.SaveSession(sess)
ctx.DeleteCsrfCookie()
return ctx.RedirectTo("/")
}
func Logout(ctx *context.Context) error {
ctx.DeleteSession()
ctx.DeleteCsrfCookie()
return ctx.RedirectTo("/all")
}
func tryDbLogin(ctx *context.Context, username, password string) (user *db.User, err error) {
if user, err = db.GetUserByUsername(username); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ctx.ErrorRes(500, "Cannot get user", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
return nil, ctx.RedirectTo("/login")
}
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
if err != nil {
return nil, ctx.ErrorRes(500, "Cannot check for password", err)
}
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
return nil, ctx.RedirectTo("/login")
}
return user, nil
}
func tryLdapLogin(ctx *context.Context, username, password string) (user *db.User, err error) {
ok, err := ldap.Authenticate(username, password)
if err != nil {
log.Info().Err(err).Msgf("LDAP authentication error")
return nil, ctx.ErrorRes(500, "Cannot get user", err)
}
if !ok {
log.Warn().Msg("Invalid LDAP authentication attempt from " + ctx.RealIP())
return nil, nil
}
if user, err = db.GetUserByUsername(username); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ctx.ErrorRes(500, "Cannot get user", err)
}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
user = &db.User{
Username: username,
}
if err = user.Create(); err != nil {
log.Warn().Err(err).Msg("Cannot create user after LDAP authentication")
return nil, ctx.ErrorRes(500, "Cannot create user", err)
}
return user, nil
}
return user, nil
}

View File

@ -0,0 +1,177 @@
package auth
import (
"github.com/thomiceli/opengist/internal/auth/totp"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"net/url"
)
func BeginTotp(ctx *context.Context) error {
user := ctx.User
if _, hasTotp, err := user.HasMFA(); err != nil {
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
} else if hasTotp {
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
return ctx.RedirectTo("/settings/mfa")
}
ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string))
if err != nil {
return ctx.ErrorRes(500, "Cannot parse base URL", err)
}
sess := ctx.GetSession()
generatedSecret, _ := sess.Values["generatedSecret"].([]byte)
totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(ctx.User.Username, ogUrl.Hostname(), generatedSecret)
if err != nil {
return ctx.ErrorRes(500, "Cannot generate TOTP QR code", err)
}
sess.Values["totpSecret"] = totpSecret
sess.Values["generatedSecret"] = generatedSecret
ctx.SaveSession(sess)
ctx.SetData("totpSecret", totpSecret)
ctx.SetData("totpQrcode", qrcode)
return ctx.Html("totp.html")
}
func FinishTotp(ctx *context.Context) error {
user := ctx.User
if _, hasTotp, err := user.HasMFA(); err != nil {
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
} else if hasTotp {
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
return ctx.RedirectTo("/settings/mfa")
}
dto := &db.TOTPDTO{}
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("Invalid secret", "error")
return ctx.RedirectTo("/settings/totp/generate")
}
sess := ctx.GetSession()
secret, ok := sess.Values["totpSecret"].(string)
if !ok {
return ctx.ErrorRes(500, "Cannot get TOTP secret from session", nil)
}
if !totp.Validate(dto.Code, secret) {
ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error")
return ctx.RedirectTo("/settings/totp/generate")
}
userTotp := &db.TOTP{
UserID: ctx.User.ID,
}
if err := userTotp.StoreSecret(secret); err != nil {
return ctx.ErrorRes(500, "Cannot store TOTP secret", err)
}
if err := userTotp.Create(); err != nil {
return ctx.ErrorRes(500, "Cannot create TOTP", err)
}
ctx.AddFlash("TOTP successfully enabled", "success")
codes, err := userTotp.GenerateRecoveryCodes()
if err != nil {
return ctx.ErrorRes(500, "Cannot generate recovery codes", err)
}
delete(sess.Values, "totpSecret")
delete(sess.Values, "generatedSecret")
ctx.SaveSession(sess)
ctx.SetData("recoveryCodes", codes)
return ctx.Html("totp.html")
}
func AssertTotp(ctx *context.Context) error {
var err error
dto := &db.TOTPDTO{}
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(ctx.Tr("auth.totp.invalid-code"), "error")
return ctx.RedirectTo("/mfa")
}
sess := ctx.GetSession()
userId := sess.Values["mfaID"].(uint)
var userTotp *db.TOTP
if userTotp, err = db.GetTOTPByUserID(userId); err != nil {
return ctx.ErrorRes(500, "Cannot get TOTP by UID", err)
}
redirectUrl := "/"
var validCode, validRecoveryCode bool
if validCode, err = userTotp.ValidateCode(dto.Code); err != nil {
return ctx.ErrorRes(500, "Cannot validate TOTP code", err)
}
if !validCode {
validRecoveryCode, err = userTotp.ValidateRecoveryCode(dto.Code)
if err != nil {
return ctx.ErrorRes(500, "Cannot validate TOTP code", err)
}
if !validRecoveryCode {
ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error")
return ctx.RedirectTo("/mfa")
}
ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning")
redirectUrl = "/settings/mfa"
}
sess.Values["user"] = userId
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "mfaID")
ctx.SaveSession(sess)
return ctx.RedirectTo(redirectUrl)
}
func DisableTotp(ctx *context.Context) error {
user := ctx.User
userTotp, err := db.GetTOTPByUserID(user.ID)
if err != nil {
return ctx.ErrorRes(500, "Cannot get TOTP by UID", err)
}
if err = userTotp.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete TOTP", err)
}
ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success")
return ctx.RedirectTo("/settings/mfa")
}
func RegenerateTotpRecoveryCodes(ctx *context.Context) error {
user := ctx.User
userTotp, err := db.GetTOTPByUserID(user.ID)
if err != nil {
return ctx.ErrorRes(500, "Cannot get TOTP by UID", err)
}
codes, err := userTotp.GenerateRecoveryCodes()
if err != nil {
return ctx.ErrorRes(500, "Cannot generate recovery codes", err)
}
ctx.SetData("recoveryCodes", codes)
return ctx.Html("totp.html")
}

View File

@ -0,0 +1,151 @@
package auth
import (
"bytes"
gojson "encoding/json"
"github.com/thomiceli/opengist/internal/auth/webauthn"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"io"
)
func BeginWebAuthnBinding(ctx *context.Context) error {
credsCreation, jsonWaSession, err := webauthn.BeginBinding(ctx.User)
if err != nil {
return ctx.ErrorRes(500, "Cannot begin WebAuthn registration", err)
}
sess := ctx.GetSession()
sess.Values["webauthn_registration_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
ctx.SaveSession(sess)
return ctx.JSON(200, credsCreation)
}
func FinishWebAuthnBinding(ctx *context.Context) error {
sess := ctx.GetSession()
jsonWaSession, ok := sess.Values["webauthn_registration_session"].([]byte)
if !ok {
return ctx.ErrorRes(401, "Cannot get WebAuthn registration session", nil)
}
user := ctx.User
// extract passkey name from request
body, err := io.ReadAll(ctx.Request().Body)
if err != nil {
return ctx.ErrorRes(400, "Failed to read request body", err)
}
ctx.Request().Body.Close()
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(body))
dto := new(db.CrendentialDTO)
_ = gojson.Unmarshal(body, &dto)
if err = ctx.Validate(dto); err != nil {
return ctx.ErrorRes(400, "Invalid request", err)
}
passkeyName := dto.PasskeyName
if passkeyName == "" {
passkeyName = "WebAuthn"
}
waCredential, err := webauthn.FinishBinding(user, jsonWaSession, ctx.Request())
if err != nil {
return ctx.ErrorRes(403, "Failed binding attempt for passkey", err)
}
if _, err = db.CreateFromCrendential(user.ID, passkeyName, waCredential); err != nil {
return ctx.ErrorRes(500, "Cannot create WebAuthn credential on database", err)
}
delete(sess.Values, "webauthn_registration_session")
ctx.SaveSession(sess)
ctx.AddFlash(ctx.Tr("flash.auth.passkey-registred", passkeyName), "success")
return ctx.Json([]string{"OK"})
}
func BeginWebAuthnLogin(ctx *context.Context) error {
credsCreation, jsonWaSession, err := webauthn.BeginDiscoverableLogin()
if err != nil {
return ctx.ErrorRes(401, "Cannot begin WebAuthn login", err)
}
sess := ctx.GetSession()
sess.Values["webauthn_login_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
ctx.SaveSession(sess)
return ctx.Json(credsCreation)
}
func FinishWebAuthnLogin(ctx *context.Context) error {
sess := ctx.GetSession()
sessionData, ok := sess.Values["webauthn_login_session"].([]byte)
if !ok {
return ctx.ErrorRes(401, "Cannot get WebAuthn login session", nil)
}
userID, err := webauthn.FinishDiscoverableLogin(sessionData, ctx.Request())
if err != nil {
return ctx.ErrorRes(403, "Failed authentication attempt for passkey", err)
}
sess.Values["user"] = userID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "webauthn_login_session")
ctx.SaveSession(sess)
return ctx.Json([]string{"OK"})
}
func BeginWebAuthnAssertion(ctx *context.Context) error {
sess := ctx.GetSession()
ogUser, err := db.GetUserById(sess.Values["mfaID"].(uint))
if err != nil {
return ctx.ErrorRes(500, "Cannot get user", err)
}
credsCreation, jsonWaSession, err := webauthn.BeginLogin(ogUser)
if err != nil {
return ctx.ErrorRes(401, "Cannot begin WebAuthn login", err)
}
sess.Values["webauthn_assertion_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
ctx.SaveSession(sess)
return ctx.Json(credsCreation)
}
func FinishWebAuthnAssertion(ctx *context.Context) error {
sess := ctx.GetSession()
sessionData, ok := sess.Values["webauthn_assertion_session"].([]byte)
if !ok {
return ctx.ErrorRes(401, "Cannot get WebAuthn assertion session", nil)
}
userId := sess.Values["mfaID"].(uint)
ogUser, err := db.GetUserById(userId)
if err != nil {
return ctx.ErrorRes(500, "Cannot get user", err)
}
if err = webauthn.FinishLogin(ogUser, sessionData, ctx.Request()); err != nil {
return ctx.ErrorRes(403, "Failed authentication attempt for passkey", err)
}
sess.Values["user"] = userId
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "webauthn_assertion_session")
delete(sess.Values, "mfaID")
ctx.SaveSession(sess)
return ctx.Json([]string{"OK"})
}

View File

@ -0,0 +1,217 @@
package gist
import (
"errors"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/index"
"github.com/thomiceli/opengist/internal/render"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
"gorm.io/gorm"
"slices"
"strings"
)
func AllGists(ctx *context.Context) error {
var err error
var urlPage string
fromUserStr := ctx.Param("user")
userLogged := ctx.User
pageInt := handlers.GetPage(ctx)
sort := "created"
sortText := ctx.TrH("gist.list.sort-by-created")
order := "desc"
orderText := ctx.TrH("gist.list.order-by-desc")
if ctx.QueryParam("sort") == "updated" {
sort = "updated"
sortText = ctx.TrH("gist.list.sort-by-updated")
}
if ctx.QueryParam("order") == "asc" {
order = "asc"
orderText = ctx.TrH("gist.list.order-by-asc")
}
pagination := &handlers.PaginationParams{
Sort: sort,
Order: order,
}
ctx.SetData("sort", sortText)
ctx.SetData("order", orderText)
var gists []*db.Gist
var currentUserId uint
if userLogged != nil {
currentUserId = userLogged.ID
} else {
currentUserId = 0
}
mode := ctx.GetData("mode")
if fromUserStr == "" {
if mode == "search" {
ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
ctx.SetData("searchQuery", ctx.QueryParam("q"))
pagination.Query = ctx.QueryParam("q")
urlPage = "search"
gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "")
} else if mode == "topics" {
ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic")))
ctx.SetData("topic", ctx.Param("topic"))
urlPage = "topics/" + ctx.Param("topic")
gists, err = db.GetAllGistsFromSearch(currentUserId, "", pageInt-1, sort, order, ctx.Param("topic"))
} else if mode == "all" {
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all"))
urlPage = "all"
gists, err = db.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order)
}
} else {
var fromUser *db.User
var count int64
fromUser, err = db.GetUserByUsername(fromUserStr)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.NotFound("User not found")
}
return ctx.ErrorRes(500, "Error fetching user", err)
}
ctx.SetData("fromUser", fromUser)
if countFromUser, err := db.CountAllGistsFromUser(fromUser.ID, currentUserId); err != nil {
return ctx.ErrorRes(500, "Error counting gists", err)
} else {
ctx.SetData("countFromUser", countFromUser)
}
if countLiked, err := db.CountAllGistsLikedByUser(fromUser.ID, currentUserId); err != nil {
return ctx.ErrorRes(500, "Error counting liked gists", err)
} else {
ctx.SetData("countLiked", countLiked)
}
if countForked, err := db.CountAllGistsForkedByUser(fromUser.ID, currentUserId); err != nil {
return ctx.ErrorRes(500, "Error counting forked gists", err)
} else {
ctx.SetData("countForked", countForked)
}
if mode == "liked" {
urlPage = fromUserStr + "/liked"
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-liked-by", fromUserStr))
gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
} else if mode == "forked" {
urlPage = fromUserStr + "/forked"
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-forked-by", fromUserStr))
gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
} else if mode == "fromUser" {
urlPage = fromUserStr
if languages, err := db.GetGistLanguagesForUser(fromUser.ID, currentUserId); err != nil {
return ctx.ErrorRes(500, "Error fetching languages", err)
} else {
ctx.SetData("languages", languages)
}
title := ctx.QueryParam("title")
language := ctx.QueryParam("language")
visibility := ctx.QueryParam("visibility")
topicsStr := ctx.QueryParam("topics")
topics := strings.Fields(topicsStr)
if len(topics) > 10 {
topics = topics[:10]
}
slices.Sort(topics)
topics = slices.Compact(topics)
pagination.Title = title
pagination.Language = language
pagination.Visibility = visibility
pagination.Topics = topicsStr
ctx.SetData("title", title)
ctx.SetData("language", language)
ctx.SetData("visibility", visibility)
ctx.SetData("topics", topicsStr)
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-from", fromUserStr))
gists, count, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, title, language, visibility, topics, pageInt-1, sort, order)
ctx.SetData("countFromUser", count)
}
}
if err != nil {
return ctx.ErrorRes(500, "Error fetching gists", err)
}
renderedGists := make([]*render.RenderedGist, 0, len(gists))
for _, gist := range gists {
rendered, err := render.HighlightGistPreview(gist)
if err != nil {
log.Error().Err(err).Msg("Error rendering gist preview for " + gist.Identifier() + " - " + gist.PreviewFilename)
}
renderedGists = append(renderedGists, &rendered)
}
if err = handlers.Paginate(ctx, renderedGists, pageInt, 10, "gists", urlPage, 2, pagination); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
return ctx.Html("all.html")
}
func Search(ctx *context.Context) error {
var err error
pagination := &handlers.PaginationParams{
Query: ctx.QueryParam("q"),
}
content, meta := handlers.ParseSearchQueryStr(ctx.QueryParam("q"))
pageInt := handlers.GetPage(ctx)
var currentUserId uint
userLogged := ctx.User
if userLogged != nil {
currentUserId = userLogged.ID
} else {
currentUserId = 0
}
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"],
}, currentUserId, pageInt)
if err != nil {
return ctx.ErrorRes(500, "Error searching gists", err)
}
gists, err := db.GetAllGistsByIds(gistsIds)
if err != nil {
return ctx.ErrorRes(500, "Error fetching gists", err)
}
renderedGists := make([]*render.RenderedGist, 0, len(gists))
for _, gist := range gists {
rendered, err := render.HighlightGistPreview(gist)
if err != nil {
log.Error().Err(err).Msg("Error rendering gist preview for " + gist.Identifier() + " - " + gist.PreviewFilename)
}
renderedGists = append(renderedGists, &rendered)
}
if err = handlers.Paginate(ctx, renderedGists, pageInt, 10, "gists", "search", 2, pagination); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
ctx.SetData("nbHits", nbHits)
ctx.SetData("langs", langs)
ctx.SetData("searchQuery", ctx.QueryParam("q"))
return ctx.Html("search.html")
}

View File

@ -0,0 +1,143 @@
package gist
import (
"github.com/google/uuid"
"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"
"net/url"
"strconv"
"strings"
)
func Create(ctx *context.Context) error {
ctx.SetData("htmlTitle", ctx.TrH("gist.new.create-a-new-gist"))
return ctx.Html("create.html")
}
func ProcessCreate(ctx *context.Context) error {
isCreate := false
if ctx.Request().URL.Path == "/" {
isCreate = true
}
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
if isCreate {
ctx.SetData("htmlTitle", ctx.TrH("gist.new.create-a-new-gist"))
} else {
gist = ctx.GetData("gist").(*db.Gist)
ctx.SetData("htmlTitle", ctx.TrH("gist.edit.edit-gist", gist.Title))
}
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
for i := 0; i < len(ctx.Request().PostForm["content"]); i++ {
name := ctx.Request().PostForm["name"][i]
content := ctx.Request().PostForm["content"][i]
if name == "" {
fileCounter += 1
name = "gistfile" + strconv.Itoa(fileCounter) + ".txt"
}
escapedValue, err := url.PathUnescape(content)
if err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.invalid-character-unescaped"), err)
}
dto.Files = append(dto.Files, db.FileDTO{
Filename: strings.Trim(name, " "),
Content: escapedValue,
})
}
ctx.SetData("dto", dto)
err = ctx.Validate(dto)
if err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
if isCreate {
return ctx.HtmlWithCode(400, "create.html")
} else {
files, err := gist.Files("HEAD", false)
if err != nil {
return ctx.ErrorRes(500, "Error fetching files", err)
}
ctx.SetData("files", files)
return ctx.HtmlWithCode(400, "edit.html")
}
}
if isCreate {
gist = dto.ToGist()
} else {
gist = dto.ToExistingGist(gist)
}
user := ctx.User
gist.NbFiles = len(dto.Files)
if isCreate {
uuidGist, err := uuid.NewRandom()
if err != nil {
return ctx.ErrorRes(500, "Error creating an UUID", err)
}
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
gist.UserID = user.ID
gist.User = *user
}
if gist.Title == "" {
if ctx.Request().PostForm["name"][0] == "" {
gist.Title = "gist:" + gist.Uuid
} else {
gist.Title = ctx.Request().PostForm["name"][0]
}
}
if len(dto.Files) > 0 {
split := strings.Split(dto.Files[0].Content, "\n")
if len(split) > 10 {
gist.Preview = strings.Join(split[:10], "\n")
} else {
gist.Preview = dto.Files[0].Content
}
gist.PreviewFilename = dto.Files[0].Filename
}
if err = gist.InitRepository(); err != nil {
return ctx.ErrorRes(500, "Error creating the repository", err)
}
if err = gist.AddAndCommitFiles(&dto.Files); err != nil {
return ctx.ErrorRes(500, "Error adding and committing files", err)
}
if isCreate {
if err = gist.Create(); err != nil {
return ctx.ErrorRes(500, "Error creating the gist", err)
}
} else {
if err = gist.Update(); err != nil {
return ctx.ErrorRes(500, "Error updating the gist", err)
}
}
gist.AddInIndex()
gist.UpdateLanguages()
return ctx.RedirectTo("/" + user.Username + "/" + gist.Identifier())
}

View File

@ -0,0 +1,18 @@
package gist
import (
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
)
func DeleteGist(ctx *context.Context) error {
gist := ctx.GetData("gist").(*db.Gist)
if err := gist.Delete(); err != nil {
return ctx.ErrorRes(500, "Error deleting this gist", err)
}
gist.RemoveFromIndex()
ctx.AddFlash(ctx.Tr("flash.gist.deleted"), "success")
return ctx.RedirectTo("/")
}

View File

@ -0,0 +1,95 @@
package gist
import (
"archive/zip"
"bytes"
"strconv"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
)
func RawFile(ctx *context.Context) error {
gist := ctx.GetData("gist").(*db.Gist)
file, err := gist.File(ctx.Param("revision"), ctx.Param("file"), false)
if err != nil {
return ctx.ErrorRes(500, "Error getting file content", err)
}
if file == nil {
return ctx.NotFound("File not found")
}
contentType := handlers.GetContentTypeFromFilename(file.Filename)
ContentDisposition := handlers.GetContentDisposition(file.Filename)
ctx.Response().Header().Set("Content-Type", contentType)
ctx.Response().Header().Set("Content-Disposition", ContentDisposition)
return ctx.PlainText(200, file.Content)
}
func DownloadFile(ctx *context.Context) error {
gist := ctx.GetData("gist").(*db.Gist)
file, err := gist.File(ctx.Param("revision"), ctx.Param("file"), false)
if err != nil {
return ctx.ErrorRes(500, "Error getting file content", err)
}
if file == nil {
return ctx.NotFound("File not found")
}
ctx.Response().Header().Set("Content-Type", "text/plain")
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+file.Filename)
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
_, err = ctx.Response().Write([]byte(file.Content))
if err != nil {
return ctx.ErrorRes(500, "Error downloading the file", err)
}
return nil
}
func DownloadZip(ctx *context.Context) error {
gist := ctx.GetData("gist").(*db.Gist)
revision := ctx.Param("revision")
files, err := gist.Files(revision, false)
if err != nil {
return ctx.ErrorRes(500, "Error fetching files from repository", err)
}
if len(files) == 0 {
return ctx.NotFound("No files found in this revision")
}
zipFile := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipFile)
for _, file := range files {
fh := &zip.FileHeader{
Name: file.Filename,
Method: zip.Deflate,
}
f, err := zipWriter.CreateHeader(fh)
if err != nil {
return ctx.ErrorRes(500, "Error adding a file the to the zip archive", err)
}
_, err = f.Write([]byte(file.Content))
if err != nil {
return ctx.ErrorRes(500, "Error adding file content the to the zip archive", err)
}
}
err = zipWriter.Close()
if err != nil {
return ctx.ErrorRes(500, "Error closing the zip archive", err)
}
ctx.Response().Header().Set("Content-Type", "application/zip")
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+gist.Identifier()+".zip")
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(zipFile.Bytes())))
_, err = ctx.Response().Write(zipFile.Bytes())
if err != nil {
return ctx.ErrorRes(500, "Error writing the zip archive", err)
}
return nil
}

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