Compare commits

...

61 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
Thomas Miceli
526da6ccbb v1.8.3 2024-11-26 22:46:25 +01:00
Phani Rithvij
3a4080176c
esbuild for all other platforms (#393)
Signed-off-by: phanirithvij <phanirithvij2000@gmail.com>
2024-11-26 22:38:51 +01:00
Phani Rithvij
64306be2d6
init git config failure -> warn (#392)
* init git config failure -> warn

Signed-off-by: phanirithvij <phanirithvij2000@gmail.com>
2024-11-26 22:28:17 +01:00
Thomas Miceli
8543f3adfa v1.8.2 2024-11-25 22:29:31 +01:00
Thomas Miceli
391ffde12e Update Go deps 2024-11-25 22:20:43 +01:00
Thomas Miceli
3193a9e888
Translations update from Opengist (#373)
* Translated using Weblate (French)

Currently translated at 87.5% (245 of 280 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (280 of 280 strings)

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

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

Currently translated at 100.0% (280 of 280 strings)

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

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

Currently translated at 100.0% (280 of 280 strings)

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

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

Currently translated at 100.0% (280 of 280 strings)

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

* Added translation using Weblate (Polish)

* Translated using Weblate (Polish)

Currently translated at 100.0% (280 of 280 strings)

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

---------

Co-authored-by: Lucas Colombo <lucasncolombo@gmail.com>
Co-authored-by: GabrielxD <gabrielxduo@outlook.com>
Co-authored-by: GGORG <GGORG0@protonmail.com>
2024-11-25 22:08:45 +01:00
Santhosh Raju
58c5ac11c7
Respect file scheme URIs for SQLite. (#387) 2024-11-25 22:07:13 +01:00
Thomas Miceli
6a8e827d61
Fix nits typos and translation (#388) 2024-11-23 17:41:15 +01:00
Thomas Miceli
8f482bce33 Improve Git config 2024-11-23 17:25:58 +01:00
Thomas Miceli
5994cd6ccd
Enforce git config on startup (#383) 2024-11-21 11:23:57 +01:00
Thomas Miceli
00e3d09cc5
Fix escaping for embed gists (#381) 2024-11-18 02:29:05 +01:00
Thomas Miceli
40ff4c7b3f
Fix git clone on SSH with MySQL (#382) 2024-11-17 21:25:59 +01:00
Thomas Miceli
c1e046f428
Convert octal notation file names in Git (#380) 2024-11-17 18:09:44 +01:00
171 changed files with 11031 additions and 5946 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,102 @@
# 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.
### Changed
- Throw `warn` instead of `fatal` on Git global config init failure (#392)
- Define esbuild as a Javascript dependency for all other platforms (#393)
## [1.8.2](https://github.com/thomiceli/opengist/compare/v1.8.1...v1.8.2) - 2024-11-25
See here how to [update](/docs/update.md) Opengist.
### Added
- More translation strings (#373) (#388)
### Changed
- Enforce git config on startup (#383)
- Respect file scheme URIs for SQLite. (#387)
### Fixed
- Convert octal notation file names in Git (#380)
- Git clone on SSH with MySQL (#382)
- Escaping for embed gists (#381)
### Other
- Update deps Golang & JS deps
## [1.8.1](https://github.com/thomiceli/opengist/compare/v1.8.0...v1.8.1) - 2024-11-02
See here how to [update](/docs/update.md) Opengist.

View File

@ -33,8 +33,6 @@ FROM base AS dev
EXPOSE 6157 2222 16157
VOLUME /opengist
RUN git config --global --add safe.directory /opengist
CMD ["make", "watch"]
@ -60,7 +58,7 @@ RUN apk update && \
libstdc++
RUN addgroup -S opengist && \
adduser -S -G opengist -H -s /bin/ash -g 'Opengist User' opengist
adduser -S -G opengist -s /bin/ash -g 'Opengist User' opengist
COPY --from=build --chown=opengist:opengist /opengist/config.yml config.yml

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

View File

@ -17,17 +17,20 @@ opengist-home:
# Secret key used for session store & encrypt MFA data on database. Default: <randomized 32 bytes>
secret-key:
# URI of the database. Default: opengist.db (SQLite)
# SQLite: file name
# URI of the database. Default: opengist.db (SQLite) is placed in opengist-home
# SQLite: file:/path/to/database
# PostgreSQL: postgres://user:password@host:port/database
# 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.1
images:
- name: ghcr.io/thomiceli/opengist
newTag: 1.8.1
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.0</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

@ -5,7 +5,7 @@ By default, Opengist uses SQLite as the database backend.
Because SQLite is a file-based database, there is not much configuration to tweak.
The configuration `db-uri`/`OG_DB_URI` refers to the path of the SQLite database file relative in the `$opengist-home/` directory (default `opengist.db`),
although it can be left untouched.
although it can be left untouched. You can also use an absolute path outside the `$opengist-home/` directory.
The SQLite journal mode is set to [`WAL` (Write-Ahead Logging)](https://www.sqlite.org/pragma.html#pragma_journal_mode) by default and can be changed.
@ -14,6 +14,9 @@ The SQLite journal mode is set to [`WAL` (Write-Ahead Logging)](https://www.sqli
# default
db-uri: opengist.db
sqlite.journal-mode: WAL
# absolute path outside the $opengist-home/ directory
db-uri: file:/home/user/opengist.db
```
#### Environment variable

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

View File

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

View File

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

123
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.2
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.22.1
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/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.5
github.com/prometheus/client_golang v1.21.1
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.6
github.com/yuin/goldmark v1.7.8
github.com/yuin/goldmark-emoji v1.0.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.28.0
golang.org/x/text v0.19.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.9
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.14.3 // indirect
github.com/blevesearch/bleve_index_api v1.1.12 // 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.10 // 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.15 // indirect
github.com/blevesearch/zapx/v16 v16.1.7 // 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.6 // 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-20241009180824-f66d83c29e7c // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/time v0.7.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.33.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
)

374
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.14.3 h1:Gd2c8lSNf9pKXom5JtD7AaKO8o7fGQ2LtFj1436qilA=
github.com/bits-and-blooms/bitset v1.14.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.4.2 h1:NooYP1mb3c0StkiY9/xviiq2LGSaE8BQBCc/pirMx0U=
github.com/blevesearch/bleve/v2 v2.4.2/go.mod h1:ATNKj7Yl2oJv/lGuF4kx39bST2dveX6w0th2FFYLkc8=
github.com/blevesearch/bleve_index_api v1.1.12 h1:P4bw9/G/5rulOF7SJ9l4FsDoo7UFJ+5kexNy1RXfegY=
github.com/blevesearch/bleve_index_api v1.1.12/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.10 h1:HGPJDT2bTva12hrHepVT3rOyIKFFF4t7Gf6yMxyMIPI=
github.com/blevesearch/vellum v1.0.10/go.mod h1:ul1oT0FhSMDIExNjIxHqJoGpVrBpKCdgDQNxfqgJt7k=
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.15 h1:JydcGIq279tmTrfBBSPDF/VOiCMBLQ+rJulTXrGFlGA=
github.com/blevesearch/zapx/v15 v15.3.15/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg=
github.com/blevesearch/zapx/v16 v16.1.7 h1:I07qV6l1rPda19zyof9Q2J9E8cjZ57pQhNY0+ePI5vM=
github.com/blevesearch/zapx/v16 v16.1.7/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.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
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.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.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/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@ -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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/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.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/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.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
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.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
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.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.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()
@ -95,6 +94,10 @@ func Initialize(ctx *cli.Context) {
homePath := config.GetHomeDir()
log.Info().Msg("Data directory: " + homePath)
if err := git.InitGitConfig(); err != nil {
log.Warn().Err(err).Msgf("Failed to change the host's git global config, ensure to add to `safe.directory` the path %s, and `receive.advertisePushOptions` is set to true.", homePath)
}
if err := createSymlink(homePath, ctx.String("config")); err != nil {
log.Fatal().Err(err).Msg("Failed to create symlinks")
}
@ -113,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())
}
}
@ -137,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,25 +47,32 @@ var DatabaseInfo *databaseInfo
func parseDBURI(uri string) (*databaseInfo, error) {
info := &databaseInfo{}
if !strings.Contains(uri, "://") {
info.SSLMode = "disable"
if uri == ":memory:" {
info.Type = SQLite
if uri == "file::memory:" {
info.Database = "file::memory:"
return info, nil
}
info.Database = filepath.Join(config.GetHomeDir(), uri)
info.Database = uri
return info, nil
}
u, err := url.Parse(uri)
if err != nil {
return nil, fmt.Errorf("invalid URI: %v", err)
}
if u.Scheme == "" {
info.Type = SQLite
info.Database = filepath.Join(config.GetHomeDir(), uri)
return info, nil
}
switch u.Scheme {
case "postgres", "postgresql":
info.Type = PostgreSQL
case "mysql", "mariadb":
info.Type = MySQL
case "file":
info.Type = SQLite
default:
return nil, fmt.Errorf("unknown database: %v", err)
}
@ -80,9 +88,18 @@ 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, "/")
case SQLite:
info.Database = u.String()
default:
return nil, fmt.Errorf("unknown database: %v", err)
}
@ -90,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
@ -113,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
@ -137,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
}
@ -182,30 +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)
}
sharedCacheStr := ""
if sharedCache {
sharedCacheStr = "&cache=shared"
}
if dbInfo.Database == ":memory:" {
dsn = ":memory:?_fk=true&cache=shared"
} else {
u, err := url.Parse(dbInfo.Database)
if err != nil {
return err
}
db, err = gorm.Open(sqlite.Open(dbInfo.Database+"?_fk=true&_journal_mode="+journalMode+sharedCacheStr), &gorm.Config{
u.Scheme = "file"
q := u.Query()
q.Set("_pragma", "foreign_keys(1)")
q.Set("_journal_mode", journalMode)
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),
@ -215,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)
@ -241,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,8 +4,10 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path"
@ -37,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" {
@ -115,6 +121,9 @@ func GetFilesOfRepository(user string, gist string, revision string) ([]string,
}
slice := strings.Split(string(stdout), "\n")
for i, s := range slice {
slice[i] = convertOctalToUTF8(s)
}
return slice[:len(slice)-1], nil
}
@ -153,7 +162,7 @@ func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*
fileMap = append(fileMap, &catFileBatch{
Hash: hash,
Size: size,
Name: name,
Name: convertOctalToUTF8(name),
})
}
@ -249,7 +258,7 @@ func GetFileContent(user string, gist string, revision string, filename string,
"git",
"--no-pager",
"show",
revision+":"+filename,
revision+":"+convertURLToOctal(filename),
)
cmd.Dir = repositoryPath
@ -273,7 +282,7 @@ func GetFileSize(user string, gist string, revision string, filename string) (ui
"git",
"cat-file",
"-s",
revision+":"+filename,
revision+":"+convertURLToOctal(filename),
)
cmd.Dir = repositoryPath
@ -481,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)
@ -536,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 {
@ -565,6 +638,48 @@ func removeFilesExceptGit(dir string) error {
})
}
func convertOctalToUTF8(name string) string {
name = strings.Trim(name, `"`)
utf8Name, err := strconv.Unquote(name)
if err != nil {
utf8Name, err = strconv.Unquote(`"` + name + `"`)
if err != nil {
return name
}
}
return utf8Name
}
func convertUTF8ToOctal(name string) string {
if strings.Contains(name, "\\") {
return name
}
needsQuoting := false
for _, r := range name {
if r > 127 {
needsQuoting = true
break
}
}
if !needsQuoting {
return name
}
quoted := fmt.Sprintf("%q", name)
return strings.Trim(quoted, `"`)
}
func convertURLToOctal(name string) string {
decoded, err := url.PathUnescape(name)
if err != nil {
return name
}
return convertUTF8ToOctal(decoded)
}
const hookTemplate = `#!/bin/sh
"$OG_OPENGIST_HOME_INTERNAL/symlinks/opengist" --config=$OG_OPENGIST_HOME_INTERNAL/symlinks/config.yml hook %s
`

View File

@ -61,11 +61,12 @@ func TestContent(t *testing.T) {
"my_other_file.txt": `I really
hate Opengist`,
"rip.txt": "byebye",
"中文名.txt": "中文内容",
})
files, err := GetFilesOfRepository("thomas", "gist1", "HEAD")
require.NoError(t, err, "Could not get files of repository")
require.Subset(t, []string{"my_file.txt", "my_other_file.txt", "rip.txt"}, files, "Files are not correct")
require.Subset(t, []string{"my_file.txt", "my_other_file.txt", "rip.txt", "中文名.txt"}, files, "Files are not correct")
content, truncated, err := GetFileContent("thomas", "gist1", "HEAD", "my_file.txt", false)
require.NoError(t, err, "Could not get content")
@ -77,16 +78,22 @@ hate Opengist`,
require.False(t, truncated, "Content should not be truncated")
require.Equal(t, "I really\nhate Opengist", content, "Content is not correct")
content, truncated, err = GetFileContent("thomas", "gist1", "HEAD", "中文名.txt", false)
require.NoError(t, err, "Could not get content")
require.False(t, truncated, "Content should not be truncated")
require.Equal(t, "中文内容", content, "Content is not correct")
CommitToBare(t, "thomas", "gist1", map[string]string{
"my_renamed_file.txt": "I love Opengist\n",
"my_other_file.txt": `I really
like Opengist actually`,
"new_file.txt": "Wait now there is a new file",
"中文名.txt": "中文内容",
})
files, err = GetFilesOfRepository("thomas", "gist1", "HEAD")
require.NoError(t, err, "Could not get files of repository")
require.Subset(t, []string{"my_renamed_file.txt", "my_other_file.txt", "new_file.txt"}, files, "Files are not correct")
require.Subset(t, []string{"my_renamed_file.txt", "my_other_file.txt", "new_file.txt", "中文名.txt"}, files, "Files are not correct")
content, truncated, err = GetFileContent("thomas", "gist1", "HEAD", "my_other_file.txt", false)
require.NoError(t, err, "Could not get content")

63
internal/git/config.go Normal file
View File

@ -0,0 +1,63 @@
package git
import (
"errors"
"os/exec"
"regexp"
)
type configEntry struct {
value string
fn func(string, string) error
}
func InitGitConfig() error {
configs := map[string]configEntry{
"receive.advertisePushOptions": {value: "true", fn: setGitConfig},
"safe.directory": {value: "*", fn: addGitConfig},
}
for key, entry := range configs {
if err := entry.fn(key, entry.value); err != nil {
return err
}
}
return nil
}
func setGitConfig(key, value string) error {
_, err := getGitConfig(key, value)
if err != nil && !checkErrorCode(err, 1) {
return err
}
cmd := exec.Command("git", "config", "--global", key, value)
return cmd.Run()
}
func addGitConfig(key, value string) error {
_, err := getGitConfig(key, regexp.QuoteMeta(value))
if err == nil {
return nil
}
if checkErrorCode(err, 1) {
cmd := exec.Command("git", "config", "--global", "--add", key, value)
return cmd.Run()
}
return err
}
func getGitConfig(key, value string) (string, error) {
cmd := exec.Command("git", "config", "--global", "--get", key, value)
out, err := cmd.Output()
return string(out), err
}
func checkErrorCode(err error, code int) bool {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode() == code
}
return false
}

View File

@ -193,28 +193,28 @@ loopLog:
case strings.HasPrefix(line, "dissimilarity index"):
continue
case strings.HasPrefix(line, "rename from "):
currentFile.OldFilename = line[12 : len(line)-1]
currentFile.OldFilename = convertOctalToUTF8(line[12 : len(line)-1])
case strings.HasPrefix(line, "rename to "):
currentFile.Filename = line[10 : len(line)-1]
currentFile.Filename = convertOctalToUTF8(line[10 : len(line)-1])
parseRename = false
case strings.HasPrefix(line, "copy from "):
currentFile.OldFilename = line[10 : len(line)-1]
currentFile.OldFilename = convertOctalToUTF8(line[10 : len(line)-1])
case strings.HasPrefix(line, "copy to "):
currentFile.Filename = line[8 : len(line)-1]
currentFile.Filename = convertOctalToUTF8(line[8 : len(line)-1])
parseRename = false
case strings.HasPrefix(line, "new file"):
currentFile.IsCreated = true
case strings.HasPrefix(line, "deleted file"):
currentFile.IsDeleted = true
case strings.HasPrefix(line, "--- "):
name := line[4 : len(line)-1]
name := convertOctalToUTF8(line[4 : len(line)-1])
if parseRename && currentFile.IsDeleted {
currentFile.Filename = name[2:]
} else if parseRename && strings.HasPrefix(name, "a/") {
currentFile.OldFilename = name[2:]
}
case strings.HasPrefix(line, "+++ "):
name := line[4 : len(line)-1]
name := convertOctalToUTF8(line[4 : len(line)-1])
if parseRename && strings.HasPrefix(name, "b/") {
currentFile.Filename = name[2:]
}

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
@ -261,6 +287,7 @@ admin.invitations.code: Code
admin.invitations.copy_link: Copy link
admin.invitations.uses: Uses
admin.invitations.expired: Expired
admin.invitations.delete_confirm: Do you want to delete this invitation ?
flash.admin.user-deleted: User has been deleted
flash.admin.gist-deleted: Gist has been deleted
@ -272,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
@ -302,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

@ -17,8 +17,8 @@ gist.header.clone-http: Clonar via %s
gist.header.clone-http-help: Clonar con Git usando autenticación básica HTTP.
gist.header.clone-ssh: Clonar via SSH
gist.header.clone-ssh-help: Clonar con Git usando una clave SSH.
gist.header.embed: ''
gist.header.embed-help: ''
gist.header.embed: 'Embeber'
gist.header.embed-help: 'Embebe este gist en tu sitio web.'
gist.header.download-zip: Descargar ZIP
gist.raw: Sin formato
@ -71,8 +71,8 @@ gist.forks: Bifurcaciones
gist.forks.view: Ver bifurcación
gist.forks.no: No hay bifurcaciones públicas
gist.likes: Gustos
gist.likes.no: Aún no hay gustos
gist.likes: Todos los me gusta
gist.likes.no: Aún no hay me gusta
gist.revisions: Revisiones
gist.revision.revised: revisó este gist
@ -80,7 +80,7 @@ gist.revision.go-to-revision: Ir a la revisión
gist.revision.file-created: archivo creado
gist.revision.file-deleted: archivo eliminado
gist.revision.file-renamed: renombrado a
gist.revision.diff-truncated: Diferencia truncada porque es demasiado grande para mostrarse.
gist.revision.diff-truncated: Diferencia truncada porque es demasiado grande para mostrarse
gist.revision.file-renamed-no-changes: Archivo renombrado sin cambios
gist.revision.empty-file: Archivo vacío
gist.revision.no-changes: Sin cambios
@ -165,8 +165,8 @@ admin.disable-login: Deshabilitar formulario de inicio de sesión
admin.disable-login_help: Prohibir el inicio de sesión a través del formulario de inicio de sesión para forzar el uso de proveedores de OAuth en su lugar.
admin.disable-gravatar: Deshabilitar Gravatar
admin.disable-gravatar_help: Deshabilitar el uso de Gravatar como proveedor de avatar.
admin.allow-gists-without-login:
admin.allow-gists-without-login_help:
admin.allow-gists-without-login: Permitir gists individuales sin iniciar sesión
admin.allow-gists-without-login_help: Permitir ver y descargar gists individuales sin iniciar sesión, requiriendo iniciar sesión para descubrir gists.
admin.users.delete_confirm: ¿Quieres eliminar a este usuario?
admin.gists.title: Título
@ -174,86 +174,123 @@ admin.gists.private: ¿Privado?
admin.gists.nb-files: Núm. de archivos
admin.gists.nb-likes: Núm. de gustos
admin.gists.delete_confirm: ¿Quieres eliminar este gist?
gist.new.url: ''
gist.new.preview: ''
gist.new.create-a-new-gist: ''
gist.edit.edit-gist: ''
gist.list.all-liked-by: ''
gist.list.all-forked-by: ''
gist.list.all-from: ''
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.forks.for: ''
gist.likes.for: ''
gist.revision-of: ''
settings.link-gitlab-account: ''
settings.unlink-gitlab-account: ''
settings.change-username: ''
settings.create-password: ''
settings.create-password-help: ''
settings.change-password: ''
settings.change-password-help: ''
settings.password-label-title: ''
error.page-not-found: ''
error.bad-request: ''
error.signup-disabled: ''
error.signup-disabled-form: ''
error.login-disabled-form: ''
error.complete-oauth-login: ''
error.oauth-unsupported: ''
error.cannot-bind-data: ''
error.invalid-number: ''
error.invalid-character-unescaped: ''
admin.invitations: ''
admin.invitations.create: ''
admin.actions.sync-previews: ''
admin.actions.reset-hooks: ''
admin.actions.index-gists: ''
admin.config-link-overriden: ''
admin.invitations.help: ''
admin.invitations.max_uses: ''
admin.invitations.expires_at: ''
admin.invitations.code: ''
admin.invitations.copy_link: ''
admin.invitations.uses: ''
admin.invitations.expired: ''
flash.admin.user-deleted: ''
flash.admin.gist-deleted: ''
flash.admin.invitation-created: ''
flash.admin.invitation-deleted: ''
flash.admin.sync-fs: ''
flash.admin.sync-db: ''
flash.admin.git-gc: ''
flash.admin.sync-previews: ''
flash.admin.reset-hooks: ''
flash.admin.index-gists: ''
flash.auth.username-exists: ''
flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: ''
flash.auth.account-unlinked-oauth: ''
flash.auth.user-sshkeys-not-retrievable: ''
flash.auth.user-sshkeys-not-created: ''
flash.auth.must-be-logged-in: ''
flash.gist.visibility-changed: ''
flash.gist.deleted: ''
flash.gist.fork-own-gist: ''
flash.gist.forked: ''
flash.user.email-updated: ''
flash.user.invalid-ssh-key: ''
flash.user.ssh-key-added: ''
flash.user.ssh-key-deleted: ''
flash.user.password-updated: ''
flash.user.username-updated: ''
validation.is-too-long: ''
validation.should-not-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: ''
html.title.admin-panel: ''
gist.new.url: 'URL'
gist.new.preview: 'Previsualizar'
gist.new.create-a-new-gist: 'Crear un nuevo gist'
gist.edit.edit-gist: 'Editar %s'
gist.list.all-liked-by: 'Todos los gists que le gustaron a %s'
gist.list.all-forked-by: 'Todos los gists bifurcados por %s'
gist.list.all-from: 'Todos los gists de %s'
gist.search.found: 'gists encontrados'
gist.search.no-results: 'No se han encontrado gists'
gist.search.help.user: 'gists creados por el usuario'
gist.search.help.title: 'gists con el título indicado'
gist.search.help.filename: 'gists que contienen archivos con el nombre indicado'
gist.search.help.extension: 'gists que contienen archivos con la extensión indicada'
gist.search.help.language: 'gists que contienen archivos con el lenguaje indicado'
gist.forks.for: 'Bifurcacaiones de %s'
gist.likes.for: 'Me gusta para %s'
gist.revision-of: 'Revisión de %s'
settings.link-gitlab-account: 'Vincular cuenta de GitLab'
settings.unlink-gitlab-account: 'Desvincular cuenta de GitLab'
settings.change-username: 'Cambiar nombre de usuario'
settings.create-password: 'Crear contraseña'
settings.create-password-help: 'Crea tu contraseña para acceder a Opengist vía HTTP'
settings.change-password: 'Cambiar contraseña'
settings.change-password-help: 'Cambia tu contraseña para acceder a Opengist vía HTTP'
settings.password-label-title: 'Contraseña'
error.page-not-found: 'Página no encontrada'
error.bad-request: 'Solicitud incorrecta'
error.signup-disabled: 'El registro está deshabilitado'
error.signup-disabled-form: 'El registro mediante el formulario está deshabilitado'
error.login-disabled-form: 'El inicio de sesión mediante el formulario está deshabilitado'
error.complete-oauth-login: 'No se puede completar la autenticación del usuario: %s'
error.oauth-unsupported: 'Proveedor no compatible'
error.cannot-bind-data: 'No se puede vincular los datos'
error.invalid-number: 'Número inválido'
error.invalid-character-unescaped: 'Carácter inválido no escapado'
admin.invitations: 'Invitaciones'
admin.invitations.create: 'Crear invitación'
admin.actions.sync-previews: 'Sincronizar todas las vistas previas de gists'
admin.actions.reset-hooks: 'Resetear los hooks de Git en todos los repositorios'
admin.actions.index-gists: 'Indexar todos los gists'
admin.config-link-overriden: 'sobrescrito'
admin.invitations.help: 'Las invitaciones se pueden usar para crear una cuenta aunque el registro esté deshabilitado.'
admin.invitations.max_uses: 'Cantidad máxima de usos'
admin.invitations.expires_at: 'Expira el'
admin.invitations.code: 'Código'
admin.invitations.copy_link: 'Copiar vínculo'
admin.invitations.uses: 'Usos'
admin.invitations.expired: 'Expirado'
flash.admin.user-deleted: 'El usuario ha sido eliminado'
flash.admin.gist-deleted: 'El gist ha sido eliminado'
flash.admin.invitation-created: 'La invitación ha sido creada'
flash.admin.invitation-deleted: 'La invitación ha sido eliminada'
flash.admin.sync-fs: 'Sincronizando repositorios desde el sistema de archivos...'
flash.admin.sync-db: 'Sincronizando repositorios desde la base de datos...'
flash.admin.git-gc: 'Recolectando basura en los repositorios...'
flash.admin.sync-previews: 'Sincronizando vistas previas de gists...'
flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...'
flash.admin.index-gists: 'Indexando todos los gists...'
flash.auth.username-exists: 'El nombre de usuario ya existe'
flash.auth.invalid-credentials: 'Credenciales incorrectas'
flash.auth.account-linked-oauth: 'Cuenta vinculada a %s'
flash.auth.account-unlinked-oauth: 'Cuenta desvinculada de %s'
flash.auth.user-sshkeys-not-retrievable: 'No se pudo obtener las claves del usuario'
flash.auth.user-sshkeys-not-created: 'No se pudo crear la aclave ssh'
flash.auth.must-be-logged-in: 'Debes estar logueaado para acceder a los gists'
flash.gist.visibility-changed: 'La visibilidad del Gist ha sido modificada'
flash.gist.deleted: 'El gist fue eliminado'
flash.gist.fork-own-gist: 'No se puede bifurcar gists propios'
flash.gist.forked: 'El gist ha sido bifurcado'
flash.user.email-updated: 'Correo actualizado'
flash.user.invalid-ssh-key: 'Clave SSH inválida'
flash.user.ssh-key-added: 'Clave SSH añadida'
flash.user.ssh-key-deleted: 'Clave SSH eliminada'
flash.user.password-updated: 'Contraseña actualizada'
flash.user.username-updated: 'Nombre de usuario actualizado'
validation.is-too-long: 'El campo %s es demasiado largo'
validation.should-not-be-empty: 'El campo %s no puede estar vacío'
validation.should-not-include-sub-directory: 'El campo %s no puede incluir un sub directorio'
validation.should-only-contain-alphanumeric-characters: 'El campo %s solo puede contener caracteres alfanuméricos'
validation.should-only-contain-alphanumeric-characters-and-dashes: 'El campo %s solo puede contener caracteres alfanuméricos y guiones'
validation.not-enough: 'No hay suficiente %s'
validation.invalid: '%s inválido'
html.title.admin-panel: 'Panel de administración'
auth.mfa: Autenticación mult-factor
auth.mfa.passkey: Clave de acceso
auth.mfa.passkeys: Claves de acceso
auth.mfa.use-passkey: Utilizar clave de acceso
auth.mfa.bind-passkey: Vincular clave de acceso
auth.mfa.login-with-passkey: Ingresar con clave de acceso
auth.mfa.waiting-for-passkey-input: Esperando interacción del navegador...
auth.mfa.use-passkey-to-finish: Usa una clave de acceso para completar la autenticación
auth.mfa.passkeys-help: Agrega una clave de acceso para iniciar sesión en tu cuenta y usarla como método MFA.
auth.mfa.passkey-name: Nombre
auth.mfa.delete-passkey: Eliminar
auth.mfa.passkey-added-at: Agregado
auth.mfa.passkey-never-used: Nunca utilizado
auth.mfa.passkey-last-used: Último uso
auth.mfa.delete-passkey-confirm: Confirmar eliminación de clave de acceso
auth.totp.enter-recovery-key: o una clave de recuperación si perdiste tu dispositivo
auth.totp.code: Código
auth.totp.submit: Enviar
auth.totp.proceed: Proceder
auth.totp.save-recovery-codes: Guarda tus códigos de recuperación en un lugar seguro. Puedes usarlos para recuperar el acceso a tu cuenta si pierdes el acceso a tu app de autenticación.
auth.totp.scan-qr-code: Escanea el código QR con tu app de autenticación para habilitar la autenticación de doble factor o ingresa la siguiente cadena y confirma con el código generado.
error.not-in-mfa-session: El usuario no está en una sesión MFA
settings.ssh-key-exists: La clave SSH ya existe
auth.totp: Contraseña de un solo uso basada en tiempo (TOTP)
auth.totp.help: TOTP es un método de autenticación doble factor que utiliza una clave compartida para generar una contraseña de un solo uso.
auth.totp.use: Usar TOTP
auth.totp.regenerate-recovery-codes: Regenerar códigos de recuperación
auth.totp.already-enabled: TOTP ya está habilitado
auth.totp.invalid-secret: Clave TOTP inválido
auth.totp.invalid-code: Código TOTP inválido
auth.totp.code-used: El código de recuperación %s fue utilizado, ahora es inválido. Puedes desactivar MFA por ahora o regenerar tus códigos.
auth.totp.disabled: TOTP deshabilitado exitosamente
auth.totp.disable: Deshabilitar TOTP
auth.totp.enter-code: Ingresa el código de la app de autenticación
gist.delete.confirm: ¿Estás seguro que deseas eliminar este gist?
flash.auth.passkey-registred: Clave de acceso %s registrada
flash.auth.passkey-deleted: Clave de acceso eliminada

View File

@ -258,3 +258,4 @@ validation.not-enough: 'Pas assez de %s'
validation.invalid: '%s non valide'
html.title.admin-panel: 'Administration'
settings.ssh-key-exists: La clé SSH existe déjà
gist.delete.confirm: Voulez-vous supprimer ce Gist ?

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

@ -0,0 +1,307 @@
gist.public: 'Publiczny'
gist.unlisted: 'Niepubliczny'
gist.private: 'Prywatny'
gist.header.like: 'Polub'
gist.header.unlike: 'Cofnij polubienie'
gist.header.fork: 'Zforkuj'
gist.header.edit: 'Edytuj'
gist.header.delete: 'Usuń'
gist.header.forked-from: 'Zforkowane z'
gist.header.last-active: 'Ostatnio aktywny'
gist.header.select-tab: 'Wybierz kartę'
gist.header.code: 'Kod'
gist.header.revisions: 'Rewizje'
gist.header.revision: 'Rewizja'
gist.header.clone-http: 'Sklonuj za pomocą %s'
gist.header.clone-http-help: 'Sklonuj za pomocą Git używając podstawowej autoryzacji HTTP.'
gist.header.clone-ssh: 'Sklonuj za pomocą SSH'
gist.header.clone-ssh-help: 'Sklonuj za pomocą Git używając klucza SSH.'
gist.header.embed: 'Osadź'
gist.header.embed-help: 'Wstaw ten Gist na twoją stronę.'
gist.header.download-zip: 'Pobierz ZIP'
gist.raw: 'Surowy'
gist.file-truncated: 'Ten plik został przycięty.'
gist.watch-full-file: 'Zobacz pełny plik.'
gist.file-not-valid: 'Ten plik nie jest poprawnym plikiem CSV.'
gist.no-content: 'Nie znaleziono plików'
gist.new.new_gist: 'Nowy Gist'
gist.new.title: 'Tytuł'
gist.new.description: 'Opis'
gist.new.url: 'URL'
gist.new.filename-with-extension: 'Nazwa pliku z rozszerzeniem'
gist.new.indent-mode: 'Tryb wcięcia'
gist.new.indent-mode-space: 'Spacje'
gist.new.indent-mode-tab: 'Tabulatory'
gist.new.indent-size: 'Wielkość wcięcia'
gist.new.wrap-mode: 'Tryb zawijania'
gist.new.wrap-mode-no: 'Bez zawijania'
gist.new.wrap-mode-soft: 'Miękkie zawijanie'
gist.new.add-file: 'Dodaj plik'
gist.new.create-public-button: 'Stwórz publiczny Gist'
gist.new.create-unlisted-button: 'Stwórz niepubliczny Gist'
gist.new.create-private-button: 'Stwórz prywatny Gist'
gist.new.preview: 'Podgląd'
gist.new.create-a-new-gist: 'Stwórz nowy Gist'
gist.edit.editing: 'Edytowanie'
gist.edit.edit-gist: 'Edytuj %s'
gist.edit.change-visibility: 'Zmień widoczność na'
gist.edit.delete: 'Usuń'
gist.edit.cancel: 'Anuluj'
gist.edit.save: 'Zapisz'
gist.delete.confirm: 'Czy na pewno chcesz usunąć ten Gist?'
gist.list.joined: 'Dołączono'
gist.list.all: 'Wszystkie Gisty'
gist.list.search-results: 'Wyniki wyszukiwania'
gist.list.sort: 'Sortuj'
gist.list.sort-by-created: 'utworzono'
gist.list.sort-by-updated: 'zaktualizowano'
gist.list.order-by-asc: 'Najdawniej'
gist.list.order-by-desc: 'Ostatnio'
gist.list.select-tab: 'Wybierz kartę'
gist.list.liked: 'Polubiane'
gist.list.likes: 'polubień'
gist.list.forked: 'Zforkowane'
gist.list.forked-from: 'Zforkowane z'
gist.list.forks: 'forków'
gist.list.files: 'plików'
gist.list.last-active: 'Ostatnio aktywne'
gist.list.no-gists: 'Brak Gistów'
gist.list.all-liked-by: 'Wszystkie Gisty polubione przez %s'
gist.list.all-forked-by: 'Wszystkie Gisty zforkowane przez %s'
gist.list.all-from: 'Wszystkie Gisty od %s'
gist.search.found: 'Gistów znaleziono'
gist.search.no-results: 'Nie znaleziono żadnych Gistów'
gist.search.help.user: 'Gisty stworzone przez użytkownika'
gist.search.help.title: 'Gisty z podanym tytułem'
gist.search.help.filename: 'Gisty zawierające pliki z podanym tytułem'
gist.search.help.extension: 'Gisty zawierające pliki z podanym rozszerzeniem'
gist.search.help.language: 'Gisty zawierające pliki z podanym językiem'
gist.forks: 'Forki'
gist.forks.view: 'Zobacz forka'
gist.forks.no: 'Brak publicznych forków'
gist.forks.for: 'Forki dla %s'
gist.likes: 'Polubienia'
gist.likes.no: 'Brak polubień'
gist.likes.for: 'Polubienia dla %s'
gist.revisions: 'Rewizje'
gist.revision.revised: 'zrewidował ten Gist'
gist.revision.go-to-revision: 'Przejdź do rewizji'
gist.revision.file-created: 'stworzono plik'
gist.revision.file-deleted: 'usunięto plik'
gist.revision.file-renamed: 'zmieniono nazwę na'
gist.revision.diff-truncated: 'Porównanie jest za duże do pokazania'
gist.revision.file-renamed-no-changes: 'Zmieniono nazwę pliku bez modyfikacji zawartości'
gist.revision.empty-file: 'Pusty plik'
gist.revision.no-changes: 'Brak zmian'
gist.revision.no-revisions: 'Brak rewizji do pokazania'
gist.revision-of: 'Rewizja %s'
settings: 'Ustawienia'
settings.email: 'Email'
settings.email-help: 'Używany do commitów i Gravatar'
settings.email-set: 'Ustaw email'
settings.link-accounts: 'Połącz konta'
settings.link-github-account: 'Połącz konto GitHub'
settings.link-gitlab-account: 'Połącz konto GitLab'
settings.link-gitea-account: 'Połącz konto Gitea'
settings.unlink-github-account: 'Odłącz konto GitHub'
settings.unlink-gitlab-account: 'Odłącz konto GitLab'
settings.unlink-gitea-account: 'Odłącz konto Gitea'
settings.delete-account: 'Usuń konto'
settings.delete-account-confirm: 'Czy na pewno chcesz usunąć swoje konto?'
settings.add-ssh-key: 'Dodaj klucz SSH'
settings.add-ssh-key-help: 'Używany tylko do operacji pull/push za pomocą Git przez SSH'
settings.add-ssh-key-title: 'Tytuł'
settings.add-ssh-key-content: 'Klucz'
settings.delete-ssh-key: 'Usuń'
settings.delete-ssh-key-confirm: 'Potwierdź usunięcie klucza SSH'
settings.ssh-key-added-at: 'Dodany'
settings.ssh-key-never-used: 'Nigdy nie użyty'
settings.ssh-key-last-used: 'Ostatnio użyty'
settings.ssh-key-exists: 'Klucz SSH już istnieje'
settings.change-username: 'Zmień nazwę użytkownika'
settings.create-password: 'Stwórz hasło'
settings.create-password-help: 'Stwórz swoje hasło do logowania się do Opengist przez HTTP'
settings.change-password: 'Zmień hasło'
settings.change-password-help: 'Zmień swoje hasło do logowania się do Opengist przez HTTP'
settings.password-label-title: 'Hasło'
auth.signup-disabled: 'Rejestracja została wyłączona przez administratora'
auth.login: 'Zaloguj się'
auth.signup: 'Zarejestruj się'
auth.new-account: 'Nowe konto'
auth.username: 'Nazwa użytkownika'
auth.password: 'Hasło'
auth.register-instead: 'Lub zarejestruj się'
auth.login-instead: 'Lub zaloguj się'
auth.oauth: 'Kontynuuj z kontem %s'
auth.mfa: 'Weryfikacja wieloskładnikowa'
auth.mfa.passkey: 'Klucz Passkey'
auth.mfa.passkeys: 'Klucze Passkey'
auth.mfa.use-passkey: 'Użyj klucza Passkey'
auth.mfa.bind-passkey: 'Powiąż klucz Passkey'
auth.mfa.login-with-passkey: 'Zaloguj się za pomocą klucza Passkey'
auth.mfa.waiting-for-passkey-input: 'Oczekiwanie na wejście z interakcji przeglądarki...'
auth.mfa.use-passkey-to-finish: 'Użyj klucza Passkey aby dokończyć logowanie'
auth.mfa.passkeys-help: 'Dodaj klucz Passkey aby logować się nim do swojego konta i aby używać go jako weryfikacji wieloskładnikowej.'
auth.mfa.passkey-name: 'Nazwa'
auth.mfa.delete-passkey: 'Usuń'
auth.mfa.passkey-added-at: 'Dodany'
auth.mfa.passkey-never-used: 'Nigdy nie użyty'
auth.mfa.passkey-last-used: 'Ostatnio użyty'
auth.mfa.delete-passkey-confirm: 'Potwierdź usunięcie klucza Passkey'
auth.totp: 'Time based one-time password (TOTP)'
auth.totp.help: 'TOTP to metoda weryfikacji dwuskładnikowej, która używa współdzielonego sekretu do generowania hasła jednorazowego użytku.'
auth.totp.use: 'Użyj TOTP'
auth.totp.regenerate-recovery-codes: 'Wygeneruje ponownie kody odzyskiwania'
auth.totp.already-enabled: 'TOTP jest już włączone'
auth.totp.invalid-secret: 'Niepoprawny sekret TOTP'
auth.totp.invalid-code: 'Niepoprawny kod TOTP'
auth.totp.code-used: 'Kod odzyskiwania %s został użyty, jest teraz nieważny. Możesz chcieć wyłączyć weryfikację wieloskładnikową na teraz lub wygenerować swoje kody ponownie.'
auth.totp.disabled: 'TOTP zostało pomyślnie wyłączone'
auth.totp.disable: 'Wyłącz TOTP'
auth.totp.enter-code: 'Wpisz kod z aplikacji uwierzytelniajacej'
auth.totp.enter-recovery-key: 'lub kod odzyskiwania jeśli zgubiłeś swoje urządzenie'
auth.totp.code: 'Kod'
auth.totp.submit: 'Prześlij'
auth.totp.proceed: 'Dalej'
auth.totp.save-recovery-codes: 'Zapis swoje kody odzyskiwania w bezpiecznym miejscu. Możesz użyć tych kodów, aby odzyskać dostęp do swojego konta jeśli stracisz dostęp do swojej aplikacji uwierzytelniajacej.'
auth.totp.scan-qr-code: 'Zeskanuj kod QR poniżej używając swojej aplikacji uwierzytelniajacej, aby włączyć weryfikację dwuskładnikową lub wpisz następujący ciąg i potwierdź go wygenerowanym kodem.'
error: 'Błąd'
error.page-not-found: 'Nie znaleziono strony'
error.bad-request: 'Złe żądanie'
error.signup-disabled: 'Rejestracja jest wyłączona'
error.signup-disabled-form: 'Rejestracja za pomocą formularza rejestracyjnego jest wyłączona'
error.login-disabled-form: 'Logowanie za pomocą formularza logowania jest wyłączone'
error.complete-oauth-login: "Nie można ukończyć logowania: %s"
error.oauth-unsupported: 'Niewspierany dostawca'
error.cannot-bind-data: 'Nie można powiązać danych'
error.invalid-number: 'Niepoprawna liczba'
error.invalid-character-unescaped: 'Nieprawidłowy niechroniony znak'
error.not-in-mfa-session: 'Użytkownik nie jest w sesji uwierzytelnienia wieloskładnikowego'
header.menu.all: 'Wszystko'
header.menu.new: 'Nowy'
header.menu.search: 'Szukaj'
header.menu.my-gists: 'Moje Gisty'
header.menu.liked: 'Polubione'
header.menu.admin: 'Admin'
header.menu.settings: 'Ustawienia'
header.menu.logout: 'Wyloguj się'
header.menu.register: 'Zarejestruj się'
header.menu.login: 'Zaloguj się'
header.menu.light: 'Jasny'
header.menu.dark: 'Ciemny'
header.menu.system: 'Systemowy'
footer.powered-by: 'Obsługiwane przez %s'
pagination.older: 'Starsze'
pagination.newer: 'Nowsze'
pagination.previous: 'Poprzedni'
pagination.next: 'Nastepny'
admin.admin_panel: 'Panel administracyjny'
admin.general: 'Ogólne'
admin.users: 'Użytkownicy'
admin.gists: 'Gisty'
admin.configuration: 'Konfiguracja'
admin.invitations: 'Zaproszenia'
admin.invitations.create: 'Stwórz zaproszenie'
admin.versions: 'Wersje'
admin.ssh_keys: 'Klucze SSH'
admin.stats: 'Statystyki'
admin.actions: 'Akcje'
admin.actions.sync-fs: 'Synchronizuj Gisty z systemu plików'
admin.actions.sync-db: 'Synchronizuj Gisty z bazy danych'
admin.actions.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git'
admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów'
admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytoriów'
admin.actions.index-gists: 'Indeksuj wszystkie Gisty'
admin.id: 'ID'
admin.user: 'Użytkownik'
admin.delete: 'Usuń'
admin.created_at: 'Utworzono'
admin.config-link: 'Ta konfiguracja może zostać %s przez plik konfiguracyjny YAML i/lub zmienne środowiskowe.'
admin.config-link-overriden: 'nadpisana'
admin.disable-signup: 'Wyłącz rejestrację'
admin.disable-signup_help: 'Zabroń tworzenia nowych kont.'
admin.require-login: 'Wymagaj logowania'
admin.require-login_help: 'Wymagaj od użytkowników zalogowania się, aby mogli zobaczyć Gisty.'
admin.allow-gists-without-login: 'Zezwól na indywidualne Gisty bez logowania'
admin.allow-gists-without-login_help: 'Zezwalaj na przeglądanie i pobieranie pojedynczych Gistów bez logowania, ale wymagaj zalogowania się w celu odkrywania Gistów.'
admin.disable-login: 'Wyłącz formularz logowania'
admin.disable-login_help: 'Zabroń logowania się za pomocą formularza logowania, aby wymusić korzystanie z dostawców OAuth.'
admin.disable-gravatar: 'Wyłącz Gravatar'
admin.disable-gravatar_help: 'Wyłącz używanie Gravatar jako dostawcy awatarów.'
admin.users.delete_confirm: 'Czy chcesz usunąć tego użytkownika?'
admin.gists.title: 'Tytuł'
admin.gists.private: 'Prywatny?'
admin.gists.nb-files: 'Liczba plików'
admin.gists.nb-likes: 'Liczba polubień'
admin.gists.delete_confirm: 'Czy chcesz usunąć tego Gista?'
admin.invitations.help: 'Zaproszenia mogą być używane, aby stworzyć konto nawet, jeśli rejestracja jest wyłączona.'
admin.invitations.max_uses: 'Maksymalna liczba użyć'
admin.invitations.expires_at: 'Wygasa'
admin.invitations.code: 'Kod'
admin.invitations.copy_link: 'Kopiuj link'
admin.invitations.uses: 'Użyć'
admin.invitations.expired: 'Wygasło'
flash.admin.user-deleted: 'Użytkownik został usunięty'
flash.admin.gist-deleted: 'Gist został usunięty'
flash.admin.invitation-created: 'Zaproszenie zostało stworzone'
flash.admin.invitation-deleted: 'Zaproszenie zostało usunięte'
flash.admin.sync-fs: 'Synchronizowanie repozytoriów z systemu plików...'
flash.admin.sync-db: 'Synchronizowanie repozytoriów z bazy danych...'
flash.admin.git-gc: 'Zbieranie śmieci w repozytoriach...'
flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...'
flash.admin.reset-hooks: 'Resetowanie hooków serwera Git dla wszystkich repozytoriów...'
flash.admin.index-gists: 'Indeksowanie wszystkich Gistów...'
flash.auth.username-exists: 'Nazwa użytkownika już istnieje'
flash.auth.invalid-credentials: 'Niepoprawne dane logowania'
flash.auth.account-linked-oauth: 'Konto połączone z %s'
flash.auth.account-unlinked-oauth: 'Konto odłączone od %s'
flash.auth.user-sshkeys-not-retrievable: 'Nie można uzyskać kluczy użytkownika'
flash.auth.user-sshkeys-not-created: 'Nie można stworzyć klucza SSH'
flash.auth.must-be-logged-in: 'Musisz być zalogowany, aby widzieć Gisty'
flash.auth.passkey-registred: 'Zarejestrowano klucz Passkey %s'
flash.auth.passkey-deleted: 'Usunięto klucz Passkey'
flash.gist.visibility-changed: 'Widoczność Gista została zmieniona'
flash.gist.deleted: 'Gist został usunięty'
flash.gist.fork-own-gist: 'Nie można forkować własnych Gistów'
flash.gist.forked: 'Gist został zforkowany'
flash.user.email-updated: 'Email został zaktualizowany'
flash.user.invalid-ssh-key: 'niepoprawny klucz SSH'
flash.user.ssh-key-added: 'Dodano klucz SSH'
flash.user.ssh-key-deleted: 'Usunięto klucz SSH'
flash.user.password-updated: 'Zaktualizowano hasło'
flash.user.username-updated: 'Zaktualizowano nazwę użytkownika'
validation.is-too-long: 'Pole %s jest za długie'
validation.should-not-be-empty: 'Pole %s nie może być puste'
validation.should-not-include-sub-directory: 'Pole %s nie może zawierać podfolderu'
validation.should-only-contain-alphanumeric-characters: 'Pole %s może tylko zawierać znaki alfanumeryczne'
validation.should-only-contain-alphanumeric-characters-and-dashes: 'Pole %s może tylko zawierać znaki alfanumeryczne i myślniki'
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

@ -1,6 +1,6 @@
gist.public: 公开
gist.unlisted: 非列出
gist.private:
gist.private:
gist.header.like: 喜欢
gist.header.unlike: 取消喜欢
@ -14,11 +14,11 @@ gist.header.code: 代码
gist.header.revisions: 修订
gist.header.revision: 修订
gist.header.clone-http: 通过 %s 克隆
gist.header.clone-http-help: 使用 Git 通过 HTTP 基础认证克隆。
gist.header.clone-http-help: 使用 HTTP 基础认证通过 Git 克隆。
gist.header.clone-ssh: 通过 SSH 克隆
gist.header.clone-ssh-help: 使用 Git 通过 SSH 密钥克隆。
gist.header.clone-ssh-help: 使用 SSH 密钥通过 Git 克隆。
gist.header.embed: '嵌入'
gist.header.embed-help: '在你的网页中嵌入此gist。'
gist.header.embed-help: '将此 Gist 嵌入到您的网站。'
gist.header.download-zip: 下载 ZIP
gist.raw: 原始文件
@ -27,10 +27,10 @@ gist.watch-full-file: 查看完整文件。
gist.file-not-valid: 此文件不是有效的 CSV 文件。
gist.no-content: 没有内容
gist.new.new_gist: 建 Gist
gist.new.new_gist: 建 Gist
gist.new.title: 标题
gist.new.description: 描述
gist.new.filename-with-extension: 文件名与扩展
gist.new.filename-with-extension: 带扩展名的文件名
gist.new.indent-mode: 缩进模式
gist.new.indent-mode-space: 空格
gist.new.indent-mode-tab: 制表符
@ -39,17 +39,17 @@ 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.create-public-button: 创建公开 Gist
gist.new.create-unlisted-button: 创建非列出 Gist
gist.new.create-private-button: 创建私密的 Gist
gist.edit.editing: 编辑
gist.edit.editing: 正在编辑
gist.edit.change-visibility: 设为
gist.edit.delete: 删除
gist.edit.cancel: 取消
gist.edit.save: 保存
gist.list.joined: 加入
gist.list.joined: 加入
gist.list.all: 所有 Gists
gist.list.search-results: 搜索结果
gist.list.sort: 排序
@ -65,30 +65,30 @@ gist.list.forked-from: 派生自
gist.list.forks: 派生
gist.list.files: 文件
gist.list.last-active: 最后活跃于
gist.list.no-gists: 没有 Gist
gist.list.no-gists: 没有任何 Gist
gist.forks: 派生
gist.forks.view: 查看派生
gist.forks.no: 无公开派生
gist.forks.no: 没有任何公开的派生
gist.likes: 喜欢
gist.likes.no: 还没有喜欢
gist.likes.no: 目前还没有任何喜欢
gist.revisions: 修订
gist.revision.revised: 修订了这个 Gist
gist.revision.go-to-revision: 跳至此修订
gist.revision.go-to-revision: 转到此修订
gist.revision.file-created: 文件已创建
gist.revision.file-deleted: 文件已删除
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.diff-truncated: 差异内容过多而无法显示
gist.revision.file-renamed-no-changes: 文件已重命名,但内容与之前没有差异
gist.revision.empty-file: 文件为空
gist.revision.no-changes: 没有任何变更
gist.revision.no-revisions: 无可供显示的修订
settings: 设置
settings.email: 邮箱
settings.email-help: 用于提交与 Gravatar
settings.email: 电子邮箱
settings.email-help: 用于提交和 Gravatar 头像
settings.email-set: 设置邮箱地址
settings.link-accounts: 关联账号
settings.link-github-account: 关联 GitHub 账号
@ -98,16 +98,16 @@ settings.unlink-gitea-account: 解除关联 Gitea 账号
settings.delete-account: 删除账号
settings.delete-account-confirm: 您确认要删除您的账号吗?
settings.add-ssh-key: 添加 SSH 密钥
settings.add-ssh-key-help: 用于使用 Git 通过 SSH 拉取与推送 Gist
settings.add-ssh-key-title: 标题
settings.add-ssh-key-help: 用于使用 Git 通过 SSH 拉取与推送 Gist
settings.add-ssh-key-title: 名称
settings.add-ssh-key-content: 密钥
settings.delete-ssh-key: 删除
settings.delete-ssh-key-confirm: 确认删除 SSH 密钥
settings.ssh-key-added-at: 添加
settings.ssh-key-added-at: 添加
settings.ssh-key-never-used: 从未使用过
settings.ssh-key-last-used: 最后使用于
auth.signup-disabled: 管理员已禁用注册
auth.signup-disabled: 管理员已禁用注册
auth.login: 登录
auth.signup: 注册
auth.new-account: 新建账号
@ -120,7 +120,7 @@ auth.oauth: 使用 %s 账号继续
error: 错误
header.menu.all: 全部
header.menu.new:
header.menu.new:
header.menu.search: 搜索
header.menu.my-gists: 我的 Gists
header.menu.liked: 喜欢的 Gists
@ -129,13 +129,13 @@ header.menu.settings: 设置
header.menu.logout: 登出
header.menu.register: 注册
header.menu.login: 登录
header.menu.light: 亮色
header.menu.dark: 暗色
header.menu.system: 系统
header.menu.light: 亮色模式
header.menu.dark: 暗色模式
header.menu.system: 跟随系统
footer.powered-by: 由 %s 强力驱动
pagination.older: 更早
pagination.newer: 更新
pagination.older: 下一页
pagination.newer: 上一页
pagination.previous: 上一页
pagination.next: 下一页
@ -156,92 +156,92 @@ admin.user: 用户
admin.delete: 删除
admin.created_at: 创建于
admin.config-link: 此配置可通过 YAML 配置和/或环境变量进行 %s 。
admin.config-link: 此配置可通过 YAML 配置文件和/或环境变量进行 %s 。
admin.config-link-overriden: 覆盖
admin.disable-signup: 用注册
admin.disable-signup_help: 止创建新的账号。
admin.require-login: 要求登录
admin.disable-signup: 注册
admin.disable-signup_help: 止创建新的账号。
admin.require-login: 登录访问限制
admin.require-login_help: 强制用户登录后才能查看 Gist。
admin.disable-login: 禁用登录表单
admin.disable-login_help: 禁止使用登录表单进行登录以强制通过 OAuth 提供方登录。
admin.disable-gravatar: 禁用 Gravatar
admin.disable-gravatar_help: 停止使用 Gravatar 作为头像提供方。
admin.allow-gists-without-login: 允许未登录状态下输入个人 gists
admin.allow-gists-without-login_help: 允许在不登录的情况下查看和下载 gist同时需要登录才能使用 gists 的发现功能。
admin.users.delete_confirm: 想要删除此用户吗?
admin.allow-gists-without-login: 允许未登录状态下访问单个 Gists
admin.allow-gists-without-login_help: 允许在不登录的情况下查看和下载 Gist同时需要登录才能使用 Gists 的发现功能。
admin.users.delete_confirm: 想要删除此用户吗?
admin.gists.title: 标题
admin.gists.private: 私有?
admin.gists.private: 访问权限
admin.gists.nb-files: 文件数
admin.gists.nb-likes: 喜欢数
admin.gists.delete_confirm: 想要删除此 Gist 吗?
gist.new.url: 'URL'
gist.new.preview: '预览 gist'
admin.gists.delete_confirm: 想要删除此 Gist 吗?
gist.new.url: '自定义 URL 路径名'
gist.new.preview: '预览'
error.page-not-found: '页面未找到'
gist.new.create-a-new-gist: '创建一个新的gist'
gist.new.create-a-new-gist: '创建一个新的 Gist'
gist.edit.edit-gist: '编辑 %s'
gist.list.all-liked-by: '所有 gists 被 %s 标记喜欢'
gist.list.all-forked-by: '所有 gists 被 %s 派生'
gist.list.all-from: '所有 gists 来自于 %s'
gist.search.found: '以下是找到的 gists'
gist.search.no-results: '没有找到gist'
gist.search.help.user: '由用户创建的gist'
gist.search.help.title: '包含指定标题的 gists'
gist.search.help.filename: 'gists 文件中包含指定名称'
gist.search.help.extension: 'gists 文件中包含指定插件'
gist.search.help.language: 'gists 文件中包含指定的开发语言'
gist.forks.for: '派生到 %s'
gist.likes.for: '喜欢给 %s'
gist.revision-of: '%s 修订'
gist.list.all-liked-by: '%s 喜欢的所有 Gists'
gist.list.all-forked-by: '%s 派生的所有 Gists'
gist.list.all-from: '来自于 %s 的所有 Gists'
gist.search.found: '个已找到的 Gist(s)'
gist.search.no-results: '没有找到任何 Gist'
gist.search.help.user: '由用户创建的 Gists'
gist.search.help.title: '包含指定标题的 Gists'
gist.search.help.filename: '包含指定文件名的 Gists'
gist.search.help.extension: '包含指定扩展名的 Gists'
gist.search.help.language: '包含指定编程语言的 Gists'
gist.forks.for: '%s 的派生'
gist.likes.for: '%s 的喜欢'
gist.revision-of: '%s 修订'
settings.link-gitlab-account: '关联 GitLab 账号'
settings.unlink-gitlab-account: '解除关联 GitLab 账号'
settings.change-username: '修改用户名'
settings.create-password: '创建密码'
settings.create-password-help: '创建密码用于 HTTP 方式登录 Opengist'
settings.create-password-help: '创建您的密码以通过 HTTP 的方式登录到 Opengist'
settings.change-password: '修改密码'
settings.change-password-help: '修改您的密码用于 HTTP 方式登录 Opengist'
settings.change-password-help: '修改您通过 HTTP 登录到 Opengist 的密码'
settings.password-label-title: '密码'
error.bad-request: '请求错误'
error.signup-disabled: '注册功能已被管理员禁用'
error.signup-disabled-form: '已禁用了表单注册功能'
error.login-disabled-form: '已禁用了表单登录功能'
error.complete-oauth-login: '用户认证未能通过: %s'
error.signup-disabled: '注册已被禁用'
error.signup-disabled-form: '通过用户名密码注册禁用'
error.login-disabled-form: '通过用户名密码登录禁用'
error.complete-oauth-login: '用户认证未能通过:%s'
error.oauth-unsupported: '不支持的认证提供商'
error.cannot-bind-data: '无法绑定数据'
error.invalid-number: '数字格式不正确'
error.invalid-character-unescaped: '包含未转义的无效字符'
admin.invitations: '邀请'
admin.invitations.create: '创建邀请'
admin.actions.sync-previews: '同步所有 gists 预览'
admin.actions.reset-hooks: '重置所有存储库的 Git 服务hooks'
admin.actions.index-gists: '索引所有 gists'
admin.actions.sync-previews: '同步所有 Gists 预览'
admin.actions.reset-hooks: '重置所有存储库的 Git 服务 hooks'
admin.actions.index-gists: '索引所有 Gists'
admin.invitations.help: '即使在禁用注册功能的情况下,邀请功能也可用于创建帐户。'
admin.invitations.max_uses: '最多使用次数'
admin.invitations.expires_at: '过期时间'
admin.invitations.code: '邀请码'
admin.invitations.copy_link: '复制链接'
admin.invitations.uses: '使用次数'
admin.invitations.expired: '已期'
flash.admin.user-deleted: '用户已删除'
flash.admin.gist-deleted: 'Gist 已删除'
flash.admin.invitation-created: '邀请已被创建'
flash.admin.invitation-deleted: '邀请已被删除'
admin.invitations.expired: '已期'
flash.admin.user-deleted: '用户已删除'
flash.admin.gist-deleted: 'Gist 已删除'
flash.admin.invitation-created: '邀请已被创建'
flash.admin.invitation-deleted: '邀请已被删除'
flash.admin.sync-fs: '正在从文件系统同步存储库...'
flash.admin.sync-db: '正在从数据库同步存储库...'
flash.admin.git-gc: '正在进行存储库垃圾回收...'
flash.admin.sync-previews: '正在同步 Gist 预览...'
flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...'
flash.admin.index-gists: '索引所有 gists...'
flash.admin.index-gists: '正在索引所有 Gists...'
flash.auth.username-exists: '用户名已存在'
flash.auth.invalid-credentials: '无效的凭证'
flash.auth.account-linked-oauth: '帐户已关联到 %s'
flash.auth.account-unlinked-oauth: '帐户与 %s 解除关联'
flash.auth.user-sshkeys-not-retrievable: '无法获取用户密钥'
flash.auth.user-sshkeys-not-created: '无法创建 ssh 密钥'
flash.auth.must-be-logged-in: '您必须登录才能访问 gists'
flash.gist.visibility-changed: 'Gist可见性已更改'
flash.gist.deleted: 'Gist已被删除'
flash.gist.fork-own-gist: '无法派生自己的要点'
flash.auth.user-sshkeys-not-created: '无法创建 SSH 密钥'
flash.auth.must-be-logged-in: '您必须登录才能访问 Gists'
flash.gist.visibility-changed: 'Gist 可见性已更改'
flash.gist.deleted: 'Gist 已被删除'
flash.gist.fork-own-gist: '无法派生自己的 Gists'
flash.gist.forked: 'Gist 已被派生'
flash.user.email-updated: '电子邮件已更新'
flash.user.invalid-ssh-key: 'SSH 密钥无效'
@ -254,20 +254,20 @@ validation.should-not-be-empty: '字段 %s 不能为空'
validation.should-not-include-sub-directory: '字段 %s 不应包含子目录'
validation.should-only-contain-alphanumeric-characters: '字段 %s 只能包含字母、数字、字符'
validation.should-only-contain-alphanumeric-characters-and-dashes: '字段 %s 应仅包含字母、数字、字符和 -'
validation.not-enough: '还不够 %s'
validation.invalid: '无效 %s'
validation.not-enough: '%s 长度过短'
validation.invalid: '%s 无效'
html.title.admin-panel: '管理面板'
settings.ssh-key-exists: SSH 密钥已经存在
auth.mfa.passkey-name: 密钥名称
auth.mfa.use-passkey-to-finish: 使用密钥完成身份验证
auth.mfa.delete-passkey: 删除密钥
auth.mfa.passkey-name: 名称
auth.mfa.use-passkey-to-finish: 使用通行密钥完成身份验证
auth.mfa.delete-passkey: 删除
auth.mfa.passkey-added-at: 密钥已添加
auth.mfa.passkey-never-used: 密钥从未使用
auth.mfa.passkey-last-used: 密钥上次使用时间
auth.mfa.passkey-never-used: 从未使用
auth.mfa.passkey-last-used: 最后使用于
auth.mfa.delete-passkey-confirm: 确认删除密钥
error.not-in-mfa-session: 用户不在 MFA 会话中
auth.mfa.waiting-for-passkey-input: 等待浏览器的交互输入...
auth.mfa.passkeys-help: 添加密钥以登录您的帐户并用于 MFA 方式
auth.mfa.passkeys-help: 添加通行密钥以登录您的帐户并用作 MFA 方法
flash.auth.passkey-registred: 密钥 %s 已注册
flash.auth.passkey-deleted: 密钥已删除
auth.mfa.use-passkey: 使用通行密钥
@ -276,3 +276,38 @@ auth.mfa.login-with-passkey: 使用通行密钥登录
auth.mfa: 多因素认证
auth.mfa.passkey: 通行密钥
auth.mfa.passkeys: 通行密钥
gist.delete.confirm: 确定要删除此 Gist 吗?
auth.totp.scan-qr-code: 使用您的身份验证应用扫描下面的二维码以启用双因素认证,或者输入以下字符串,然后用生成的代码进行确认。
auth.totp: 基于时间的一次性密码算法TOTP
auth.totp.help: TOTP 是一种双因素认证方法,它使用共享密钥生成一次性密码。
auth.totp.use: 使用 TOTP
auth.totp.regenerate-recovery-codes: 重新生成恢复码
auth.totp.already-enabled: TOTP 已启用
auth.totp.invalid-secret: 无效的 TOTP 密钥
auth.totp.code-used: 恢复代码 %s 已被使用且失效。您可能需要暂时禁用 MFA 或重新生成代码。
auth.totp.invalid-code: 无效的 TOTP 代码
auth.totp.disabled: 成功禁用 TOTP
auth.totp.disable: 禁用 TOTP
auth.totp.enter-code: 请输入来自身份验证器应用程序的代码
auth.totp.enter-recovery-key: 如果您丢失了设备,可以使用恢复密钥
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

@ -41,6 +41,7 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error {
allowUnauthenticated, err := auth.ShouldAllowUnauthenticatedGistAccess(db.AuthInfo{}, true)
if err != nil {
errorSsh("Failed to get auth info", err)
return errors.New("internal server error")
}

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,896 +0,0 @@
package web
import (
"archive/zip"
"bufio"
"bytes"
"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 := `document.write('<link rel="stylesheet" href="%s">')
document.write('%s')
`
content := strings.Replace(htmlbuf.String(), `\n`, `\\n`, -1)
content = strings.Replace(content, "\n", `\n`, -1)
js = fmt.Sprintf(js, cssUrl, content)
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)
}

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")
}

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