diff --git a/.gitattributes b/.gitattributes index 77d15c5..bf3cf2e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ templates/**/* linguist-vendored public/**/*.css linguist-vendored +public/**/*.scss linguist-vendored +*.config.js linguist-vendored diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 4163bb6..1d24363 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,49 +1,63 @@ -name: "Go" +name: "Go CI" on: push: branches: - master + - 'dev-*' pull_request: jobs: - checks: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Go 1.20 + uses: actions/setup-go@v4 + with: + go-version: "1.20" + + - name: Lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.54 + skip-pkg-cache: true + args: --out-format=colored-line-number --timeout=20m + + - name: Format + run: make fmt check_changes + + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Go 1.20 + uses: actions/setup-go@v4 + with: + go-version: "1.20" + + - name: Check + run: make go_mod check_changes + + test: strategy: fail-fast: false matrix: - os: ["ubuntu-latest", "macOS-latest"] - go: ["1.19", "1.20"] + os: ["ubuntu-latest", "macOS-latest", "windows-latest"] + go: ["1.20", "1.21"] runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v3 - name: Set up Go ${{ matrix.go }} - uses: WillAbides/setup-go-faster@v1.8.0 + uses: actions/setup-go@v4 with: go-version: ${{ matrix.go }} - - name: Cache Go modules - uses: actions/cache@v3 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + - name: Run tests + run: make test - - name: Cache Go build cache - uses: actions/cache@v3 - with: - path: ~/.cache/go-build - key: ${{ runner.os }}-go-build-${{ matrix.go }} - restore-keys: | - ${{ runner.os }}-go-build- - - - name: Run go vet - run: "go vet ./..." - - - name: Run Staticcheck - uses: dominikh/staticcheck-action@v1.3.0 - with: - version: "2023.1.1" - install-go: false - cache-key: ${{ matrix.go }} diff --git a/.github/workflows/docker.yml b/.github/workflows/release.yml similarity index 69% rename from .github/workflows/docker.yml rename to .github/workflows/release.yml index 05ef3ed..9d97fc8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,36 @@ -name: Docker +name: Release on: release: types: [published] - workflow_dispatch: jobs: + binaries-build-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Go 1.20 + uses: actions/setup-go@v4 + with: + go-version: "1.20" + + - name: Cross compile build + run: make all_crosscompile + + - name: Upload Release Assets + uses: softprops/action-gh-release@v1 + with: + files: | + build/*.tar.gz + build/*.zip + build/checksums.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + docker-build-release: runs-on: ubuntu-latest permissions: diff --git a/.gitignore b/.gitignore index 1060116..e96bcd2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ gist.db public/assets/* public/manifest.json opengist +build/ diff --git a/Dockerfile b/Dockerfile index f8a73fc..c5a0836 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN apk update && \ musl-dev \ libstdc++ -COPY --from=golang:1.19-alpine /usr/local/go/ /usr/local/go/ +COPY --from=golang:1.20-alpine /usr/local/go/ /usr/local/go/ ENV PATH="/usr/local/go/bin:${PATH}" COPY --from=node:18-alpine /usr/local/ /usr/local/ diff --git a/Makefile b/Makefile index 447a075..012a397 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ -.PHONY: all install build_frontend build_backend build build_docker watch_frontend watch_backend watch clean clean_docker +.PHONY: all all_crosscompile install build_frontend build_backend build build_crosscompile build_docker watch_frontend watch_backend watch clean clean_docker check_changes go_mod fmt test # Specify the name of your Go binary output BINARY_NAME := opengist -all: install build +all: clean install build + +all_crosscompile: clean install build_frontend build_crosscompile install: @echo "Installing NPM dependencies..." @@ -21,6 +23,9 @@ build_backend: build: build_frontend build_backend +build_crosscompile: + @bash ./scripts/build-all.sh + build_docker: @echo "Building Docker image..." docker build -t $(BINARY_NAME):latest . @@ -34,13 +39,27 @@ watch_backend: OG_DEV=1 npx nodemon --watch '**/*' -e html,yml,go,js --signal SIGTERM --exec 'go run . --config config.yml' watch: - @bash ./watch.sh + @bash ./scripts/watch.sh clean: @echo "Cleaning up build artifacts..." @rm -f $(BINARY_NAME) public/manifest.json - @rm -rf public/assets + @rm -rf public/assets build clean_docker: @echo "Cleaning up Docker image..." @docker rmi $(BINARY_NAME) + +check_changes: + @echo "Checking for changes..." + @git --no-pager diff --exit-code || (echo "There are unstaged changes detected." && exit 1) + +go_mod: + @go mod download + @go mod tidy + +fmt: + @go fmt ./... + +test: + @go test ./... -p 1 diff --git a/README.md b/README.md index 30253ac..0797390 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,42 @@ # Opengist +Opengist + +Opengist is a **self-hosted** pastebin **powered by Git**. All snippets are stored in a Git repository and can be +read and/or modified using standard Git commands, or with the web interface. +It is similiar to [GitHub Gist](https://gist.github.com/), but open-source and could be self-hosted. + +[Documentation](/docs) • [Demo](https://opengist.thomice.li) + + ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/thomiceli/opengist?sort=semver) ![License](https://img.shields.io/github/license/thomiceli/opengist?color=blue) -![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/thomiceli/opengist/go.yml) +[![Go CI](https://github.com/thomiceli/opengist/actions/workflows/go.yml/badge.svg)](https://github.com/thomiceli/opengist/actions/workflows/go.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/thomiceli/opengist)](https://goreportcard.com/report/github.com/thomiceli/opengist) -A self-hosted pastebin **powered by Git**. [Try it here](https://opengist.thomice.li). - -* [Features](#features) -* [Install](#install) - * [With Docker](#with-docker) - * [From source](#from-source) -* [Configuration](#configuration) - * [Via YAML file](#configuration-via-yaml-file) - * [Via Environment Variables](#configuration-via-environment-variables) -* [Administration](#administration) - * [Use Nginx as a reverse proxy](#use-nginx-as-a-reverse-proxy) - * [Use Fail2ban](#use-fail2ban) -* [Configure OAuth](#configure-oauth) -* [License](#license) ## Features -* Create public or unlisted snippets -* Clone / Pull / Push snippets **via Git** over HTTP or SSH +* Create public, unlisted or private snippets +* [Init](/docs/usage/init-via-git.md) / Clone / Pull / Push snippets **via Git** over HTTP or SSH * Revisions history * Syntax highlighting ; markdown & CSV support * Like / Fork snippets * Search for snippets ; browse users snippets, likes and forks -* Editor with indentation mode & size ; drag and drop files * Download raw files or as a ZIP archive -* OAuth2 login with GitHub and Gitea -* Avatars via Gravatar or OAuth2 providers -* Light/Dark mode -* Responsive UI -* Enable or disable signups +* OAuth2 login with GitHub, Gitea, and OpenID Connect * Restrict or unrestrict snippets visibility to anonymous users -* Admin panel : delete users/gists; clean database/filesystem by syncing gists -* SQLite database -* Logging * Docker support +* [More...](/docs/index.md#features) -#### Todo - -- [ ] Translation -- [ ] Code/text search -- [ ] Embed snippets -- [ ] Tests -- [ ] Filesystem/Redis support for user sessions -- [ ] Have a cool logo - -## Install +## Quick start ### With Docker Docker [images](https://github.com/thomiceli/opengist/pkgs/container/opengist) are available for each release : ```shell -docker pull ghcr.io/thomiceli/opengist:1.4 +docker pull ghcr.io/thomiceli/opengist:1 ``` It can be used in a `docker-compose.yml` file : @@ -71,7 +50,7 @@ version: "3" services: opengist: - image: ghcr.io/thomiceli/opengist:1.4 + image: ghcr.io/thomiceli/opengist:1 container_name: opengist restart: unless-stopped ports: @@ -92,9 +71,23 @@ services: GID: 1001 ``` +### Via binary + +Download the archive for your system from the release page [here](https://github.com/thomiceli/opengist/releases/latest), and extract it. + +```shell +# example for linux amd64 +wget https://github.com/thomiceli/opengist/releases/download/v1.5.0/opengist1.5.0-linux-amd64.tar.gz + +tar xzvf opengist1.5.0-linux-amd64.tar.gz +cd opengist +chmod +x opengist +./opengist # with or without `--config config.yml` +``` + ### From source -Requirements : [Git](https://git-scm.com/downloads) (2.20+), [Go](https://go.dev/doc/install) (1.19+), [Node.js](https://nodejs.org/en/download/) (16+) +Requirements : [Git](https://git-scm.com/downloads) (2.20+), [Go](https://go.dev/doc/install) (1.20+), [Node.js](https://nodejs.org/en/download/) (16+) ```shell git clone https://github.com/thomiceli/opengist @@ -105,153 +98,12 @@ make Opengist is now running on port 6157, you can browse http://localhost:6157 -## Configuration -Opengist provides flexible configuration options through either a YAML file and/or environment variables. -You would only need to specify the configuration options you want to change — for any config option left untouched, Opengist will simply apply the default values. +## Documentation -
-Configuration option list +The documentation is available in [/docs](/docs) directory. -| YAML Config Key | Environment Variable | Default value | Description | -|-----------------------|--------------------------|----------------------|-----------------------------------------------------------------------------------------------------------------------------------| -| log-level | OG_LOG_LEVEL | `warn` | Set the log level to one of the following: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`. | -| external-url | OG_EXTERNAL_URL | none | Public URL for the Git HTTP/SSH connection. If not set, uses the URL from the request. | -| opengist-home | OG_OPENGIST_HOME | home directory | Path to the directory where Opengist stores its data. | -| db-filename | OG_DB_FILENAME | `opengist.db` | Name of the SQLite database file. | -| 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`) | -| http.tls-enabled | OG_HTTP_TLS_ENABLED | `false` | Enable or disable TLS for the HTTP server. (`true` or `false`) | -| http.cert-file | OG_HTTP_CERT_FILE | none | Path to the TLS certificate file if TLS is enabled. | -| http.key-file | OG_HTTP_KEY_FILE | none | Path to the TLS key file if TLS is enabled. | -| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) | -| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. | -| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. | -| ssh.external-domain | OG_SSH_EXTERNAL_DOMAIN | none | Public domain for the Git SSH connection, if it has to be different from the HTTP one. If not set, uses the URL from the request. | -| ssh.keygen-executable | OG_SSH_KEYGEN_EXECUTABLE | `ssh-keygen` | Path to the SSH key generation executable. | -| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. | -| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. | -| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. | -| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. | -| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. | - -
- -### Configuration via YAML file - -The configuration file must be specified when launching the application, using the `--config` flag followed by the path to your YAML file. - -```shell -./opengist --config /path/to/config.yml -``` - -You can start by copying and/or modifying the provided [config.yml](config.yml) file. - -### Configuration via Environment Variables - -Usage with Docker Compose : - -```yml -services: - opengist: - # ... - environment: - OG_LOG_LEVEL: "info" - # etc. -``` -Usage via command line : - -```shell -OG_LOG_LEVEL=info ./opengist -``` - -## Administration - -### Use Nginx as a reverse proxy - -Configure Nginx to proxy requests to Opengist. Here is an example configuration file : -``` -server { - listen 80; - server_name opengist.example.com; - - location / { - proxy_pass http://127.0.0.1:6157; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -Then run : -```shell -service nginx restart -``` - -### Use Fail2ban - -Fail2ban can be used to ban IPs that try to bruteforce the login page. -Log level must be set at least to `warn`. - -Add this filter in `etc/fail2ban/filter.d/opengist.conf` : -```ini -[Definition] -failregex = Invalid .* authentication attempt from -ignoreregex = -``` - -Add this jail in `etc/fail2ban/jail.d/opengist.conf` : -```ini -[opengist] -enabled = true -filter = opengist -logpath = /home/*/.opengist/log/opengist.log -maxretry = 10 -findtime = 3600 -bantime = 600 -banaction = iptables-allports -port = anyport -``` - -Then run -```shell -service fail2ban restart -``` - -## Configure OAuth - -Opengist can be configured to use OAuth to authenticate users, with GitHub or Gitea. - -
-Integrate Github - -* Add a new OAuth app in your [Github account settings](https://github.com/settings/applications/new) -* Set 'Authorization callback URL' to `http://opengist.domain/oauth/github/callback` -* Copy the 'Client ID' and 'Client Secret' and add them to the configuration : - ```yaml - github.client-key: - github.secret: - ``` -
- -
-Integrate Gitea - -* Add a new OAuth app in Application settings from the [Gitea instance](https://gitea.com/user/settings/applications) -* Set 'Redirect URI' to `http://opengist.domain/oauth/gitea/callback` -* Copy the 'Client ID' and 'Client Secret' and add them to the configuration : - ```yaml - gitea.client-key: - gitea.secret: - # URL of the Gitea instance. Default: https://gitea.com/ - gitea.url: http://localhost:3000 - ``` -
## License -Opengist is licensed under the [AGPL-3.0 license](LICENSE). +Opengist is licensed under the [AGPL-3.0 license](/LICENSE). diff --git a/config.yml b/config.yml index 04b9889..f48d79e 100644 --- a/config.yml +++ b/config.yml @@ -1,3 +1,7 @@ +# Learn more about Opengist configuration here: +# https://github.com/thomiceli/opengist/blob/master/docs/configuration/index.md +# https://github.com/thomiceli/opengist/blob/master/docs/configuration/cheat-sheet.md + # Set the log level to one of the following: trace, debug, info, warn, error, fatal, panic. Default: warn log-level: warn @@ -26,15 +30,6 @@ 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 TLS (either `true` or `false`). Default: false -http.tls-enabled: false - -# Path to the TLS certificate file if TLS is enabled -http.cert-file: - -# Path to the TLS key file if TLS is enabled -http.key-file: - # SSH built-in server configuration # Note: it is not using the SSH daemon from your machine (yet) @@ -60,7 +55,7 @@ ssh.keygen-executable: ssh-keygen # OAuth2 configuration -# The callback/redirect URL must be http://opengist.domain/oauth//callback +# The callback/redirect URL must be http://opengist.domain/oauth//callback # To create a new OAuth2 application using GitHub : https://github.com/settings/applications/new github.client-key: @@ -71,3 +66,9 @@ gitea.client-key: gitea.secret: # URL of the Gitea instance. Default: https://gitea.com/ gitea.url: https://gitea.com/ + +# To create a new OAuth2 application using OpenID Connect: +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: diff --git a/docs/administration/fail2ban-setup.md b/docs/administration/fail2ban-setup.md new file mode 100644 index 0000000..cb76ec3 --- /dev/null +++ b/docs/administration/fail2ban-setup.md @@ -0,0 +1,29 @@ +# Fail2ban setup + +Fail2ban can be used to ban IPs that try to bruteforce the login page. +Log level must be set at least to `warn`. + +Add this filter in `etc/fail2ban/filter.d/opengist.conf` : +```ini +[Definition] +failregex = Invalid .* authentication attempt from +ignoreregex = +``` + +Add this jail in `etc/fail2ban/jail.d/opengist.conf` : +```ini +[opengist] +enabled = true +filter = opengist +logpath = /home/*/.opengist/log/opengist.log +maxretry = 10 +findtime = 3600 +bantime = 600 +banaction = iptables-allports +port = anyport +``` + +Then run +```shell +service fail2ban restart +``` diff --git a/docs/administration/nginx-reverse-proxy.md b/docs/administration/nginx-reverse-proxy.md new file mode 100644 index 0000000..9accdb6 --- /dev/null +++ b/docs/administration/nginx-reverse-proxy.md @@ -0,0 +1,22 @@ +# Use Nginx as a reverse proxy + +Configure Nginx to proxy requests to Opengist. Here is an example configuration file : +``` +server { + listen 80; + server_name opengist.example.com; + + location / { + proxy_pass http://127.0.0.1:6157; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Then run : +```shell +service nginx restart +``` diff --git a/docs/administration/oauth-providers.md b/docs/administration/oauth-providers.md new file mode 100644 index 0000000..9b1e73e --- /dev/null +++ b/docs/administration/oauth-providers.md @@ -0,0 +1,39 @@ +# Use OAuth providers + +Opengist can be configured to use OAuth to authenticate users, with GitHub, Gitea, or OpenID Connect. + +## Github + +* Add a new OAuth app in your [Github account settings](https://github.com/settings/applications/new) +* Set 'Authorization callback URL' to `http://opengist.domain/oauth/github/callback` +* Copy the 'Client ID' and 'Client Secret' and add them to the [configuration](/docs/configuration/cheat-sheet.md) : + ```yaml + github.client-key: + github.secret: + ``` + + +## Gitea + +* Add a new OAuth app in Application settings from the [Gitea instance](https://gitea.com/user/settings/applications) +* Set 'Redirect URI' to `http://opengist.domain/oauth/gitea/callback` +* Copy the 'Client ID' and 'Client Secret' and add them to the [configuration](/docs/configuration/cheat-sheet.md) : + ```yaml + gitea.client-key: + gitea.secret: + # URL of the Gitea instance. Default: https://gitea.com/ + gitea.url: http://localhost:3000 + ``` + + +## OpenID Connect + +* Add a new OAuth app in Application settings of your OIDC provider +* Set 'Redirect URI' to `http://opengist.domain/oauth/openid-connect/callback` +* Copy the 'Client ID', 'Client Secret', and the discovery endpoint, and add them to the [configuration](/docs/configuration/cheat-sheet.md) : + ```yaml + 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: http://auth.example.com/.well-known/openid-configuration + ``` diff --git a/docs/administration/run-with-systemd.md b/docs/administration/run-with-systemd.md new file mode 100644 index 0000000..cb08bd2 --- /dev/null +++ b/docs/administration/run-with-systemd.md @@ -0,0 +1,47 @@ +# Run with Systemd + +For non-Docker users, you could run Opengist as a systemd service. + +On Unix distributions with systemd, place the Opengist binary like: + +```shell +sudo cp opengist /usr/local/bin +sudo mkdir -p /var/lib/opengist +sudo cp config.yml /etc/opengist +``` + +Edit the Opengist home directory configuration in `/etc/opengist/config.yml` like: +```shell +opengist-home: /var/lib/opengist +``` + +Create a new user to run Opengist: +```shell +sudo useradd --system opengist +sudo mkdir -p /var/lib/opengist +sudo chown -R opengist:opengist /var/lib/opengist +``` + +Then create a service file at `/etc/systemd/system/opengist.service`: +```ini +[Unit] +Description=opengist Server +After=network.target + +[Service] +Type=simple +User=opengist +Group=opengist +ExecStart=opengist --config /etc/opengist/config.yml +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +Finally, start the service: +```shell +systemctl daemon-reload +systemctl enable --now opengist +systemctl status opengist +``` diff --git a/docs/configuration/cheat-sheet.md b/docs/configuration/cheat-sheet.md new file mode 100644 index 0000000..5400570 --- /dev/null +++ b/docs/configuration/cheat-sheet.md @@ -0,0 +1,25 @@ +# Configuration Cheat Sheet + +| YAML Config Key | Environment Variable | Default value | Description | +|-----------------------|--------------------------|----------------------|-----------------------------------------------------------------------------------------------------------------------------------| +| log-level | OG_LOG_LEVEL | `warn` | Set the log level to one of the following: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`. | +| external-url | OG_EXTERNAL_URL | none | Public URL for the Git HTTP/SSH connection. If not set, uses the URL from the request. | +| opengist-home | OG_OPENGIST_HOME | home directory | Path to the directory where Opengist stores its data. | +| db-filename | OG_DB_FILENAME | `opengist.db` | Name of the SQLite database file. | +| 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`) | +| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) | +| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. | +| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. | +| ssh.external-domain | OG_SSH_EXTERNAL_DOMAIN | none | Public domain for the Git SSH connection, if it has to be different from the HTTP one. If not set, uses the URL from the request. | +| ssh.keygen-executable | OG_SSH_KEYGEN_EXECUTABLE | `ssh-keygen` | Path to the SSH key generation executable. | +| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. | +| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. | +| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. | +| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. | +| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. | +| 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. | diff --git a/docs/configuration/index.md b/docs/configuration/index.md new file mode 100644 index 0000000..1327aa4 --- /dev/null +++ b/docs/configuration/index.md @@ -0,0 +1,38 @@ +# Configuration + +Opengist provides flexible configuration options through either a YAML file and/or environment variables. +You would only need to specify the configuration options you want to change — for any config option left untouched, +Opengist will simply apply the default values. + +The [configuration cheat sheet](cheat-sheet.md) lists all available configuration options. + + +## Configuration via YAML file + +The configuration file must be specified when launching the application, using the `--config` flag followed by the path +to your YAML file. + +```shell +./opengist --config /path/to/config.yml +``` + +You can start by copying and/or modifying the provided [config.yml](/config.yml) file. + + +## Configuration via Environment Variables + +Usage with Docker Compose : + +```yml +services: + opengist: + # ... + environment: + OG_LOG_LEVEL: "info" + # etc. +``` +Usage via command line : + +```shell +OG_LOG_LEVEL=info ./opengist +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..27cc627 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,52 @@ +# Opengist + +Opengist is a **self-hosted** pastebin **powered by Git**. All snippets are stored in a Git repository and can be +read and/or modified using standard Git commands, or with the web interface. +It is similiar to [GitHub Gist](https://gist.github.com/), but open-source and could be self-hosted. + +Written in [Go](https://go.dev), Opengist aims to be fast and easy to deploy. + + +## Features + +* Create public, unlisted or private snippets +* [Init](/docs/usage/init-via-git.md) / Clone / Pull / Push snippets **via Git** over HTTP or SSH +* Revisions history +* Syntax highlighting ; markdown & CSV support +* Like / Fork snippets +* Search for snippets ; browse users snippets, likes and forks +* Editor with indentation mode & size ; drag and drop files +* Download raw files or as a ZIP archive +* OAuth2 login with GitHub, Gitea, and OpenID Connect +* Avatars via Gravatar or OAuth2 providers +* Light/Dark mode +* Responsive UI +* Enable or disable signups +* Restrict or unrestrict snippets visibility to anonymous users +* Admin panel : + * delete users/gists; + * clean database/filesystem by syncing gists + * run `git gc` for all repositories +* SQLite database +* Logging +* Docker support + + +## System requirements + +[Git](https://git-scm.com/download) is obviously required to run Opengist, as it's the main feature of the app. +Version **2.20** or later is recommended as the app has not been tested with older Git versions. + +[OpenSSH](https://www.openssh.com/) suite if you wish to use Git over SSH. + + +## Components + +* Backend Web Framework: [Echo](https://echo.labstack.com/) +* ORM: [GORM](https://gorm.io/) +* Frontend libraries: + * [Tailwind CSS](https://tailwindcss.com/) + * [CodeMirror](https://codemirror.net/) + * [Day.js](https://day.js.org/) + * [highlight.js](https://highlightjs.org/) + * and [others](/package.json) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..33ac184 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,73 @@ +# Installation + +## With Docker + +Docker [images](https://github.com/thomiceli/opengist/pkgs/container/opengist) are available for each release : + +```shell +docker pull ghcr.io/thomiceli/opengist:1 +``` + +It can be used in a `docker-compose.yml` file : + +1. Create a `docker-compose.yml` file with the following content +2. Run `docker compose up -d` +3. Opengist is now running on port 6157, you can browse http://localhost:6157 + +```yml +version: "3" + +services: + opengist: + image: ghcr.io/thomiceli/opengist:1 + container_name: opengist + restart: unless-stopped + ports: + - "6157:6157" # HTTP port + - "2222:2222" # SSH port, can be removed if you don't use SSH + volumes: + - "$HOME/.opengist:/opengist" +``` + +You can define which user/group should run the container and own the files by setting the `UID` and `GID` environment +variables : + +```yml +services: + opengist: + # ... + environment: + UID: 1001 + GID: 1001 +``` + +### Via binary + +Download the archive for your system from the release page [here](https://github.com/thomiceli/opengist/releases/latest), and extract it. + +```shell +# example for linux amd64 +wget https://github.com/thomiceli/opengist/releases/download/v1.5.0/opengist1.5.0-linux-amd64.tar.gz + +tar xzvf opengist1.5.0-linux-amd64.tar.gz +cd opengist +chmod +x opengist +./opengist # with or without `--config config.yml` +``` + + +## From source + +Requirements : +* [Git](https://git-scm.com/downloads) (2.20+) +* [Go](https://go.dev/doc/install) (1.20+) +* [Node.js](https://nodejs.org/en/download/) (16+) + +```shell +git clone https://github.com/thomiceli/opengist +cd opengist +make +./opengist +``` + +Opengist is now running on port 6157, you can browse http://localhost:6157 diff --git a/docs/usage/init-via-git.md b/docs/usage/init-via-git.md new file mode 100644 index 0000000..231e698 --- /dev/null +++ b/docs/usage/init-via-git.md @@ -0,0 +1,42 @@ +# Init Gists via Git + +Opengist allows you to create new snippets via Git over HTTP. + +Simply init a new Git repository where your file(s) is/are located: + +```shell +git init +git add . +git commit -m "My cool snippet" +``` + +Then add this Opengist special remote URL and push your changes: + +```shell +git remote add origin http://localhost:6157/init + +git push -u origin master +``` + +Log in with your Opengist account credentials, and your snippet will be created at the specified URL: + +```shell +Username for 'http://localhost:6157': thomas +Password for 'http://thomas@localhost:6157': +Enumerating objects: 3, done. +Counting objects: 100% (3/3), done. +Delta compression using up to 8 threads +Compressing objects: 100% (2/2), done. +Writing objects: 100% (3/3), 416 bytes | 416.00 KiB/s, done. +Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 +remote: +remote: Your new repository has been created here: http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066 +remote: +remote: If you want to keep working with your gist, you could set the remote URL via: +remote: git remote set-url origin http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066 +remote: +To http://localhost:6157/init + * [new branch] master -> master +``` + +https://github.com/thomiceli/opengist/assets/27960254/3fe1a0ba-b638-4928-83a1-f38e46fea066 diff --git a/fs_embed.go b/fs_embed.go deleted file mode 100644 index 30d7fd4..0000000 --- a/fs_embed.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build fs_embed - -package main - -import "embed" - -//go:embed templates/*/*.html public/manifest.json public/assets/*.js public/assets/*.css public/assets/*.svg public/assets/*.png -var dirFS embed.FS diff --git a/fs_os.go b/fs_os.go deleted file mode 100644 index a608301..0000000 --- a/fs_os.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !fs_embed - -package main - -import "os" - -var dirFS = os.DirFS(".") diff --git a/go.mod b/go.mod index a68dfe1..08cd873 100644 --- a/go.mod +++ b/go.mod @@ -1,42 +1,54 @@ module github.com/thomiceli/opengist -go 1.19 +go 1.20 require ( - github.com/go-playground/validator/v10 v10.11.0 - github.com/google/uuid v1.3.0 + github.com/glebarez/go-sqlite v1.21.2 + github.com/glebarez/sqlite v1.9.0 + github.com/go-playground/validator/v10 v10.15.4 + github.com/google/uuid v1.3.1 github.com/gorilla/sessions v1.2.1 - github.com/labstack/echo/v4 v4.10.0 - github.com/markbates/goth v1.77.0 - github.com/mattn/go-sqlite3 v1.14.13 - github.com/rs/zerolog v1.29.0 - golang.org/x/crypto v0.2.0 - golang.org/x/text v0.7.0 + github.com/hashicorp/go-memdb v1.3.4 + github.com/labstack/echo/v4 v4.11.1 + github.com/markbates/goth v1.78.0 + github.com/rs/zerolog v1.30.0 + github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.13.0 + golang.org/x/text v0.13.0 gopkg.in/yaml.v3 v3.0.1 - gorm.io/driver/sqlite v1.3.2 - gorm.io/gorm v1.23.5 + gorm.io/gorm v1.25.4 ) require ( - github.com/go-playground/locales v0.14.0 // indirect - github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect - github.com/golang/protobuf v1.4.2 // indirect - github.com/gorilla/context v1.1.1 // indirect - github.com/gorilla/mux v1.6.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/securecookie v1.1.1 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/labstack/gommon v0.4.0 // indirect - github.com/leodido/go-urn v1.2.1 // indirect + github.com/leodido/go-urn v1.2.4 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/net v0.7.0 // indirect - golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect - golang.org/x/sys v0.5.0 // indirect - golang.org/x/time v0.2.0 // indirect - google.golang.org/appengine v1.6.6 // indirect - google.golang.org/protobuf v1.25.0 // indirect + golang.org/x/net v0.15.0 // indirect + golang.org/x/oauth2 v0.12.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.31.0 // indirect + modernc.org/libc v1.24.1 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.1 // indirect + modernc.org/sqlite v1.25.0 // indirect ) diff --git a/go.sum b/go.sum index 75daec6..d16ac2d 100644 --- a/go.sum +++ b/go.sum @@ -40,28 +40,34 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= +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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs= +github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= -github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= -github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= -github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= -github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw= -github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +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.15.4 h1:zMXza4EpOdooxPel5xDqXEdXG5r+WggpvnAKMsalBjs= +github.com/go-playground/validator/v10 v10.15.4/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.9.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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= @@ -90,8 +96,11 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -101,8 +110,9 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -113,47 +123,54 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200905233945-acf8798be1f7/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +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.1/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/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4= 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.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -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.10.0 h1:5CiyngihEO4HXsz3vVsJn7f8xAlWwRr3aY6Ih280ZKA= -github.com/labstack/echo/v4 v4.10.0/go.mod h1:S/T/5fy/GigaXnHTkh0ZGe4LpkkQysvRjFMSUTkDRNQ= +github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4= +github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= -github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= -github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE= @@ -161,39 +178,39 @@ github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbq github.com/lestrrat-go/jwx v1.2.21/go.mod h1:9cfxnOH7G1gN75CaJP2hKGcxFEx5sPh1abRIA/ZJVh4= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/markbates/going v1.0.0/go.mod h1:I6mnB4BPnEeqo85ynXIx1ZFLLbtiLHNXVgWeFO9OGOA= -github.com/markbates/goth v1.77.0 h1:s3scqnWv/Zq/a5M766V0FKsLfOdFNdh/HEkuWCKbvT8= -github.com/markbates/goth v1.77.0/go.mod h1:X6xdNgpapSENS0O35iTBBcMHoJDQDfI9bJl+APCkYMc= +github.com/markbates/goth v1.78.0 h1:7VEIFDycJp9deyVv3YraGBPdD0ZYQW93Y3Aw1eVP3BY= +github.com/markbates/goth v1.78.0/go.mod h1:X6xdNgpapSENS0O35iTBBcMHoJDQDfI9bJl+APCkYMc= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I= -github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= -github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= +github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= @@ -203,6 +220,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -213,10 +231,10 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE= -golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -247,6 +265,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -274,16 +293,19 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200927032502-5d4f70055728/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 h1:ld7aEMNHoBnnDAX15v1T6z31v8HwR2A9FYOuAhWqkwc= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -292,6 +314,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -322,14 +345,17 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/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 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -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.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -337,13 +363,14 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/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 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.2.0 h1:52I/1L54xyEQAYdtcSuxtiT84KGYTBGXwayxmIpNJhE= -golang.org/x/time v0.2.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -386,10 +413,10 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20200929161345-d7fc70abf50f/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -413,8 +440,9 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -469,23 +497,22 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 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-20210107192922-496545a6307b/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/sqlite v1.3.2 h1:nWTy4cE52K6nnMhv23wLmur9Y3qWbZvOBz+V4PrGAxg= -gorm.io/driver/sqlite v1.3.2/go.mod h1:B+8GyC9K7VgzJAcrcXMRPdnMcck+8FgJynEehEPM16U= -gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= -gorm.io/gorm v1.23.5 h1:TnlF26wScKSvknUC/Rn8t0NLLM22fypYBlvj1+aH6dM= -gorm.io/gorm v1.23.5/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw= +gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -493,6 +520,14 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= +modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak= +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.7.1 h1:9J+2/GKTlV503mk3yv8QJ6oEpRCUrRy0ad8TXEPoV8M= +modernc.org/memory v1.7.1/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA= +modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/config/config.go b/internal/config/config.go index c1c3745..5753e55 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,7 +15,7 @@ import ( "gopkg.in/yaml.v3" ) -var OpengistVersion = "1.4.2" +var OpengistVersion = "1.5-dev" var C *config @@ -29,12 +29,9 @@ type config struct { SqliteJournalMode string `yaml:"sqlite.journal-mode" env:"OG_SQLITE_JOURNAL_MODE"` - HttpHost string `yaml:"http.host" env:"OG_HTTP_HOST"` - HttpPort string `yaml:"http.port" env:"OG_HTTP_PORT"` - HttpGit bool `yaml:"http.git-enabled" env:"OG_HTTP_GIT_ENABLED"` - HttpTLSEnabled bool `yaml:"http.tls-enabled" env:"OG_HTTP_TLS_ENABLED"` - HttpCertFile string `yaml:"http.cert-file" env:"OG_HTTP_CERT_FILE"` - HttpKeyFile string `yaml:"http.key-file" env:"OG_HTTP_KEY_FILE"` + HttpHost string `yaml:"http.host" env:"OG_HTTP_HOST"` + HttpPort string `yaml:"http.port" env:"OG_HTTP_PORT"` + HttpGit bool `yaml:"http.git-enabled" env:"OG_HTTP_GIT_ENABLED"` SshGit bool `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"` SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"` @@ -48,6 +45,10 @@ type config struct { GiteaClientKey string `yaml:"gitea.client-key" env:"OG_GITEA_CLIENT_KEY"` GiteaSecret string `yaml:"gitea.secret" env:"OG_GITEA_SECRET"` GiteaUrl string `yaml:"gitea.url" env:"OG_GITEA_URL"` + + 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"` } func configWithDefaults() (*config, error) { @@ -66,7 +67,6 @@ func configWithDefaults() (*config, error) { c.HttpHost = "0.0.0.0" c.HttpPort = "6157" c.HttpGit = true - c.HttpTLSEnabled = false c.SshGit = true c.SshHost = "0.0.0.0" @@ -175,17 +175,6 @@ func loadConfigFromYaml(c *config, configPath string) error { fmt.Println("No YAML config file specified.") } - // Override default values with environment variables (as yaml) - configEnv := os.Getenv("CONFIG") - if configEnv != "" { - fmt.Println("Using config from environment variable: CONFIG") - fmt.Println("!! This method of setting the config is deprecated and will be removed in a future version of Opengist") - d := yaml.NewDecoder(strings.NewReader(configEnv)) - if err := d.Decode(&c); err != nil { - return err - } - } - return nil } @@ -237,5 +226,9 @@ func checks(c *config) error { return err } + if _, err := url.Parse(c.OIDCDiscoveryUrl); err != nil { + return err + } + return nil } diff --git a/internal/models/admin_setting.go b/internal/db/admin_setting.go similarity index 98% rename from internal/models/admin_setting.go rename to internal/db/admin_setting.go index e23e938..7fb8cb4 100644 --- a/internal/models/admin_setting.go +++ b/internal/db/admin_setting.go @@ -1,4 +1,4 @@ -package models +package db import ( "gorm.io/gorm/clause" diff --git a/internal/models/db.go b/internal/db/db.go similarity index 71% rename from internal/models/db.go rename to internal/db/db.go index 8605a3c..99573c6 100644 --- a/internal/models/db.go +++ b/internal/db/db.go @@ -1,20 +1,21 @@ -package models +package db import ( "errors" - "github.com/mattn/go-sqlite3" + "strings" + + msqlite "github.com/glebarez/go-sqlite" + "github.com/glebarez/sqlite" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/utils" - "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" - "strings" ) var db *gorm.DB -func Setup(dbPath string) error { +func Setup(dbPath string, sharedCache bool) error { var err error journalMode := strings.ToUpper(config.C.SqliteJournalMode) @@ -22,7 +23,12 @@ func Setup(dbPath string) error { log.Warn().Msg("Invalid SQLite journal mode: " + journalMode) } - if db, err = gorm.Open(sqlite.Open(dbPath+"?_fk=true&_journal_mode="+journalMode), &gorm.Config{ + sharedCacheStr := "" + if sharedCache { + sharedCacheStr = "&cache=shared" + } + + if db, err = gorm.Open(sqlite.Open(dbPath+"?_fk=true&_journal_mode="+journalMode+sharedCacheStr), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }); err != nil { return err @@ -40,7 +46,9 @@ func Setup(dbPath string) error { return err } - ApplyMigrations(db) + if err = ApplyMigrations(db); err != nil { + return err + } // Default admin setting values return initAdminSettings(map[string]string{ @@ -51,6 +59,14 @@ func Setup(dbPath string) error { }) } +func Close() error { + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + func CountAll(table interface{}) (int64, error) { var count int64 err := db.Model(table).Count(&count).Error @@ -58,8 +74,8 @@ func CountAll(table interface{}) (int64, error) { } func IsUniqueConstraintViolation(err error) bool { - var sqliteErr sqlite3.Error - if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { + var sqliteErr *msqlite.Error + if errors.As(err, &sqliteErr) && sqliteErr.Code() == 2067 { return true } return false diff --git a/internal/models/gist.go b/internal/db/gist.go similarity index 92% rename from internal/models/gist.go rename to internal/db/gist.go index 3bcd1da..aba16c8 100644 --- a/internal/models/gist.go +++ b/internal/db/gist.go @@ -1,6 +1,7 @@ -package models +package db import ( + "github.com/labstack/echo/v4" "github.com/thomiceli/opengist/internal/git" "gorm.io/gorm" "os/exec" @@ -15,7 +16,7 @@ type Gist struct { Preview string PreviewFilename string Description string - Private bool + Private int // 0: public, 1: unlisted, 2: private UserID uint User User NbFiles int @@ -89,7 +90,7 @@ func GetAllGists(offset int) ([]*Gist, error) { func GetAllGistsFromSearch(currentUserId uint, query string, offset int, sort string, order string) ([]*Gist, error) { var gists []*Gist err := db.Preload("User").Preload("Forked.User"). - Where("((gists.private = 0) or (gists.private = 1 and gists.user_id = ?))", currentUserId). + 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). Offset(offset * 10). @@ -101,7 +102,7 @@ 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"). - Where("((gists.private = 0) or (gists.private = 1 and gists.user_id = ?))", currentUserId). + 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") } @@ -124,7 +125,7 @@ func CountAllGistsFromUser(fromUserId uint, currentUserId uint) (int64, error) { func likedStatement(fromUserId uint, currentUserId uint) *gorm.DB { return db.Preload("User").Preload("Forked.User"). - Where("((gists.private = 0) or (gists.private = 1 and gists.user_id = ?))", currentUserId). + 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"). Joins("join users on likes.user_id = users.id") @@ -147,7 +148,7 @@ func CountAllGistsLikedByUser(fromUserId uint, currentUserId uint) (int64, error func forkedStatement(fromUserId uint, currentUserId uint) *gorm.DB { return db.Preload("User").Preload("Forked.User"). - Where("gists.forked_id is not null and ((gists.private = 0) or (gists.private = 1 and gists.user_id = ?))", currentUserId). + 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") } @@ -190,6 +191,11 @@ func (gist *Gist) Update() error { } func (gist *Gist) Delete() error { + err := gist.DeleteRepository() + if err != nil { + return err + } + return db.Delete(&gist).Error } @@ -243,7 +249,7 @@ func (gist *Gist) GetForks(currentUserId uint, offset int) ([]*Gist, error) { var gists []*Gist err := db.Model(&gist).Preload("User"). Where("forked_id = ?", gist.ID). - Where("(gists.private = 0) or (gists.private = 1 and gists.user_id = ?)", currentUserId). + Where("(gists.private = 0) or (gists.private > 0 and gists.user_id = ?)", currentUserId). Limit(11). Offset(offset * 10). Order("updated_at desc"). @@ -260,6 +266,10 @@ func (gist *Gist) InitRepository() error { return git.InitRepository(gist.User.Username, gist.Uuid) } +func (gist *Gist) InitRepositoryViaInit(ctx echo.Context) error { + return git.InitRepositoryViaInit(gist.User.Username, gist.Uuid, ctx) +} + func (gist *Gist) DeleteRepository() error { return git.DeleteRepository(gist.User.Username, gist.Uuid) } @@ -307,7 +317,7 @@ func (gist *Gist) Log(skip int) ([]*git.Commit, error) { } func (gist *Gist) NbCommits() (string, error) { - return git.GetNumberOfCommitsOfRepository(gist.User.Username, gist.Uuid) + return git.CountCommits(gist.User.Username, gist.Uuid) } func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error { @@ -367,7 +377,6 @@ func (gist *Gist) UpdatePreviewAndCount() error { gist.Preview = file.Content } - gist.Preview = file.Content gist.PreviewFilename = file.Filename } @@ -379,8 +388,10 @@ func (gist *Gist) UpdatePreviewAndCount() error { type GistDTO struct { Title string `validate:"max=50" form:"title"` Description string `validate:"max=150" form:"description"` - Private bool `form:"private"` + Private int `validate:"number,min=0,max=2" form:"private"` Files []FileDTO `validate:"min=1,dive"` + Name []string `form:"name"` + Content []string `form:"content"` } type FileDTO struct { diff --git a/internal/models/migration.go b/internal/db/migration.go similarity index 99% rename from internal/models/migration.go rename to internal/db/migration.go index 3240bf0..907e2b3 100644 --- a/internal/models/migration.go +++ b/internal/db/migration.go @@ -1,4 +1,4 @@ -package models +package db import ( "fmt" diff --git a/internal/models/sshkey.go b/internal/db/sshkey.go similarity index 99% rename from internal/models/sshkey.go rename to internal/db/sshkey.go index 2dcdabc..ee62151 100644 --- a/internal/models/sshkey.go +++ b/internal/db/sshkey.go @@ -1,4 +1,4 @@ -package models +package db import ( "crypto/sha256" diff --git a/internal/models/user.go b/internal/db/user.go similarity index 90% rename from internal/models/user.go rename to internal/db/user.go index 9208d0d..d5ec34a 100644 --- a/internal/models/user.go +++ b/internal/db/user.go @@ -1,4 +1,4 @@ -package models +package db import ( "gorm.io/gorm" @@ -15,6 +15,7 @@ type User struct { AvatarURL string GithubID string GiteaID string + OIDCID string `gorm:"column:oidc_id"` Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` @@ -38,7 +39,7 @@ func (user *User) BeforeDelete(tx *gorm.DB) error { } // Decrement forks counter for all gists forked by this user - return tx.Model(&Gist{}). + err = tx.Model(&Gist{}). Omit("updated_at"). Where("id IN (?)", tx. Select("forked_id"). @@ -47,6 +48,12 @@ func (user *User) BeforeDelete(tx *gorm.DB) error { ). UpdateColumn("nb_forks", gorm.Expr("nb_forks - 1")). Error + if err != nil { + return err + } + + // Delete all gists created by this user + return tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error } func UserExists(username string) (bool, error) { @@ -124,6 +131,8 @@ func GetUserByProvider(id string, provider string) (*User, error) { err = db.Where("github_id = ?", id).First(&user).Error case "gitea": err = db.Where("gitea_id = ?", id).First(&user).Error + case "openid-connect": + err = db.Where("oidc_id = ?", id).First(&user).Error } return user, err @@ -169,6 +178,11 @@ func (user *User) DeleteProviderID(provider string) error { Update("gitea_id", nil). Update("avatar_url", nil). Error + case "openid-connect": + return db.Model(&user). + Update("oidc_id", nil). + Update("avatar_url", nil). + Error } return nil diff --git a/internal/git/commands.go b/internal/git/commands.go index b3cc687..1b45781 100644 --- a/internal/git/commands.go +++ b/internal/git/commands.go @@ -1,7 +1,10 @@ package git import ( + "bytes" "fmt" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" "os" "os/exec" @@ -11,8 +14,31 @@ import ( "strings" ) +var ( + ReposDirectory = "repos" +) + +const truncateLimit = 2 << 18 + func RepositoryPath(user string, gist string) string { - return filepath.Join(config.GetHomeDir(), "repos", strings.ToLower(user), gist) + return filepath.Join(config.GetHomeDir(), ReposDirectory, strings.ToLower(user), gist) +} + +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" { + httpProtocol = "https" + } + + var baseHttpUrl string + // if a custom external url is set, use it + if config.C.ExternalUrl != "" { + baseHttpUrl = config.C.ExternalUrl + } else { + baseHttpUrl = httpProtocol + "://" + ctx.Request().Host + } + + return fmt.Sprintf("%s/%s/%s", baseHttpUrl, user, gist) } func TmpRepositoryPath(gistId string) string { @@ -34,15 +60,24 @@ func InitRepository(user string, gist string) error { repositoryPath, ) - err := cmd.Run() - if err != nil { + if err := cmd.Run(); err != nil { return err } - return copyFiles(repositoryPath) + return createDotGitFiles(repositoryPath) } -func GetNumberOfCommitsOfRepository(user string, gist string) (string, error) { +func InitRepositoryViaInit(user string, gist string, ctx echo.Context) error { + repositoryPath := RepositoryPath(user, gist) + + if err := InitRepository(user, gist); err != nil { + return err + } + repositoryUrl := RepositoryUrl(ctx, user, gist) + return createDotGitHookFile(repositoryPath, "post-receive", fmt.Sprintf(postReceive, repositoryUrl, repositoryUrl)) +} + +func CountCommits(user string, gist string) (string, error) { repositoryPath := RepositoryPath(user, gist) cmd := exec.Command( @@ -83,7 +118,7 @@ func GetFileContent(user string, gist string, revision string, filename string, var maxBytes int64 = -1 if truncate { - maxBytes = 2 << 18 + maxBytes = truncateLimit } cmd := exec.Command( @@ -99,9 +134,17 @@ func GetFileContent(user string, gist string, revision string, filename string, if err != nil { return "", false, err } - defer cmd.Wait() - return truncateCommandOutput(stdout, maxBytes) + output, truncated, err := truncateCommandOutput(stdout, maxBytes) + if err != nil { + return "", false, err + } + + if err := cmd.Wait(); err != nil { + return "", false, err + } + + return output, truncated, nil } func GetLog(user string, gist string, skip int) ([]*Commit, error) { @@ -127,9 +170,14 @@ func GetLog(user string, gist string, skip int) ([]*Commit, error) { if err != nil { return nil, err } - defer cmd.Wait() + defer func(cmd *exec.Cmd) { + waitErr := cmd.Wait() + if waitErr != nil { + err = waitErr + } + }(cmd) - return parseLog(stdout, 2<<18), nil + return parseLog(stdout, truncateLimit), err } func CloneTmp(user string, gist string, gistTmpId string, email string) error { @@ -151,9 +199,7 @@ func CloneTmp(user string, gist string, gistTmpId string, email string) error { } // remove every file (and not the .git directory!) - cmd = exec.Command("find", ".", "-maxdepth", "1", "-type", "f", "-delete") - cmd.Dir = tmpRepositoryPath - if err = cmd.Run(); err != nil { + if err = removeFilesExceptGit(tmpRepositoryPath); err != nil { return err } @@ -177,7 +223,7 @@ func ForkClone(userSrc string, gistSrc string, userDst string, gistDst string) e return err } - return copyFiles(repositoryPathDst) + return createDotGitFiles(repositoryPathDst) } func SetFileContent(gistTmpId string, filename string, content string) error { @@ -255,6 +301,67 @@ func RPC(user string, gist string, service string) ([]byte, error) { return stdout, err } +func GcRepos() error { + subdirs, err := os.ReadDir(filepath.Join(config.GetHomeDir(), ReposDirectory)) + if err != nil { + return err + } + + for _, subdir := range subdirs { + if !subdir.IsDir() { + continue + } + + subRoot := filepath.Join(config.GetHomeDir(), ReposDirectory, subdir.Name()) + + gitRepos, err := os.ReadDir(subRoot) + if err != nil { + log.Warn().Err(err).Msg("Cannot read directory") + continue + } + + for _, repo := range gitRepos { + if !repo.IsDir() { + continue + } + + repoPath := filepath.Join(subRoot, repo.Name()) + + log.Info().Msg("Running git gc for repository " + repoPath) + + cmd := exec.Command("git", "gc") + cmd.Dir = repoPath + err = cmd.Run() + if err != nil { + log.Warn().Err(err).Msg("Cannot run git gc for repository " + repoPath) + continue + } + } + } + + return err +} + +func HasNoCommits(user string, gist string) (bool, error) { + repositoryPath := RepositoryPath(user, gist) + + cmd := exec.Command("git", "rev-parse", "--all") + cmd.Dir = repositoryPath + + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + return false, err + } + + if out.String() == "" { + return true, nil // No commits exist + } + + return false, nil // Commits exist +} + func GetGitVersion() (string, error) { cmd := exec.Command("git", "--version") stdout, err := cmd.Output() @@ -270,19 +377,27 @@ func GetGitVersion() (string, error) { return versionFields[2], nil } -func copyFiles(repositoryPath string) error { +func createDotGitFiles(repositoryPath string) error { f1, err := os.OpenFile(filepath.Join(repositoryPath, "git-daemon-export-ok"), os.O_RDONLY|os.O_CREATE, 0644) if err != nil { return err } defer f1.Close() - preReceiveDst, err := os.OpenFile(filepath.Join(repositoryPath, "hooks", "pre-receive"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0744) + if err = createDotGitHookFile(repositoryPath, "pre-receive", preReceive); err != nil { + return err + } + + return nil +} + +func createDotGitHookFile(repositoryPath string, hook string, content string) error { + preReceiveDst, err := os.OpenFile(filepath.Join(repositoryPath, "hooks", hook), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0744) if err != nil { return err } - if _, err = preReceiveDst.WriteString(preReceive); err != nil { + if _, err = preReceiveDst.WriteString(content); err != nil { return err } defer preReceiveDst.Close() @@ -290,12 +405,35 @@ func copyFiles(repositoryPath string) error { return nil } +func removeFilesExceptGit(dir string) error { + return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && filepath.Base(path) == ".git" { + return filepath.SkipDir + } + if !d.IsDir() { + return os.Remove(path) + } + return nil + }) +} + const preReceive = `#!/bin/sh disallowed_files="" while read -r old_rev new_rev ref do + if [ "$old_rev" = "0000000000000000000000000000000000000000" ]; then + # This is the first commit, so we check all the files in that commit + changed_files=$(git ls-tree -r --name-only "$new_rev") + else + # This is not the first commit, so we compare it with its predecessor + changed_files=$(git diff --name-only "$old_rev" "$new_rev") + fi + while IFS= read -r file do case $file in @@ -304,15 +442,29 @@ do ;; esac done < 0 { + for filename, content := range files { + if err := SetFileContent(gist, filename, content); err != nil { + require.NoError(t, err, "Could not commit to repository") + } + + if err := AddAll(gist); err != nil { + require.NoError(t, err, "Could not commit to repository") + } + } + + } + + if err := CommitRepository(gist, user, "thomas@mail.com"); err != nil { + require.NoError(t, err, "Could not commit to repository") + } + + if err := Push(gist); err != nil { + require.NoError(t, err, "Could not commit to repository") + } +} diff --git a/internal/i18n/locale.go b/internal/i18n/locale.go new file mode 100644 index 0000000..564b3e5 --- /dev/null +++ b/internal/i18n/locale.go @@ -0,0 +1,125 @@ +package i18n + +import ( + "fmt" + "github.com/thomiceli/opengist/internal/i18n/locales" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "golang.org/x/text/language/display" + "gopkg.in/yaml.v3" + "html/template" + "io" + "io/fs" + "path/filepath" + "strings" +) + +var title = cases.Title(language.English) +var Locales = NewLocaleStore() + +type LocaleStore struct { + Locales map[string]*Locale +} + +type Locale struct { + Code string + Name string + Messages map[string]string +} + +// NewLocaleStore creates a new LocaleStore +func NewLocaleStore() *LocaleStore { + return &LocaleStore{ + Locales: make(map[string]*Locale), + } +} + +// loadLocaleFromYAML loads a single Locale from a given YAML file +func (store *LocaleStore) loadLocaleFromYAML(localeCode, path string) error { + a, err := locales.Files.Open(path) + if err != nil { + return err + } + data, err := io.ReadAll(a) + if err != nil { + return err + } + + tag, err := language.Parse(localeCode) + if err != nil { + return err + } + + name := display.Self.Name(tag) + if tag == language.AmericanEnglish { + name = "English" + } + + locale := &Locale{ + Code: localeCode, + Name: title.String(name), + Messages: make(map[string]string), + } + + err = yaml.Unmarshal(data, &locale.Messages) + if err != nil { + return err + } + + store.Locales[localeCode] = locale + return nil +} + +func (store *LocaleStore) LoadAll() error { + return fs.WalkDir(locales.Files, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + localeKey := strings.TrimSuffix(path, filepath.Ext(path)) + err := store.loadLocaleFromYAML(localeKey, path) + if err != nil { + return err + } + } + return nil + }) +} + +func (store *LocaleStore) GetLocale(lang string) (*Locale, error) { + _, ok := store.Locales[lang] + if !ok { + return nil, fmt.Errorf("locale %s not found", lang) + } + + return store.Locales[lang], nil +} + +func (store *LocaleStore) HasLocale(lang string) bool { + _, ok := store.Locales[lang] + return ok +} + +func (store *LocaleStore) MatchTag(langs []language.Tag) string { + for _, lang := range langs { + if store.HasLocale(lang.String()) { + return lang.String() + } + } + + return "en-US" +} + +func (l *Locale) Tr(key string, args ...any) template.HTML { + message := l.Messages[key] + + if message == "" { + return Locales.Locales["en-US"].Tr(key, args...) + } + + if len(args) == 0 { + return template.HTML(message) + } + + return template.HTML(fmt.Sprintf(message, args...)) +} diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml new file mode 100644 index 0000000..00d862d --- /dev/null +++ b/internal/i18n/locales/en-US.yml @@ -0,0 +1,177 @@ +gist.public: Public +gist.unlisted: Unlisted +gist.private: Private + +gist.header.like: Like +gist.header.unlike: Unlike +gist.header.fork: Fork +gist.header.edit: Edit +gist.header.delete: Delete +gist.header.forked-from: Forked from +gist.header.last-active: Last active +gist.header.select-tab: Select a tab +gist.header.code: Code +gist.header.revisions: Revisions +gist.header.revision: Revision +gist.header.clone-http: Clone via %s +gist.header.clone-http-help: Clone with Git using HTTP basic authentication. +gist.header.clone-ssh: Clone via SSH +gist.header.clone-ssh-help: Clone with Git using an SSH key. +gist.header.share: Share +gist.header.share-help: Copy shareable link for this gist. +gist.header.download-zip: Download ZIP + +gist.raw: Raw +gist.file-truncated: This file has been truncated. +gist.watch-full-file: View the full file. +gist.file-not-valid: This file is not a valid CSV file. +gist.no-content: No content + +gist.new.new_gist: New gist +gist.new.title: Title +gist.new.description: Description +gist.new.filename-with-extension: Filename with extension +gist.new.indent-mode: Indent mode +gist.new.indent-mode-space: Space +gist.new.indent-mode-tab: Tab +gist.new.indent-size: Indent size +gist.new.wrap-mode: Wrap mode +gist.new.wrap-mode-no: No wrap +gist.new.wrap-mode-soft: Soft wrap +gist.new.add-file: Add file +gist.new.create-public-button: Create public gist +gist.new.create-unlisted-button: Create unlisted gist +gist.new.create-private-button: Create private gist + +gist.edit.editing: Editing +gist.edit.change-visibility: Make +gist.edit.delete: Delete +gist.edit.cancel: Cancel +gist.edit.save: Save + +gist.list.joined: Joined +gist.list.all: All gists +gist.list.search-results: Search results +gist.list.sort: Sort +gist.list.sort-by-created: created +gist.list.sort-by-updated: updated +gist.list.order-by-asc: Least recently +gist.list.order-by-desc: Recently +gist.list.select-tab: Select a tab +gist.list.liked: Liked +gist.list.likes: likes +gist.list.forked: Forked +gist.list.forked-from: Forked from +gist.list.forks: forks +gist.list.files: files +gist.list.last-active: Last active +gist.list.no-gists: No gists + +gist.forks: Forks +gist.forks.view: View fork +gist.forks.no: No public forks + +gist.likes: Likes +gist.likes.no: No likes yet + +gist.revisions: Revisions +gist.revision.revised: revised this gist +gist.revision.go-to-revision: Go to revision +gist.revision.file-created: file created +gist.revision.file-deleted: file deleted +gist.revision.file-renamed: renamed to +gist.revision.diff-truncated: Diff truncated because it's too large to be shown +gist.revision.file-renamed-no-changes: File renamed without changes +gist.revision.empty-file: Empty file +gist.revision.no-changes: No changes +gist.revision.no-revisions: No revisions to show + +settings: Settings +settings.email: Email +settings.email-help: Used for commits and Gravatar +settings.email-set: Set email +settings.link-accounts: Link accounts +settings.link-github-account: Link GitHub account +settings.link-gitea-account: Link Gitea account +settings.unlink-github-account: Unlink GitHub account +settings.unlink-gitea-account: Unlink Gitea account +settings.delete-account: Delete account +settings.delete-account-confirm: Are you sure you want to delete your account ? +settings.add-ssh-key: Add SSH key +settings.add-ssh-key-help: Used only to pull/push gists using Git via SSH +settings.add-ssh-key-title: Title +settings.add-ssh-key-content: Key +settings.delete-ssh-key: Delete +settings.delete-ssh-key-confirm: Confirm deletion of SSH key +settings.ssh-key-added-at: Added +settings.ssh-key-never-used: Never used +settings.ssh-key-last-used: Last used + +auth.signup-disabled: Administrator has disabled signing up +auth.login: Login +auth.signup: Register +auth.new-account: New account +auth.username: Username +auth.password: Password +auth.register-instead: Register instead +auth.login-instead: Login instead +auth.github-oauth: Continue with GitHub account +auth.gitea-oauth: Continue with Gitea account + +error: Error + +header.menu.all: All +header.menu.new: New +header.menu.search: Search +header.menu.my-gists: My gists +header.menu.liked: Liked +header.menu.admin: Admin +header.menu.settings: Settings +header.menu.logout: Logout +header.menu.register: Register +header.menu.login: Login +header.menu.light: Light +header.menu.dark: Dark +header.menu.system: System +footer.powered-by: Powered by %s + +pagination.older: Older +pagination.newer: Newer +pagination.previous: Previous +pagination.next: Next + +admin.admin_panel: Admin panel +admin.general: General +admin.users: Users +admin.gists: Gists +admin.configuration: Configuration +admin.versions: Versions +admin.ssh_keys: SSH keys +admin.stats: Stats +admin.actions: Actions +admin.actions.sync-fs: Synchronize gists from filesystem +admin.actions.sync-db: Synchronize gists from database +admin.actions.git-gc: Garbage collect git repositories +admin.id: ID +admin.user: User +admin.delete: Delete +admin.created_at: Created + +admin.config-link: This configuration can be %s by a YAML config file and/or environment variables. +admin.config-link-overriden: overridden +admin.disable-signup: Disable signup +admin.disable-signup_help: Forbid the creation of new accounts. +admin.require-login: Require login +admin.require-login_help: Enforce users to be logged in to see gists. +admin.disable-login: Disable login form +admin.disable-login_help: Forbid logging in via the login form to force using OAuth providers instead. +admin.disable-gravatar: Disable Gravatar +admin.disable-gravatar_help: Disable the usage of Gravatar as an avatar provider. + +admin.users.delete_confirm: Do you want to delete this user ? + +admin.gists.title: Title +admin.gists.private: Private ? +admin.gists.nb-files: Nb. files +admin.gists.nb-likes: Nb. likes +admin.gists.delete_confirm: Do you want to delete this gist ? diff --git a/internal/i18n/locales/fr-FR.yml b/internal/i18n/locales/fr-FR.yml new file mode 100644 index 0000000..ced1ca9 --- /dev/null +++ b/internal/i18n/locales/fr-FR.yml @@ -0,0 +1,177 @@ +gist.public: Public +gist.unlisted: Non répertorié +gist.private: Privé + +gist.header.like: J'aime +gist.header.unlike: Je n'aime plus +gist.header.fork: Fork +gist.header.edit: Éditer +gist.header.delete: Supprimer +gist.header.forked-from: Forké de +gist.header.last-active: Dernière activité +gist.header.select-tab: Sélectionner un onglet +gist.header.code: Code +gist.header.revisions: Révisions +gist.header.revision: Révision +gist.header.clone-http: Cloner via %s +gist.header.clone-http-help: Cloner avec Git en utilisant l'authentification HTTP basic. +gist.header.clone-ssh: Cloner via SSH +gist.header.clone-ssh-help: Cloner avec Git en utilisant une clé SSH. +gist.header.share: Partager +gist.header.share-help: Copier le lien partageable de ce gist. +gist.header.download-zip: Télécharger en ZIP + +gist.raw: Brut +gist.file-truncated: Ce fichier a été tronqué. +gist.watch-full-file: Voir le fichier complet. +gist.file-not-valid: Ce fichier n'est pas un fichier CSV valide. +gist.no-content: Pas de contenu + +gist.new.new_gist: Nouveau gist +gist.new.title: Titre +gist.new.description: Description +gist.new.filename-with-extension: Nom de fichier avec extension +gist.new.indent-mode: Mode d'indentation +gist.new.indent-mode-space: Espace +gist.new.indent-mode-tab: Tabulation +gist.new.indent-size: Taille d'indentation +gist.new.wrap-mode: Mode d'enroulement +gist.new.wrap-mode-no: Sans enroulement +gist.new.wrap-mode-soft: Enroulement doux +gist.new.add-file: Ajouter un fichier +gist.new.create-public-button: Créer un gist public +gist.new.create-unlisted-button: Créer un gist non repertorié +gist.new.create-private-button: Créer un gist privé + +gist.edit.editing: Édition de +gist.edit.change-visibility: Rendre +gist.edit.delete: Supprimer +gist.edit.cancel: Annuler +gist.edit.save: Sauvegarder + +gist.list.joined: Inscrit +gist.list.all: Tous les gists +gist.list.search-results: Résultats de recherche +gist.list.sort: Trier +gist.list.sort-by-created: créé +gist.list.sort-by-updated: mis à jour +gist.list.order-by-asc: Le moins récemment +gist.list.order-by-desc: Récemment +gist.list.select-tab: Sélectionner un onglet +gist.list.liked: Aimé +gist.list.likes: j'aimes +gist.list.forked: Forké +gist.list.forked-from: Forké de +gist.list.forks: forks +gist.list.files: fichiers +gist.list.last-active: Dernière activité +gist.list.no-gists: Aucun gist + +gist.forks: Forks +gist.forks.view: Voir le fork +gist.forks.no: Pas de forks publics + +gist.likes: J'aime +gist.likes.no: Aucun j'aime pour le moment + +gist.revisions: Révisions +gist.revision.revised: a révisé ce gist +gist.revision.go-to-revision: Aller à la révision +gist.revision.file-created: fichier créé +gist.revision.file-deleted: fichier supprimé +gist.revision.file-renamed: renommé en +gist.revision.diff-truncated: Révision tronquée car trop volumineuse pour être affichée +gist.revision.file-renamed-no-changes: Fichier renommé sans modifications +gist.revision.empty-file: Fichier vide +gist.revision.no-changes: Aucun changement +gist.revision.no-revisions: Aucune révision à afficher + +settings: Paramètres +settings.email: Email +settings.email-help: Utilisé pour les commits et Gravatar +settings.email-set: Définir l'email +settings.link-accounts: Lier les comptes +settings.link-github-account: Lier le compte GitHub +settings.link-gitea-account: Lier le compte Gitea +settings.unlink-github-account: Détacher le compte GitHub +settings.unlink-gitea-account: Détacher le compte Gitea +settings.delete-account: Supprimer le compte +settings.delete-account-confirm: Êtes-vous sûr de vouloir supprimer votre compte ? +settings.add-ssh-key: Ajouter une clé SSH +settings.add-ssh-key-help: Utilisé uniquement pour pull/push des gists avec Git via SSH +settings.add-ssh-key-title: Titre +settings.add-ssh-key-content: Clé +settings.delete-ssh-key: Supprimer +settings.delete-ssh-key-confirm: Confirmer la suppression de la clé SSH +settings.ssh-key-added-at: Ajouté +settings.ssh-key-never-used: Jamais utilisé +settings.ssh-key-last-used: Dernière utilisation + +auth.signup-disabled: L'administrateur a désactivé l'inscription +auth.login: Connexion +auth.signup: Inscription +auth.new-account: Nouveau compte +auth.username: Nom d'utilisateur +auth.password: Mot de passe +auth.register-instead: Je préfère m'inscrire +auth.login-instead: Je préfère me connecter +auth.github-oauth: Continuer avec un compte GitHub +auth.gitea-oauth: Continuer avec un compte Gitea + +error: Erreur + +header.menu.all: Tous +header.menu.new: Nouveau +header.menu.search: Recherche +header.menu.my-gists: Mes gists +header.menu.liked: Aimés +header.menu.admin: Admin +header.menu.settings: Paramètres +header.menu.logout: Déconnexion +header.menu.register: Inscription +header.menu.login: Connexion +header.menu.light: Clair +header.menu.dark: Sombre +header.menu.system: Système +footer.powered-by: Propulsé par %s + +pagination.older: Plus ancien +pagination.newer: Plus récent +pagination.previous: Précédent +pagination.next: Suivant + +admin.admin_panel: Panneau d'administration +admin.general: Général +admin.users: Utilisateurs +admin.gists: Gists +admin.configuration: Configuration +admin.versions: Versions +admin.ssh_keys: Clés SSH +admin.stats: Statistiques +admin.actions: Actions +admin.actions.sync-fs: Synchroniser les gists depuis le système de fichiers +admin.actions.sync-db: Synchroniser les gists depuis la base de données +admin.actions.git-gc: Nettoyage des dépôts git +admin.id: ID +admin.user: Utilisateur +admin.delete: Supprimer +admin.created_at: Créé + +admin.config-link: Cette configuration peut être %s par un fichier de configuration YAML et/ou des variables d'environnement. +admin.config-link-overriden: remplacée +admin.disable-signup: Désactiver l'inscription +admin.disable-signup_help: Interdire la création de nouveaux comptes. +admin.require-login: Exiger la connexion +admin.require-login_help: Obliger les utilisateurs à être connectés pour voir les gists. +admin.disable-login: Désactiver le formulaire de connexion +admin.disable-login_help: Interdire la connexion via le formulaire de connexion pour forcer l'utilisation des fournisseurs OAuth à la place. +admin.disable-gravatar: Désactiver Gravatar +admin.disable-gravatar_help: Désactiver l'utilisation de Gravatar comme fournisseur d'avatar. + +admin.users.delete_confirm: Voulez-vous supprimer cet utilisateur ? + +admin.gists.title: Titre +admin.gists.private: Privé ? +admin.gists.nb-files: Nb. de fichiers +admin.gists.nb-likes: Nb. de j'aime +admin.gists.delete_confirm: Voulez-vous supprimer ce gist ? diff --git a/internal/i18n/locales/fs_embed.go b/internal/i18n/locales/fs_embed.go new file mode 100644 index 0000000..f5bdafa --- /dev/null +++ b/internal/i18n/locales/fs_embed.go @@ -0,0 +1,6 @@ +package locales + +import "embed" + +//go:embed *.yml +var Files embed.FS diff --git a/internal/memdb/memdb.go b/internal/memdb/memdb.go new file mode 100644 index 0000000..3b8a952 --- /dev/null +++ b/internal/memdb/memdb.go @@ -0,0 +1,72 @@ +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 +} diff --git a/internal/ssh/git_ssh.go b/internal/ssh/git_ssh.go index 54ca59b..7847b17 100644 --- a/internal/ssh/git_ssh.go +++ b/internal/ssh/git_ssh.go @@ -3,8 +3,8 @@ package ssh import ( "errors" "github.com/rs/zerolog/log" + "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" - "github.com/thomiceli/opengist/internal/models" "golang.org/x/crypto/ssh" "gorm.io/gorm" "io" @@ -32,27 +32,36 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error { userName := strings.ToLower(repoFields[0]) gistName := strings.TrimSuffix(strings.ToLower(repoFields[1]), ".git") - gist, err := models.GetGist(userName, gistName) + gist, err := db.GetGist(userName, gistName) if err != nil { return errors.New("gist not found") } - requireLogin, err := models.GetSetting(models.SettingRequireLogin) + requireLogin, err := db.GetSetting(db.SettingRequireLogin) if err != nil { return errors.New("internal server error") } - if verb == "receive-pack" || requireLogin == "1" { - pubKey, err := models.SSHKeyExistsForUser(key, gist.UserID) + // Check for the key if : + // - user wants to push the gist + // - user wants to clone a private gist + // - gist is not found (obfuscation) + // - admin setting to require login is set to true + if verb == "receive-pack" || + gist.Private == 2 || + gist.ID == 0 || + requireLogin == "1" { + + pubKey, err := db.SSHKeyExistsForUser(key, gist.UserID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { log.Warn().Msg("Invalid SSH authentication attempt from " + ip) - return errors.New("unauthorized") + return errors.New("gist not found") } errorSsh("Failed to get user by SSH key id", err) return errors.New("internal server error") } - _ = models.SSHKeyLastUsedNow(pubKey.Content) + _ = db.SSHKeyLastUsedNow(pubKey.Content) } repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid) diff --git a/internal/ssh/run.go b/internal/ssh/run.go index c7d3dcd..d5c4e99 100644 --- a/internal/ssh/run.go +++ b/internal/ssh/run.go @@ -4,7 +4,7 @@ import ( "errors" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" - "github.com/thomiceli/opengist/internal/models" + "github.com/thomiceli/opengist/internal/db" "golang.org/x/crypto/ssh" "gorm.io/gorm" "io" @@ -24,7 +24,7 @@ func Start() { sshConfig := &ssh.ServerConfig{ PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { strKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))) - _, err := models.SSHKeyDoesExists(strKey) + _, err := db.SSHKeyDoesExists(strKey) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err diff --git a/internal/web/admin.go b/internal/web/admin.go index 25d2c10..444b4a0 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -4,8 +4,8 @@ import ( "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" - "github.com/thomiceli/opengist/internal/models" "os" "path/filepath" "runtime" @@ -16,6 +16,7 @@ import ( var ( syncReposFromFS = false syncReposFromDB = false + gitGcRepos = false ) func adminIndex(ctx echo.Context) error { @@ -31,19 +32,19 @@ func adminIndex(ctx echo.Context) error { } setData(ctx, "gitVersion", gitVersion) - countUsers, err := models.CountAll(&models.User{}) + countUsers, err := db.CountAll(&db.User{}) if err != nil { return errorRes(500, "Cannot count users", err) } setData(ctx, "countUsers", countUsers) - countGists, err := models.CountAll(&models.Gist{}) + countGists, err := db.CountAll(&db.Gist{}) if err != nil { return errorRes(500, "Cannot count gists", err) } setData(ctx, "countGists", countGists) - countKeys, err := models.CountAll(&models.SSHKey{}) + countKeys, err := db.CountAll(&db.SSHKey{}) if err != nil { return errorRes(500, "Cannot count SSH keys", err) } @@ -51,6 +52,7 @@ func adminIndex(ctx echo.Context) error { setData(ctx, "syncReposFromFS", syncReposFromFS) setData(ctx, "syncReposFromDB", syncReposFromDB) + setData(ctx, "gitGcRepos", gitGcRepos) return html(ctx, "admin_index.html") } @@ -60,9 +62,9 @@ func adminUsers(ctx echo.Context) error { setData(ctx, "adminHeaderPage", "users") pageInt := getPage(ctx) - var data []*models.User + var data []*db.User var err error - if data, err = models.GetAllUsers(pageInt - 1); err != nil { + if data, err = db.GetAllUsers(pageInt - 1); err != nil { return errorRes(500, "Cannot get users", err) } @@ -79,9 +81,9 @@ func adminGists(ctx echo.Context) error { setData(ctx, "adminHeaderPage", "gists") pageInt := getPage(ctx) - var data []*models.Gist + var data []*db.Gist var err error - if data, err = models.GetAllGists(pageInt - 1); err != nil { + if data, err = db.GetAllGists(pageInt - 1); err != nil { return errorRes(500, "Cannot get gists", err) } @@ -94,7 +96,7 @@ func adminGists(ctx echo.Context) error { func adminUserDelete(ctx echo.Context) error { userId, _ := strconv.ParseUint(ctx.Param("user"), 10, 64) - user, err := models.GetUserById(uint(userId)) + user, err := db.GetUserById(uint(userId)) if err != nil { return errorRes(500, "Cannot retrieve user", err) } @@ -108,7 +110,7 @@ func adminUserDelete(ctx echo.Context) error { } func adminGistDelete(ctx echo.Context) error { - gist, err := models.GetGistByID(ctx.Param("gist")) + gist, err := db.GetGistByID(ctx.Param("gist")) if err != nil { return errorRes(500, "Cannot retrieve gist", err) } @@ -133,7 +135,7 @@ func adminSyncReposFromFS(ctx echo.Context) error { } syncReposFromFS = true - gists, err := models.GetAllGistsRows() + gists, err := db.GetAllGistsRows() if err != nil { log.Error().Err(err).Msg("Cannot get gists") syncReposFromFS = false @@ -170,7 +172,7 @@ func adminSyncReposFromDB(ctx echo.Context) error { for _, e := range entries { path := strings.Split(e, string(os.PathSeparator)) - gist, _ := models.GetGist(path[len(path)-2], path[len(path)-1]) + gist, _ := db.GetGist(path[len(path)-2], path[len(path)-1]) if gist.ID == 0 { if err := git.DeleteRepository(path[len(path)-2], path[len(path)-1]); err != nil { @@ -185,6 +187,23 @@ func adminSyncReposFromDB(ctx echo.Context) error { return redirect(ctx, "/admin-panel") } +func adminGcRepos(ctx echo.Context) error { + addFlash(ctx, "Garbage collecting repositories...", "success") + go func() { + if gitGcRepos { + return + } + gitGcRepos = true + if err := git.GcRepos(); err != nil { + log.Error().Err(err).Msg("Error garbage collecting repositories") + gitGcRepos = false + return + } + gitGcRepos = false + }() + return redirect(ctx, "/admin-panel") +} + func adminConfig(ctx echo.Context) error { setData(ctx, "title", "Configuration") setData(ctx, "htmlTitle", "Configuration - Admin panel") @@ -197,7 +216,7 @@ func adminSetConfig(ctx echo.Context) error { key := ctx.FormValue("key") value := ctx.FormValue("value") - if err := models.UpdateSetting(key, value); err != nil { + if err := db.UpdateSetting(key, value); err != nil { return errorRes(500, "Cannot set setting", err) } diff --git a/internal/web/auth.go b/internal/web/auth.go index a5a4e9d..2457f22 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -16,9 +16,10 @@ import ( "github.com/markbates/goth/gothic" "github.com/markbates/goth/providers/gitea" "github.com/markbates/goth/providers/github" + "github.com/markbates/goth/providers/openidConnect" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" - "github.com/thomiceli/opengist/internal/models" + "github.com/thomiceli/opengist/internal/db" "golang.org/x/text/cases" "golang.org/x/text/language" "gorm.io/gorm" @@ -27,7 +28,7 @@ import ( var title = cases.Title(language.English) func register(ctx echo.Context) error { - setData(ctx, "title", "New account") + setData(ctx, "title", tr(ctx, "auth.new-account")) setData(ctx, "htmlTitle", "New account") setData(ctx, "disableForm", getData(ctx, "DisableLoginForm")) return html(ctx, "auth_form.html") @@ -47,7 +48,7 @@ func processRegister(ctx echo.Context) error { sess := getSession(ctx) - dto := new(models.UserDTO) + dto := new(db.UserDTO) if err := ctx.Bind(dto); err != nil { return errorRes(400, "Cannot bind data", err) } @@ -57,7 +58,7 @@ func processRegister(ctx echo.Context) error { return html(ctx, "auth_form.html") } - if exists, err := models.UserExists(dto.Username); err != nil || exists { + if exists, err := db.UserExists(dto.Username); err != nil || exists { addFlash(ctx, "Username already exists", "error") return html(ctx, "auth_form.html") } @@ -87,7 +88,7 @@ func processRegister(ctx echo.Context) error { } func login(ctx echo.Context) error { - setData(ctx, "title", "Login") + setData(ctx, "title", tr(ctx, "auth.login")) setData(ctx, "htmlTitle", "Login") setData(ctx, "disableForm", getData(ctx, "DisableLoginForm")) return html(ctx, "auth_form.html") @@ -101,15 +102,15 @@ func processLogin(ctx echo.Context) error { var err error sess := getSession(ctx) - dto := &models.UserDTO{} + dto := &db.UserDTO{} if err = ctx.Bind(dto); err != nil { return errorRes(400, "Cannot bind data", err) } password := dto.Password - var user *models.User + var user *db.User - if user, err = models.GetUserByUsername(dto.Username); err != nil { + if user, err = db.GetUserByUsername(dto.Username); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return errorRes(500, "Cannot get user", err) } @@ -150,6 +151,9 @@ func oauthCallback(ctx echo.Context) error { case "gitea": currUser.GiteaID = user.UserID currUser.AvatarURL = getAvatarUrlFromProvider("gitea", user.NickName) + case "openid-connect": + currUser.OIDCID = user.UserID + currUser.AvatarURL = user.AvatarURL } if err = currUser.Update(); err != nil { @@ -161,7 +165,7 @@ func oauthCallback(ctx echo.Context) error { } // if user is not in database, create it - userDB, err := models.GetUserByProvider(user.UserID, user.Provider) + userDB, err := db.GetUserByProvider(user.UserID, user.Provider) if err != nil { if getData(ctx, "DisableSignup") == true { return errorRes(403, "Signing up is disabled", nil) @@ -171,7 +175,7 @@ func oauthCallback(ctx echo.Context) error { return errorRes(500, "Cannot get user", err) } - userDB = &models.User{ + userDB = &db.User{ Username: user.NickName, Email: user.Email, MD5Hash: fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(strings.TrimSpace(user.Email))))), @@ -185,10 +189,13 @@ func oauthCallback(ctx echo.Context) error { case "gitea": userDB.GiteaID = user.UserID userDB.AvatarURL = getAvatarUrlFromProvider("gitea", user.NickName) + case "openid-connect": + userDB.OIDCID = user.UserID + userDB.AvatarURL = user.AvatarURL } if err = userDB.Create(); err != nil { - if models.IsUniqueConstraintViolation(err) { + if db.IsUniqueConstraintViolation(err) { addFlash(ctx, "Username "+user.NickName+" already exists in Opengist", "error") return redirect(ctx, "/login") } @@ -208,6 +215,8 @@ func oauthCallback(ctx echo.Context) error { resp, err = http.Get("https://github.com/" + user.NickName + ".keys") case "gitea": resp, err = http.Get(urlJoin(config.C.GiteaUrl, user.NickName+".keys")) + case "openid-connect": + err = errors.New("cannot get keys from OIDC provider") } if err == nil { @@ -224,7 +233,7 @@ func oauthCallback(ctx echo.Context) error { keys = keys[:len(keys)-1] } for _, key := range keys { - sshKey := models.SSHKey{ + sshKey := db.SSHKey{ Title: "Added from " + user.Provider, Content: key, User: *userDB, @@ -282,6 +291,22 @@ func oauth(ctx echo.Context) error { urlJoin(config.C.GiteaUrl, "/api/v1/user"), ), ) + case "openid-connect": + 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) } currUser := getUserLogged(ctx) @@ -299,6 +324,11 @@ func oauth(ctx echo.Context) error { isDelete = true err = currUser.DeleteProviderID(provider) } + case "openid-connect": + if currUser.OIDCID != "" { + isDelete = true + err = currUser.DeleteProviderID(provider) + } } if err != nil { @@ -313,7 +343,7 @@ func oauth(ctx echo.Context) error { ctxValue := context.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, provider) ctx.SetRequest(ctx.Request().WithContext(ctxValue)) - if provider != "github" && provider != "gitea" { + if provider != "github" && provider != "gitea" && provider != "openid-connect" { return errorRes(400, "Unsupported provider", nil) } diff --git a/internal/web/gist.go b/internal/web/gist.go index e3e1d67..3b527c0 100644 --- a/internal/web/gist.go +++ b/internal/web/gist.go @@ -7,7 +7,7 @@ import ( "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/thomiceli/opengist/internal/config" - "github.com/thomiceli/opengist/internal/models" + "github.com/thomiceli/opengist/internal/db" "gorm.io/gorm" "html/template" "net/url" @@ -23,7 +23,7 @@ func gistInit(next echo.HandlerFunc) echo.HandlerFunc { gistName = strings.TrimSuffix(gistName, ".git") - gist, err := models.GetGist(userName, gistName) + gist, err := db.GetGist(userName, gistName) if err != nil { return notFound("Gist not found") } @@ -80,7 +80,7 @@ func gistInit(next echo.HandlerFunc) echo.HandlerFunc { setData(ctx, "hasLiked", hasLiked) } - if gist.Private { + if gist.Private > 0 { setData(ctx, "NoIndex", true) } @@ -88,6 +88,30 @@ func gistInit(next echo.HandlerFunc) echo.HandlerFunc { } } +// 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 @@ -97,22 +121,24 @@ func allGists(ctx echo.Context) error { pageInt := getPage(ctx) sort := "created" + sortText := tr(ctx, "gist.list.sort-by-created") order := "desc" - orderText := "Recently" + orderText := tr(ctx, "gist.list.order-by-desc") if ctx.QueryParam("sort") == "updated" { sort = "updated" + sortText = tr(ctx, "gist.list.sort-by-updated") } if ctx.QueryParam("order") == "asc" { order = "asc" - orderText = "Least recently" + orderText = tr(ctx, "gist.list.order-by-asc") } - setData(ctx, "sort", sort) + setData(ctx, "sort", sortText) setData(ctx, "order", orderText) - var gists []*models.Gist + var gists []*db.Gist var currentUserId uint if userLogged != nil { currentUserId = userLogged.ID @@ -128,12 +154,12 @@ func allGists(ctx echo.Context) error { setData(ctx, "searchQuery", ctx.QueryParam("q")) setData(ctx, "searchQueryUrl", template.URL("&q="+ctx.QueryParam("q"))) urlPage = "search" - gists, err = models.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order) + gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order) } else if strings.HasSuffix(urlctx, "all") { setData(ctx, "htmlTitle", "All gists") setData(ctx, "mode", "all") urlPage = "all" - gists, err = models.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order) + gists, err = db.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order) } } else { liked := false @@ -149,9 +175,9 @@ func allGists(ctx echo.Context) error { return errorRes(500, "Error matching regexp", err) } - var fromUser *models.User + var fromUser *db.User - fromUser, err = models.GetUserByUsername(fromUserStr) + fromUser, err = db.GetUserByUsername(fromUserStr) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return notFound("User not found") @@ -160,19 +186,19 @@ func allGists(ctx echo.Context) error { } setData(ctx, "fromUser", fromUser) - if countFromUser, err := models.CountAllGistsFromUser(fromUser.ID, currentUserId); err != nil { + 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 := models.CountAllGistsLikedByUser(fromUser.ID, currentUserId); err != nil { + 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 := models.CountAllGistsForkedByUser(fromUser.ID, currentUserId); err != nil { + if countForked, err := db.CountAllGistsForkedByUser(fromUser.ID, currentUserId); err != nil { return errorRes(500, "Error counting forked gists", err) } else { setData(ctx, "countForked", countForked) @@ -182,17 +208,17 @@ func allGists(ctx echo.Context) error { urlPage = fromUserStr + "/liked" setData(ctx, "htmlTitle", "All gists liked by "+fromUserStr) setData(ctx, "mode", "liked") - gists, err = models.GetAllGistsLikedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order) + gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order) } else if forked { urlPage = fromUserStr + "/forked" setData(ctx, "htmlTitle", "All gists forked by "+fromUserStr) setData(ctx, "mode", "forked") - gists, err = models.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order) + gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order) } else { urlPage = fromUserStr setData(ctx, "htmlTitle", "All gists from "+fromUserStr) setData(ctx, "mode", "fromUser") - gists, err = models.GetAllGistsFromUser(fromUser.ID, currentUserId, pageInt-1, sort, order) + gists, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, pageInt-1, sort, order) } } @@ -209,7 +235,7 @@ func allGists(ctx echo.Context) error { } func gistIndex(ctx echo.Context) error { - gist := getData(ctx, "gist").(*models.Gist) + gist := getData(ctx, "gist").(*db.Gist) revision := ctx.Param("revision") if revision == "" { @@ -234,7 +260,7 @@ func gistIndex(ctx echo.Context) error { } func revisions(ctx echo.Context) error { - gist := getData(ctx, "gist").(*models.Gist) + gist := getData(ctx, "gist").(*db.Gist) userName := gist.User.Username gistName := gist.Uuid @@ -257,7 +283,7 @@ func revisions(ctx echo.Context) error { emailsSet[strings.ToLower(commit.AuthorEmail)] = struct{}{} } - emailsUsers, err := models.GetUsersFromEmails(emailsSet) + emailsUsers, err := db.GetUsersFromEmails(emailsSet) if err != nil { return errorRes(500, "Error fetching users emails", err) } @@ -286,13 +312,13 @@ func processCreate(ctx echo.Context) error { return errorRes(400, "Bad request", err) } - dto := new(models.GistDTO) - var gist *models.Gist + dto := new(db.GistDTO) + var gist *db.Gist if isCreate { setData(ctx, "htmlTitle", "Create a new gist") } else { - gist = getData(ctx, "gist").(*models.Gist) + gist = getData(ctx, "gist").(*db.Gist) setData(ctx, "htmlTitle", "Edit "+gist.Title) } @@ -300,7 +326,7 @@ func processCreate(ctx echo.Context) error { return errorRes(400, "Cannot bind data", err) } - dto.Files = make([]models.FileDTO, 0) + dto.Files = make([]db.FileDTO, 0) fileCounter := 0 for i := 0; i < len(ctx.Request().PostForm["content"]); i++ { name := ctx.Request().PostForm["name"][i] @@ -316,7 +342,7 @@ func processCreate(ctx echo.Context) error { return errorRes(400, "Invalid character unescaped", err) } - dto.Files = append(dto.Files, models.FileDTO{ + dto.Files = append(dto.Files, db.FileDTO{ Filename: strings.Trim(name, " "), Content: escapedValue, }) @@ -398,9 +424,9 @@ func processCreate(ctx echo.Context) error { } func toggleVisibility(ctx echo.Context) error { - var gist = getData(ctx, "gist").(*models.Gist) + var gist = getData(ctx, "gist").(*db.Gist) - gist.Private = !gist.Private + gist.Private = (gist.Private + 1) % 3 if err := gist.Update(); err != nil { return errorRes(500, "Error updating this gist", err) } @@ -410,12 +436,7 @@ func toggleVisibility(ctx echo.Context) error { } func deleteGist(ctx echo.Context) error { - var gist = getData(ctx, "gist").(*models.Gist) - - err := gist.DeleteRepository() - if err != nil { - return errorRes(500, "Error deleting the repository", err) - } + var gist = getData(ctx, "gist").(*db.Gist) if err := gist.Delete(); err != nil { return errorRes(500, "Error deleting this gist", err) @@ -426,7 +447,7 @@ func deleteGist(ctx echo.Context) error { } func like(ctx echo.Context) error { - var gist = getData(ctx, "gist").(*models.Gist) + var gist = getData(ctx, "gist").(*db.Gist) currentUser := getUserLogged(ctx) hasLiked, err := currentUser.HasLiked(gist) @@ -452,7 +473,7 @@ func like(ctx echo.Context) error { } func fork(ctx echo.Context) error { - var gist = getData(ctx, "gist").(*models.Gist) + var gist = getData(ctx, "gist").(*db.Gist) currentUser := getUserLogged(ctx) alreadyForked, err := gist.GetForkParent(currentUser) @@ -474,7 +495,7 @@ func fork(ctx echo.Context) error { return errorRes(500, "Error creating an UUID", err) } - newGist := &models.Gist{ + newGist := &db.Gist{ Uuid: strings.Replace(uuidGist.String(), "-", "", -1), Title: gist.Title, Preview: gist.Preview, @@ -503,7 +524,7 @@ func fork(ctx echo.Context) error { } func rawFile(ctx echo.Context) error { - gist := getData(ctx, "gist").(*models.Gist) + gist := getData(ctx, "gist").(*db.Gist) file, err := gist.File(ctx.Param("revision"), ctx.Param("file"), false) if err != nil { @@ -517,8 +538,32 @@ func rawFile(ctx echo.Context) error { 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 { - var gist = getData(ctx, "gist").(*models.Gist) + var gist = getData(ctx, "gist").(*db.Gist) files, err := gist.Files("HEAD") if err != nil { @@ -532,7 +577,7 @@ func edit(ctx echo.Context) error { } func downloadZip(ctx echo.Context) error { - var gist = getData(ctx, "gist").(*models.Gist) + var gist = getData(ctx, "gist").(*db.Gist) var revision = ctx.Param("revision") files, err := gist.Files(revision) @@ -577,7 +622,7 @@ func downloadZip(ctx echo.Context) error { } func likes(ctx echo.Context) error { - var gist = getData(ctx, "gist").(*models.Gist) + var gist = getData(ctx, "gist").(*db.Gist) pageInt := getPage(ctx) @@ -596,7 +641,7 @@ func likes(ctx echo.Context) error { } func forks(ctx echo.Context) error { - var gist = getData(ctx, "gist").(*models.Gist) + var gist = getData(ctx, "gist").(*db.Gist) pageInt := getPage(ctx) currentUser := getUserLogged(ctx) diff --git a/internal/web/git_http.go b/internal/web/git_http.go index 54e0649..a514d8d 100644 --- a/internal/web/git_http.go +++ b/internal/web/git_http.go @@ -4,11 +4,15 @@ import ( "bytes" "compress/gzip" "encoding/base64" + "errors" "fmt" + "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" + "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" - "github.com/thomiceli/opengist/internal/models" + "github.com/thomiceli/opengist/internal/memdb" + "gorm.io/gorm" "net/http" "os" "os/exec" @@ -45,25 +49,31 @@ func gitHttp(ctx echo.Context) error { continue } - gist := getData(ctx, "gist").(*models.Gist) + gist := getData(ctx, "gist").(*db.Gist) - noAuth := (ctx.QueryParam("service") == "git-upload-pack" || + isInit := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs") + isInitReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack") + isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$") + isPull := ctx.QueryParam("service") == "git-upload-pack" || strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") || - ctx.Request().Method == "GET") && - !getData(ctx, "RequireLogin").(bool) + ctx.Request().Method == "GET" && !isInfoRefs repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid) - if _, err := os.Stat(repositoryPath); os.IsNotExist(err) { if err != nil { - return errorRes(500, "Repository does not exist", err) + log.Info().Err(err).Msg("Repository directory does not exist") + return errorRes(404, "Repository directory does not exist", err) } } - ctx.Set("repositoryPath", repositoryPath) + setData(ctx, "repositoryPath", repositoryPath) - // Requires Basic Auth if we push the repository - if noAuth { + // Shows basic auth if : + // - user wants to push the gist + // - user wants to clone/pull a private gist + // - gist is not found (obfuscation) + // - admin setting to require login is set to true + if isPull && gist.Private != 2 && gist.ID != 0 && !getData(ctx, "RequireLogin").(bool) { return route.handler(ctx) } @@ -82,12 +92,70 @@ func gitHttp(ctx echo.Context) error { return basicAuth(ctx) } - if ok, err := argon2id.verify(authPassword, gist.User.Password); !ok || gist.User.Username != authUsername { - if err != nil { - return errorRes(500, "Cannot verify password", err) + if !isInit && !isInitReceive { + if gist.ID == 0 { + return plainText(ctx, 404, "Check your credentials or make sure you have access to the Gist") + } + + if ok, err := argon2id.verify(authPassword, gist.User.Password); !ok || gist.User.Username != authUsername { + if err != nil { + return errorRes(500, "Cannot verify password", err) + } + log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) + return plainText(ctx, 404, "Check your credentials or make sure you have access to the Gist") + } + } else { + var user *db.User + if user, err = db.GetUserByUsername(authUsername); err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return errorRes(500, "Cannot get user", err) + } + log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) + return errorRes(401, "Invalid credentials", nil) + } + + if ok, err := argon2id.verify(authPassword, user.Password); !ok { + if err != nil { + return errorRes(500, "Cannot check for password", err) + } + log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) + return errorRes(401, "Invalid credentials", nil) + } + + if isInit { + gist = new(db.Gist) + gist.UserID = user.ID + gist.User = *user + uuidGist, err := uuid.NewRandom() + if err != nil { + return errorRes(500, "Error creating an UUID", err) + } + gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1) + gist.Title = "gist:" + gist.Uuid + + if err = gist.InitRepositoryViaInit(ctx); err != nil { + return errorRes(500, "Cannot init repository in the file system", err) + } + + if err = gist.Create(); err != nil { + return errorRes(500, "Cannot init repository in database", err) + } + + if err := memdb.InsertGistInit(user.ID, gist); err != nil { + return errorRes(500, "Cannot save the URL for the new Gist", err) + } + + setData(ctx, "gist", gist) + } else { + gistFromMemdb, err := memdb.GetGistInitAndDelete(user.ID) + if err != nil { + return errorRes(500, "Cannot get the gist link from the in memory database", err) + } + + gist := gistFromMemdb.Gist + setData(ctx, "gist", gist) + setData(ctx, "repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid)) } - log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) - return errorRes(403, "Unauthorized", nil) } return route.handler(ctx) @@ -123,7 +191,7 @@ func pack(ctx echo.Context, serviceType string) error { } } - repositoryPath := ctx.Get("repositoryPath").(string) + repositoryPath := getData(ctx, "repositoryPath").(string) var stderr bytes.Buffer cmd := exec.Command("git", serviceType, "--stateless-rpc", repositoryPath) @@ -137,7 +205,16 @@ func pack(ctx echo.Context, serviceType string) error { // updatedAt is updated only if serviceType is receive-pack if serviceType == "receive-pack" { - gist := getData(ctx, "gist").(*models.Gist) + gist := getData(ctx, "gist").(*db.Gist) + + if hasNoCommits, err := git.HasNoCommits(gist.User.Username, gist.Uuid); err != nil { + return err + } else if hasNoCommits { + if err = gist.Delete(); err != nil { + return err + } + } + _ = gist.SetLastActiveNow() _ = gist.UpdatePreviewAndCount() } @@ -148,7 +225,7 @@ func infoRefs(ctx echo.Context) error { noCacheHeaders(ctx) var service string - gist := getData(ctx, "gist").(*models.Gist) + gist := getData(ctx, "gist").(*db.Gist) serviceType := ctx.QueryParam("service") if strings.HasPrefix(serviceType, "git-") { @@ -232,7 +309,7 @@ func basicAuthDecode(encoded string) (string, string, error) { func sendFile(ctx echo.Context, contentType string) error { gitFile := "/" + strings.Join(strings.Split(ctx.Request().URL.Path, "/")[3:], "/") - gitFile = path.Join(ctx.Get("repositoryPath").(string), gitFile) + gitFile = path.Join(getData(ctx, "repositoryPath").(string), gitFile) fi, err := os.Stat(gitFile) if os.IsNotExist(err) { return errorRes(404, "File not found", nil) diff --git a/internal/web/run.go b/internal/web/server.go similarity index 68% rename from internal/web/run.go rename to internal/web/server.go index 0d92733..33332f9 100644 --- a/internal/web/run.go +++ b/internal/web/server.go @@ -10,13 +10,16 @@ import ( "github.com/markbates/goth/gothic" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" - "github.com/thomiceli/opengist/internal/models" + "github.com/thomiceli/opengist/internal/i18n" + "github.com/thomiceli/opengist/public" + "github.com/thomiceli/opengist/templates" + "golang.org/x/text/language" + htmlpkg "html" "html/template" "io" - "io/fs" "net/http" - "os" "path/filepath" "regexp" "strconv" @@ -24,17 +27,17 @@ import ( "time" ) -var dev = os.Getenv("OG_DEV") == "1" +var dev bool var store *sessions.CookieStore var re = regexp.MustCompile("[^a-z0-9]+") var fm = template.FuncMap{ "split": strings.Split, "indexByte": strings.IndexByte, - "toInt": func(i string) int64 { - val, _ := strconv.ParseInt(i, 10, 64) + "toInt": func(i string) int { + val, _ := strconv.Atoi(i) return val }, - "inc": func(i int64) int64 { + "inc": func(i int) int { return i + 1 }, "splitGit": func(i string) []string { @@ -70,7 +73,7 @@ var fm = template.FuncMap{ "slug": func(s string) string { return strings.Trim(re.ReplaceAllString(strings.ToLower(s), "-"), "-") }, - "avatarUrl": func(user *models.User, noGravatar bool) string { + "avatarUrl": func(user *db.User, noGravatar bool) string { if user.AvatarURL != "" { return user.AvatarURL } @@ -81,16 +84,38 @@ var fm = template.FuncMap{ return defaultAvatar() }, - "asset": func(jsfile string) string { + "asset": func(file string) string { if dev { - return "http://localhost:16157/" + jsfile + return "http://localhost:16157/" + file } - return config.C.ExternalUrl + "/" + manifestEntries[jsfile].File + return config.C.ExternalUrl + "/" + manifestEntries[file].File + }, + "dev": func() bool { + return dev }, "defaultAvatar": defaultAvatar, -} + "visibilityStr": func(visibility int, lowercase bool) string { + s := "Public" + switch visibility { + case 1: + s = "Unlisted" + case 2: + s = "Private" + } -var EmbedFS fs.FS + if lowercase { + return strings.ToLower(s) + } + return s + }, + "unescape": htmlpkg.UnescapeString, + "join": func(s ...string) string { + return strings.Join(s, "") + }, + "toStr": func(i interface{}) string { + return fmt.Sprint(i) + }, +} type Template struct { templates *template.Template @@ -100,17 +125,26 @@ func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Con return t.templates.ExecuteTemplate(w, name, data) } -func Start() { +type Server struct { + echo *echo.Echo + dev bool +} + +func NewServer(isDev bool) *Server { + dev = isDev store = sessions.NewCookieStore([]byte("opengist")) gothic.Store = store - assetsFS := echo.MustSubFS(EmbedFS, "public/assets") - e := echo.New() e.HideBanner = true e.HidePort = true + if err := i18n.Locales.LoadAll(); err != nil { + log.Fatal().Err(err).Msg("Failed to load locales") + } + e.Use(dataInit) + e.Use(locale) e.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{ Getter: middleware.MethodFromForm("_method"), })) @@ -125,11 +159,11 @@ func Start() { return nil }, })) - e.Use(middleware.Recover()) + //e.Use(middleware.Recover()) e.Use(middleware.Secure()) e.Renderer = &Template{ - templates: template.Must(template.New("t").Funcs(fm).ParseFS(EmbedFS, "templates/*/*.html")), + templates: template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html")), } e.HTTPErrorHandler = func(er error, ctx echo.Context) { if err, ok := er.(*echo.HTTPError); ok { @@ -152,19 +186,21 @@ func Start() { if !dev { parseManifestEntries() - e.GET("/assets/*", cacheControl(echo.WrapHandler(http.StripPrefix("/assets", http.FileServer(http.FS(assetsFS)))))) + e.GET("/assets/*", cacheControl(echo.WrapHandler(http.FileServer(http.FS(public.Files))))) } // Web based routes g1 := e.Group("") { - g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ - TokenLookup: "form:_csrf", - CookiePath: "/", - CookieHTTPOnly: true, - CookieSameSite: http.SameSiteStrictMode, - })) - g1.Use(csrfInit) + if !dev { + g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ + TokenLookup: "form:_csrf", + CookiePath: "/", + CookieHTTPOnly: true, + CookieSameSite: http.SameSiteStrictMode, + })) + g1.Use(csrfInit) + } g1.GET("/", create, logged) g1.POST("/", processCreate, logged) @@ -193,10 +229,15 @@ func Start() { g2.POST("/gists/:gist/delete", adminGistDelete) g2.POST("/sync-fs", adminSyncReposFromFS) g2.POST("/sync-db", adminSyncReposFromDB) + g2.POST("/gc-repos", adminGcRepos) g2.GET("/configuration", adminConfig) g2.PUT("/set-config", adminSetConfig) } + if config.C.HttpGit { + e.Any("/init/*", gitHttp, gistNewPushSoftInit) + } + g1.GET("/all", allGists, checkRequireLogin) g1.GET("/search", allGists, checkRequireLogin) g1.GET("/:user", allGists, checkRequireLogin) @@ -213,6 +254,7 @@ func Start() { g3.POST("/visibility", toggleVisibility, logged, writePermission) g3.POST("/delete", deleteGist, logged, writePermission) g3.GET("/raw/:revision/:file", rawFile) + g3.GET("/download/:revision/:file", downloadFile) g3.GET("/edit", edit, logged, writePermission) g3.POST("/edit", processCreate, logged, writePermission) g3.POST("/like", like, logged) @@ -222,30 +264,35 @@ func Start() { } } - debugStr := "" // Git HTTP routes if config.C.HttpGit { - e.Any("/:user/:gistname/*", gitHttp, gistInit) - debugStr = " (with Git over HTTP)" + e.Any("/:user/:gistname/*", gitHttp, gistSoftInit) } e.Any("/*", noRouteFound) + return &Server{echo: e, dev: dev} +} + +func (s *Server) Start() { addr := config.C.HttpHost + ":" + config.C.HttpPort - if config.C.HttpTLSEnabled { - log.Info().Msg("Starting HTTPS server on https://" + addr + debugStr) - if err := e.StartTLS(addr, config.C.HttpCertFile, config.C.HttpKeyFile); err != nil { - log.Fatal().Err(err).Msg("Failed to start HTTPS server") - } - } else { - log.Info().Msg("Starting HTTP server on http://" + addr + debugStr) - if err := e.Start(addr); err != nil { - log.Fatal().Err(err).Msg("Failed to start HTTP server") - } + log.Info().Msg("Starting HTTP server on http://" + addr) + if err := s.echo.Start(addr); err != nil && err != http.ErrServerClosed { + log.Fatal().Err(err).Msg("Failed to start HTTP server") } } +func (s *Server) Stop() { + if err := s.echo.Close(); err != nil { + log.Fatal().Err(err).Msg("Failed to stop HTTP server") + } +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.echo.ServeHTTP(w, r) +} + func dataInit(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { ctxValue := context.WithValue(ctx.Request().Context(), dataKey, echo.Map{}) @@ -260,6 +307,51 @@ func dataInit(next echo.HandlerFunc) echo.HandlerFunc { setData(ctx, "githubOauth", config.C.GithubClientKey != "" && config.C.GithubSecret != "") setData(ctx, "giteaOauth", config.C.GiteaClientKey != "" && config.C.GiteaSecret != "") + setData(ctx, "oidcOauth", config.C.OIDCClientKey != "" && config.C.OIDCSecret != "" && config.C.OIDCDiscoveryUrl != "") + + return next(ctx) + } +} + +func locale(next echo.HandlerFunc) echo.HandlerFunc { + return func(ctx echo.Context) error { + + // Check URL arguments + lang := ctx.Request().URL.Query().Get("lang") + changeLang := lang != "" + + // Then check cookies + if len(lang) == 0 { + cookie, _ := ctx.Request().Cookie("lang") + if cookie != nil { + lang = cookie.Value + } + } + + // Check again in case someone changes the supported language list. + if lang != "" && !i18n.Locales.HasLocale(lang) { + lang = "" + changeLang = false + } + + //3.Then check from 'Accept-Language' header. + if len(lang) == 0 { + tags, _, _ := language.ParseAcceptLanguage(ctx.Request().Header.Get("Accept-Language")) + lang = i18n.Locales.MatchTag(tags) + } + + if changeLang { + ctx.SetCookie(&http.Cookie{Name: "lang", Value: lang, Path: "/", MaxAge: 1<<31 - 1}) + } + + localeUsed, err := i18n.Locales.GetLocale(lang) + if err != nil { + return errorRes(500, "Cannot get locale", err) + } + + setData(ctx, "localeName", localeUsed.Name) + setData(ctx, "locale", localeUsed) + setData(ctx, "allLocales", i18n.Locales.Locales) return next(ctx) } @@ -270,9 +362,9 @@ func sessionInit(next echo.HandlerFunc) echo.HandlerFunc { sess := getSession(ctx) if sess.Values["user"] != nil { var err error - var user *models.User + var user *db.User - if user, err = models.GetUserById(sess.Values["user"].(uint)); err != nil { + if user, err = db.GetUserById(sess.Values["user"].(uint)); err != nil { sess.Values["user"] = nil saveSession(sess, ctx) setData(ctx, "userLogged", nil) @@ -300,8 +392,8 @@ func writePermission(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { gist := getData(ctx, "gist") user := getUserLogged(ctx) - if !gist.(*models.Gist).CanWrite(user) { - return redirect(ctx, "/"+gist.(*models.Gist).User.Username+"/"+gist.(*models.Gist).Uuid) + if !gist.(*db.Gist).CanWrite(user) { + return redirect(ctx, "/"+gist.(*db.Gist).User.Username+"/"+gist.(*db.Gist).Uuid) } return next(ctx) } @@ -362,7 +454,7 @@ type Asset struct { var manifestEntries map[string]Asset func parseManifestEntries() { - file, err := EmbedFS.Open("public/manifest.json") + file, err := public.Files.Open("manifest.json") if err != nil { log.Fatal().Err(err).Msg("Failed to open manifest.json") } diff --git a/internal/web/settings.go b/internal/web/settings.go index fd6f0d3..4056ba0 100644 --- a/internal/web/settings.go +++ b/internal/web/settings.go @@ -4,7 +4,7 @@ import ( "crypto/md5" "fmt" "github.com/labstack/echo/v4" - "github.com/thomiceli/opengist/internal/models" + "github.com/thomiceli/opengist/internal/db" "golang.org/x/crypto/ssh" "strconv" "strings" @@ -14,7 +14,7 @@ import ( func userSettings(ctx echo.Context) error { user := getUserLogged(ctx) - keys, err := models.GetSSHKeysByUserID(user.ID) + keys, err := db.GetSSHKeysByUserID(user.ID) if err != nil { return errorRes(500, "Cannot get SSH keys", err) } @@ -61,7 +61,7 @@ func accountDeleteProcess(ctx echo.Context) error { func sshKeysProcess(ctx echo.Context) error { user := getUserLogged(ctx) - var dto = new(models.SSHKeyDTO) + var dto = new(db.SSHKeyDTO) if err := ctx.Bind(dto); err != nil { return errorRes(400, "Cannot bind data", err) } @@ -97,7 +97,7 @@ func sshKeysDelete(ctx echo.Context) error { return redirect(ctx, "/settings") } - key, err := models.GetSSHKeyByID(uint(keyId)) + key, err := db.GetSSHKeyByID(uint(keyId)) if err != nil || key.UserID != user.ID { return redirect(ctx, "/settings") diff --git a/internal/web/test/auth_test.go b/internal/web/test/auth_test.go new file mode 100644 index 0000000..73b7b9d --- /dev/null +++ b/internal/web/test/auth_test.go @@ -0,0 +1,91 @@ +package test + +import ( + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + "testing" +) + +func TestRegister(t *testing.T) { + setup(t) + s, err := newTestServer() + require.NoError(t, err, "Failed to create test server") + defer teardown(t, s) + + err = s.request("GET", "/", nil, 302) + require.NoError(t, err) + + err = s.request("GET", "/register", nil, 200) + require.NoError(t, err) + + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + user1db, err := db.GetUserById(1) + require.NoError(t, err) + require.Equal(t, user1.Username, user1db.Username) + require.True(t, user1db.IsAdmin) + + err = s.request("GET", "/", nil, 200) + require.NoError(t, err) + + s.sessionCookie = "" + + user2 := db.UserDTO{Username: "thomas", Password: "azeaze"} + err = s.request("POST", "/register", user2, 200) + require.Error(t, err) + + user3 := db.UserDTO{Username: "kaguya", Password: "kaguya"} + register(t, s, user3) + + user3db, err := db.GetUserById(2) + require.NoError(t, err) + require.False(t, user3db.IsAdmin) + + s.sessionCookie = "" + + count, err := db.CountAll(db.User{}) + require.NoError(t, err) + require.Equal(t, int64(2), count) +} + +func TestLogin(t *testing.T) { + setup(t) + s, err := newTestServer() + require.NoError(t, err, "Failed to create test server") + defer teardown(t, s) + + err = s.request("GET", "/login", nil, 200) + require.NoError(t, err) + + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + s.sessionCookie = "" + + login(t, s, user1) + require.NotEmpty(t, s.sessionCookie) + + s.sessionCookie = "" + + user2 := db.UserDTO{Username: "thomas", Password: "azeaze"} + user3 := db.UserDTO{Username: "azeaze", Password: ""} + + err = s.request("POST", "/login", user2, 302) + require.Empty(t, s.sessionCookie) + require.Error(t, err) + + err = s.request("POST", "/login", user3, 302) + require.Empty(t, s.sessionCookie) + require.Error(t, err) +} + +func register(t *testing.T, s *testServer, user db.UserDTO) { + err := s.request("POST", "/register", user, 302) + require.NoError(t, err) +} + +func login(t *testing.T, s *testServer, user db.UserDTO) { + err := s.request("POST", "/login", user, 302) + require.NoError(t, err) +} diff --git a/internal/web/test/gist_test.go b/internal/web/test/gist_test.go new file mode 100644 index 0000000..b477e0e --- /dev/null +++ b/internal/web/test/gist_test.go @@ -0,0 +1,200 @@ +package test + +import ( + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/git" + "testing" +) + +func TestGists(t *testing.T) { + setup(t) + s, err := newTestServer() + require.NoError(t, err, "Failed to create test server") + defer teardown(t, s) + + err = s.request("GET", "/", nil, 302) + require.NoError(t, err) + + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + err = s.request("GET", "/all", nil, 200) + require.NoError(t, err) + + err = s.request("POST", "/", nil, 200) + require.NoError(t, err) + + gist1 := db.GistDTO{ + Title: "gist1", + Description: "my first gist", + Private: 0, + Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, + Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, + } + err = s.request("POST", "/", gist1, 302) + require.NoError(t, err) + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, uint(1), gist1db.ID) + require.Equal(t, gist1.Title, gist1db.Title) + require.Equal(t, gist1.Description, gist1db.Description) + require.Regexp(t, "[a-f0-9]{32}", gist1db.Uuid) + require.Equal(t, user1.Username, gist1db.User.Username) + + err = s.request("GET", "/"+gist1db.User.Username+"/"+gist1db.Uuid, nil, 200) + require.NoError(t, err) + + gist1files, err := git.GetFilesOfRepository(gist1db.User.Username, gist1db.Uuid, "HEAD") + require.NoError(t, err) + require.Equal(t, 3, len(gist1files)) + + gist1fileContent, _, err := git.GetFileContent(gist1db.User.Username, gist1db.Uuid, "HEAD", gist1.Name[0], false) + require.NoError(t, err) + require.Equal(t, gist1.Content[0], gist1fileContent) + + gist2 := db.GistDTO{ + Title: "gist2", + Description: "my second gist", + Private: 0, + Name: []string{"", "gist2.txt", "gist3.txt"}, + Content: []string{"", "yeah\ncool", "yeah\ncool gist actually"}, + } + err = s.request("POST", "/", gist2, 200) + require.NoError(t, err) + + gist3 := db.GistDTO{ + Title: "gist3", + Description: "my third gist", + Private: 0, + Name: []string{""}, + Content: []string{"yeah"}, + } + err = s.request("POST", "/", gist3, 302) + require.NoError(t, err) + + gist3db, err := db.GetGistByID("2") + require.NoError(t, err) + + gist3files, err := git.GetFilesOfRepository(gist3db.User.Username, gist3db.Uuid, "HEAD") + require.NoError(t, err) + require.Equal(t, "gistfile1.txt", gist3files[0]) + + err = s.request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/edit", nil, 200) + require.NoError(t, err) + + gist1.Name = []string{"gist1.txt"} + gist1.Content = []string{"only want one gist"} + + err = s.request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/edit", gist1, 302) + require.NoError(t, err) + + gist1files, err = git.GetFilesOfRepository(gist1db.User.Username, gist1db.Uuid, "HEAD") + require.NoError(t, err) + require.Equal(t, 1, len(gist1files)) + + err = s.request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/delete", nil, 302) + require.NoError(t, err) +} + +func TestVisibility(t *testing.T) { + setup(t) + s, err := newTestServer() + require.NoError(t, err, "Failed to create test server") + defer teardown(t, s) + + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + gist1 := db.GistDTO{ + Title: "gist1", + Description: "my first gist", + Private: 1, + Name: []string{""}, + Content: []string{"yeah"}, + } + err = s.request("POST", "/", gist1, 302) + require.NoError(t, err) + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, 1, gist1db.Private) + + err = s.request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/visibility", nil, 302) + require.NoError(t, err) + gist1db, err = db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, 2, gist1db.Private) + + err = s.request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/visibility", nil, 302) + require.NoError(t, err) + gist1db, err = db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, 0, gist1db.Private) + + err = s.request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/visibility", nil, 302) + require.NoError(t, err) + gist1db, err = db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, 1, gist1db.Private) +} + +func TestLikeFork(t *testing.T) { + setup(t) + s, err := newTestServer() + require.NoError(t, err, "Failed to create test server") + defer teardown(t, s) + + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + gist1 := db.GistDTO{ + Title: "gist1", + Description: "my first gist", + Private: 1, + Name: []string{""}, + Content: []string{"yeah"}, + } + err = s.request("POST", "/", gist1, 302) + require.NoError(t, err) + + s.sessionCookie = "" + + user2 := db.UserDTO{Username: "kaguya", Password: "kaguya"} + register(t, s, user2) + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, 0, gist1db.NbLikes) + likeCount, err := db.CountAll(db.Like{}) + require.NoError(t, err) + require.Equal(t, int64(0), likeCount) + + err = s.request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/like", nil, 302) + require.NoError(t, err) + gist1db, err = db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, 1, gist1db.NbLikes) + likeCount, err = db.CountAll(db.Like{}) + require.NoError(t, err) + require.Equal(t, int64(1), likeCount) + + err = s.request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/like", nil, 302) + require.NoError(t, err) + gist1db, err = db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, 0, gist1db.NbLikes) + likeCount, err = db.CountAll(db.Like{}) + require.NoError(t, err) + require.Equal(t, int64(0), likeCount) + + err = s.request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/fork", nil, 302) + require.NoError(t, err) + gist2db, err := db.GetGistByID("2") + require.NoError(t, err) + require.Equal(t, gist1db.Title, gist2db.Title) + require.Equal(t, gist1db.Description, gist2db.Description) + require.Equal(t, gist1db.Private, gist2db.Private) + require.Equal(t, user2.Username, gist2db.User.Username) +} diff --git a/internal/web/test/server.go b/internal/web/test/server.go new file mode 100644 index 0000000..a6853c1 --- /dev/null +++ b/internal/web/test/server.go @@ -0,0 +1,162 @@ +package test + +import ( + "errors" + "fmt" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/git" + "github.com/thomiceli/opengist/internal/memdb" + "github.com/thomiceli/opengist/internal/web" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +type testServer struct { + server *web.Server + sessionCookie string +} + +func newTestServer() (*testServer, error) { + s := &testServer{ + server: web.NewServer(true), + } + + go s.start() + return s, nil +} + +func (s *testServer) start() { + s.server.Start() +} + +func (s *testServer) stop() { + s.server.Stop() +} + +func (s *testServer) request(method, uri string, data interface{}, expectedCode int) error { + var bodyReader io.Reader + if method == http.MethodPost || method == http.MethodPut { + values := structToURLValues(data) + bodyReader = strings.NewReader(values.Encode()) + } + + req := httptest.NewRequest(method, "http://localhost:6157"+uri, bodyReader) + w := httptest.NewRecorder() + + if method == http.MethodPost || method == http.MethodPut { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + if s.sessionCookie != "" { + req.AddCookie(&http.Cookie{Name: "session", Value: s.sessionCookie}) + } + + s.server.ServeHTTP(w, req) + + if w.Code != expectedCode { + return fmt.Errorf("unexpected status code %d, expected %d", w.Code, expectedCode) + } + + if method == http.MethodPost { + if strings.Contains(uri, "/login") || strings.Contains(uri, "/register") { + cookie := "" + h := w.Header().Get("Set-Cookie") + parts := strings.Split(h, "; ") + for _, p := range parts { + if strings.HasPrefix(p, "session=") { + cookie = p + break + } + } + if cookie == "" { + return errors.New("unable to find access session token in response headers") + } + s.sessionCookie = strings.TrimPrefix(cookie, "session=") + } else if strings.Contains(uri, "/logout") { + s.sessionCookie = "" + } + } + + return nil +} + +func structToURLValues(s interface{}) url.Values { + v := url.Values{} + if s == nil { + return v + } + + rValue := reflect.ValueOf(s) + if rValue.Kind() != reflect.Struct { + return v + } + + for i := 0; i < rValue.NumField(); i++ { + field := rValue.Type().Field(i) + tag := field.Tag.Get("form") + if tag != "" { + if field.Type.Kind() == reflect.Int { + fieldValue := rValue.Field(i).Int() + v.Add(tag, strconv.FormatInt(fieldValue, 10)) + } else if field.Type.Kind() == reflect.Slice { + fieldValue := rValue.Field(i).Interface().([]string) + for _, va := range fieldValue { + v.Add(tag, va) + } + } else { + fieldValue := rValue.Field(i).String() + v.Add(tag, fieldValue) + } + } + } + return v +} + +func setup(t *testing.T) { + err := config.InitConfig("") + require.NoError(t, err, "Could not init config") + + err = os.MkdirAll(filepath.Join(config.GetHomeDir()), 0755) + require.NoError(t, err, "Could not create Opengist home directory") + + git.ReposDirectory = path.Join("tests") + + config.InitLog() + + homePath := config.GetHomeDir() + log.Info().Msg("Data directory: " + homePath) + + err = os.MkdirAll(filepath.Join(homePath, "repos"), 0755) + require.NoError(t, err, "Could not create repos directory") + + err = os.MkdirAll(filepath.Join(homePath, "tmp", "repos"), 0755) + require.NoError(t, err, "Could not create tmp repos directory") + + err = db.Setup("file::memory:", true) + require.NoError(t, err, "Could not initialize database") + + err = memdb.Setup() + require.NoError(t, err, "Could not initialize in memory database") +} + +func teardown(t *testing.T, s *testServer) { + s.stop() + + err := db.Close() + require.NoError(t, err, "Could not close database") + + err = os.RemoveAll(path.Join(config.C.OpengistHome, "tests")) + require.NoError(t, err, "Could not remove repos directory") +} diff --git a/internal/web/util.go b/internal/web/util.go index 8f6fbd2..dadc7dd 100644 --- a/internal/web/util.go +++ b/internal/web/util.go @@ -11,7 +11,8 @@ import ( "github.com/gorilla/sessions" "github.com/labstack/echo/v4" "github.com/thomiceli/opengist/internal/config" - "github.com/thomiceli/opengist/internal/models" + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/i18n" "golang.org/x/crypto/argon2" "html/template" "net/http" @@ -60,10 +61,10 @@ func errorRes(code int, message string, err error) error { return &echo.HTTPError{Code: code, Message: message, Internal: err} } -func getUserLogged(ctx echo.Context) *models.User { +func getUserLogged(ctx echo.Context) *db.User { user := getData(ctx, "userLogged") if user != nil { - return user.(*models.User) + return user.(*db.User) } return nil } @@ -110,7 +111,7 @@ func deleteCsrfCookie(ctx echo.Context) { } func loadSettings(ctx echo.Context) error { - settings, err := models.GetSettings() + settings, err := db.GetSettings() if err != nil { return err } @@ -167,7 +168,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool { name := fl.Field().String() restrictedNames := map[string]struct{}{} - for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search"} { + for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init"} { restrictedNames[restrictedName] = struct{}{} } @@ -212,11 +213,11 @@ func paginate[T any](ctx echo.Context, data []*T, pageInt int, perPage int, temp switch labels { case 1: - setData(ctx, "prevLabel", "Previous") - setData(ctx, "nextLabel", "Next") + setData(ctx, "prevLabel", tr(ctx, "pagination.previous")) + setData(ctx, "nextLabel", tr(ctx, "pagination.next")) case 2: - setData(ctx, "prevLabel", "Newer") - setData(ctx, "nextLabel", "Older") + setData(ctx, "prevLabel", tr(ctx, "pagination.newer")) + setData(ctx, "nextLabel", tr(ctx, "pagination.older")) } setData(ctx, "urlPage", urlPage) @@ -224,6 +225,11 @@ func paginate[T any](ctx echo.Context, data []*T, pageInt int, perPage int, temp return nil } +func tr(ctx echo.Context, key string) template.HTML { + l := getData(ctx, "locale").(*i18n.Locale) + return l.Tr(key) +} + type Argon2ID struct { format string version int diff --git a/opengist.go b/opengist.go index 9bd0a9d..eb2a5a1 100644 --- a/opengist.go +++ b/opengist.go @@ -5,8 +5,9 @@ import ( "fmt" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" - "github.com/thomiceli/opengist/internal/models" + "github.com/thomiceli/opengist/internal/memdb" "github.com/thomiceli/opengist/internal/ssh" "github.com/thomiceli/opengist/internal/web" "os" @@ -51,17 +52,19 @@ func initialize() { } log.Info().Msg("Database file: " + filepath.Join(homePath, config.C.DBFilename)) - if err := models.Setup(filepath.Join(homePath, config.C.DBFilename)); err != nil { + if err := db.Setup(filepath.Join(homePath, config.C.DBFilename), false); err != nil { log.Fatal().Err(err).Msg("Failed to initialize database") } - web.EmbedFS = dirFS + if err := memdb.Setup(); err != nil { + log.Fatal().Err(err).Msg("Failed to initialize in memory database") + } } func main() { initialize() - go web.Start() + go web.NewServer(os.Getenv("OG_DEV") == "1").Start() go ssh.Start() select {} diff --git a/package-lock.json b/package-lock.json index 4b9764b..b389b8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,10 @@ "autoprefixer": "^10.4.14", "codemirror": "^6.0.1", "cssnano": "^5.1.15", + "dayjs": "^1.11.9", "github-markdown-css": "^5.2.0", "highlight.js": "^11.7.0", "markdown-it": "^13.0.1", - "moment": "^2.29.3", "nodemon": "^2.0.22", "postcss": "^8.4.13", "postcss-cssnext": "^3.1.1", @@ -1546,6 +1546,12 @@ "node": ">=8.0.0" } }, + "node_modules/dayjs": { + "version": "1.11.9", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", + "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==", + "dev": true + }, "node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -2380,15 +2386,6 @@ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, - "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6114,6 +6111,12 @@ "css-tree": "^1.1.2" } }, + "dayjs": { + "version": "1.11.9", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", + "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==", + "dev": true + }, "debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -6781,12 +6784,6 @@ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, - "moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", - "dev": true - }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 0ed1af7..b4fa934 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,10 @@ "autoprefixer": "^10.4.14", "codemirror": "^6.0.1", "cssnano": "^5.1.15", + "dayjs": "^1.11.9", "github-markdown-css": "^5.2.0", "highlight.js": "^11.7.0", "markdown-it": "^13.0.1", - "moment": "^2.29.3", "nodemon": "^2.0.22", "postcss": "^8.4.13", "postcss-cssnext": "^3.1.1", diff --git a/public/admin.ts b/public/admin.ts index cb7a69e..a044a8d 100644 --- a/public/admin.ts +++ b/public/admin.ts @@ -11,7 +11,9 @@ const setSetting = (key: string, value: string) => { const data = new URLSearchParams(); data.append('key', key); data.append('value', value); - data.append('_csrf', ((document.getElementsByName('_csrf')[0] as HTMLInputElement).value)); + if (document.getElementsByName('_csrf').length !== 0) { + data.append('_csrf', ((document.getElementsByName('_csrf')[0] as HTMLInputElement).value)); + } return fetch('/admin-panel/set-config', { method: 'PUT', credentials: 'same-origin', diff --git a/public/favicon-32.png b/public/favicon-32.png new file mode 100644 index 0000000..2d06943 Binary files /dev/null and b/public/favicon-32.png differ diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index 26a05c2..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/public/fs_embed.go b/public/fs_embed.go new file mode 100644 index 0000000..330456d --- /dev/null +++ b/public/fs_embed.go @@ -0,0 +1,8 @@ +//go:build fs_embed + +package public + +import "embed" + +//go:embed manifest.json assets/*.js assets/*.css assets/*.svg assets/*.png +var Files embed.FS diff --git a/public/fs_os.go b/public/fs_os.go new file mode 100644 index 0000000..e5ca954 --- /dev/null +++ b/public/fs_os.go @@ -0,0 +1,7 @@ +//go:build !fs_embed + +package public + +import "os" + +var Files = os.DirFS(".") diff --git a/public/hljs.ts b/public/hljs.ts new file mode 100644 index 0000000..1edfb93 --- /dev/null +++ b/public/hljs.ts @@ -0,0 +1,50 @@ +import hljs from 'highlight.js'; +import md from 'markdown-it'; + +document.querySelectorAll('.markdown').forEach((e: HTMLElement) => { + e.innerHTML = md({ + html: true, + highlight: function (str, lang) { + if (lang && hljs.getLanguage(lang)) { + try { + return '
' +
+                        hljs.highlight(str, {language: lang, ignoreIllegals: true}).value +
+                        '
'; + } catch (__) { + } + } + + return '
' + md().utils.escapeHtml(str) + '
'; + } + }).render(e.textContent); +}); + +document.querySelectorAll('.table-code').forEach((el) => { + const ext = el.dataset.filename?.split('.').pop() || ''; + + if (hljs.autoDetection(ext) && ext !== 'txt') { + el.querySelectorAll('td.line-code').forEach((ell) => { + ell.classList.add('language-' + ext); + hljs.highlightElement(ell); + }); + } + + el.addEventListener('click', event => { + if (event.target && (event.target as HTMLElement).matches('.line-num')) { + Array.from(document.querySelectorAll('.table-code .selected')).forEach((el) => el.classList.remove('selected')); + + const nextSibling = (event.target as HTMLElement).nextSibling; + if (nextSibling instanceof HTMLElement) { + nextSibling.classList.add('selected'); + } + + + const filename = el.dataset.filenameSlug; + const line = (event.target as HTMLElement).textContent; + const url = location.protocol + '//' + location.host + location.pathname; + const hash = '#file-' + filename + '-' + line; + window.history.pushState(null, null, url + hash); + location.hash = hash; + } + }); +}); diff --git a/public/main.ts b/public/main.ts index 8992039..3360680 100644 --- a/public/main.ts +++ b/public/main.ts @@ -1,11 +1,14 @@ import './style.css'; -import './hljs.scss'; -import './favicon.svg'; +import './style.scss'; +import './favicon-32.png'; +import './opengist.svg'; import './default.png'; -import moment from 'moment'; -import md from 'markdown-it'; -import hljs from 'highlight.js'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +dayjs.extend(relativeTime); +dayjs.extend(localizedFormat); document.addEventListener('DOMContentLoaded', () => { const themeMenu = document.getElementById('theme-menu')!; @@ -14,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => { e.stopPropagation() localStorage.theme = 'light'; themeMenu.classList.toggle('hidden'); + // @ts-ignore checkTheme() } @@ -21,6 +25,7 @@ document.addEventListener('DOMContentLoaded', () => { e.stopPropagation() localStorage.theme = 'dark'; themeMenu.classList.toggle('hidden'); + // @ts-ignore checkTheme() } @@ -28,24 +33,25 @@ document.addEventListener('DOMContentLoaded', () => { e.stopPropagation() localStorage.removeItem('theme'); themeMenu.classList.toggle('hidden'); + // @ts-ignore checkTheme(); } - document.getElementById('theme-btn')!.onclick = (e) => { + document.getElementById('theme-btn')!.onclick = () => { themeMenu.classList.toggle('hidden'); } - document.getElementById('user-btn')?.addEventListener("click" , (e) => { + document.getElementById('user-btn')?.addEventListener("click" , () => { document.getElementById('user-menu').classList.toggle('hidden'); }) document.querySelectorAll('.moment-timestamp').forEach((e: HTMLElement) => { - e.title = moment.unix(parseInt(e.innerHTML)).format('LLLL'); - e.innerHTML = moment.unix(parseInt(e.innerHTML)).fromNow(); + e.title = dayjs.unix(parseInt(e.innerHTML)).format('LLLL'); + e.innerHTML = dayjs.unix(parseInt(e.innerHTML)).fromNow(); }); document.querySelectorAll('.moment-timestamp-date').forEach((e: HTMLElement) => { - e.innerHTML = moment.unix(parseInt(e.innerHTML)).format('DD/MM/YYYY HH:mm'); + e.innerHTML = dayjs.unix(parseInt(e.innerHTML)).format('DD/MM/YYYY HH:mm'); }); const rev = document.querySelector('.revision-text'); @@ -62,53 +68,6 @@ document.addEventListener('DOMContentLoaded', () => { }; } - document.querySelectorAll('.markdown').forEach((e: HTMLElement) => { - e.innerHTML = md({ - html: true, - highlight: function (str, lang) { - if (lang && hljs.getLanguage(lang)) { - try { - return '
' +
-                            hljs.highlight(str, {language: lang, ignoreIllegals: true}).value +
-                            '
'; - } catch (__) { - } - } - - return '
' + md().utils.escapeHtml(str) + '
'; - } - }).render(e.textContent); - }); - - document.querySelectorAll('.table-code').forEach((el) => { - const ext = el.dataset.filename?.split('.').pop() || ''; - - if (hljs.autoDetection(ext) && ext !== 'txt') { - el.querySelectorAll('td.line-code').forEach((ell) => { - ell.classList.add('language-' + ext); - hljs.highlightElement(ell); - }); - } - - el.addEventListener('click', event => { - if (event.target && (event.target as HTMLElement).matches('.line-num')) { - Array.from(document.querySelectorAll('.table-code .selected')).forEach((el) => el.classList.remove('selected')); - - const nextSibling = (event.target as HTMLElement).nextSibling; - if (nextSibling instanceof HTMLElement) { - nextSibling.classList.add('selected'); - } - - - const filename = el.dataset.filenameSlug; - const line = (event.target as HTMLElement).textContent; - const url = location.protocol + '//' + location.host + location.pathname; - const hash = '#file-' + filename + '-' + line; - window.history.pushState(null, null, url + hash); - location.hash = hash; - } - }); - }); const colorhash = () => { Array.from(document.querySelectorAll('.table-code .selected')).forEach((el) => el.classList.remove('selected')); @@ -176,11 +135,31 @@ document.addEventListener('DOMContentLoaded', () => { }; } + document.getElementById('language-btn')!.onclick = () => { + document.getElementById('language-list')!.classList.toggle('hidden'); + }; + + document.querySelectorAll('.copy-gist-btn').forEach((e: HTMLElement) => { e.onclick = () => { - navigator.clipboard.writeText(e.parentNode!.querySelector('.gist-content')!.textContent || '').catch((err) => { + navigator.clipboard.writeText(e.parentNode!.parentNode!.querySelector('.gist-content')!.textContent || '').catch((err) => { console.error('Could not copy text: ', err); }); }; }); + + const gistmenuvisibility = document.getElementById('gist-menu-visibility'); + if (gistmenuvisibility) { + let submitgistbutton = (document.getElementById('submit-gist') as HTMLInputElement); + document.getElementById('gist-visibility-menu-button')!.onclick = () => { + gistmenuvisibility!.classList.toggle('hidden'); + } + Array.from(document.querySelectorAll('.gist-visibility-option')).forEach((el) => { + (el as HTMLElement).onclick = () => { + submitgistbutton.textContent = (el as HTMLElement).dataset.btntext; + submitgistbutton!.value = (el as HTMLElement).dataset.visibility || '0'; + gistmenuvisibility!.classList.add('hidden'); + } + }); + } }); diff --git a/public/opengist.svg b/public/opengist.svg new file mode 100644 index 0000000..90e0781 --- /dev/null +++ b/public/opengist.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/public/hljs.scss b/public/style.scss similarity index 100% rename from public/hljs.scss rename to public/style.scss diff --git a/scripts/build-all.sh b/scripts/build-all.sh new file mode 100755 index 0000000..fe56710 --- /dev/null +++ b/scripts/build-all.sh @@ -0,0 +1,64 @@ +#!/bin/sh + +CHECKSUMS_FILE="build/checksums.txt" +BINARY_NAME="opengist" +TARGETS="darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 linux/armv6 linux/armv7 linux/386 windows/amd64" +VERSION=$(git describe --tags | sed 's/^v//') + +if [ -z "$VERSION" ]; then + echo "Error: Could not retrieve version from git tags. Exiting..." + exit 1 +fi + +for TARGET in $TARGETS; do + GOOS=${TARGET%/*} + GOARCH=${TARGET#*/} + + case $GOOS-$GOARCH in + linux-armv6) + GOARCH="arm" + GOARM=6 + ;; + linux-armv7) + GOARCH="arm" + GOARM=7 + ;; + *) + unset GOARM + ;; + esac + + OUTPUT_PARENT_DIR="build/$GOOS-$GOARCH${GOARM:+v$GOARM}-$VERSION" + OUTPUT_DIR="$OUTPUT_PARENT_DIR/$BINARY_NAME" + OUTPUT_FILE="$OUTPUT_DIR/$BINARY_NAME" + + if [ "$GOOS" = "windows" ]; then + OUTPUT_FILE="$OUTPUT_FILE.exe" + fi + + echo "Building version $VERSION for $GOOS/$GOARCH${GOARM:+v$GOARM}..." + mkdir -p $OUTPUT_DIR + env GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM CGO_ENABLED=0 go build -tags fs_embed -o $OUTPUT_FILE + cp README.md $OUTPUT_DIR + cp LICENSE $OUTPUT_DIR + cp config.yml $OUTPUT_DIR + + if [ $? -ne 0 ]; then + echo "Error building for $GOOS/$GOARCH${GOARM:+v$GOARM}. Exiting..." + exit 1 + fi + + # Archive the binary with README and LICENSE + echo "Archiving for $GOOS/$GOARCH${GOARM:+v$GOARM}..." + if [ "$GOOS" = "windows" ]; then + # ZIP for Windows + cd $OUTPUT_PARENT_DIR && zip -r "../$BINARY_NAME$VERSION-$GOOS-$GOARCH${GOARM:+v$GOARM}.zip" "$BINARY_NAME/" && cd - > /dev/null + sha256sum "build/$BINARY_NAME$VERSION-$GOOS-$GOARCH${GOARM:+v$GOARM}.zip" | awk '{print $1 " " substr($2,7)}' >> $CHECKSUMS_FILE + else + # tar.gz for other platforms + tar -czf "build/$BINARY_NAME$VERSION-$GOOS-$GOARCH${GOARM:+v$GOARM}.tar.gz" -C $OUTPUT_PARENT_DIR "$BINARY_NAME" + sha256sum "build/$BINARY_NAME$VERSION-$GOOS-$GOARCH${GOARM:+v$GOARM}.tar.gz" | awk '{print $1 " " substr($2,7)}' >> $CHECKSUMS_FILE + fi +done + +echo "Build and archiving complete." diff --git a/watch.sh b/scripts/watch.sh old mode 100644 new mode 100755 similarity index 100% rename from watch.sh rename to scripts/watch.sh diff --git a/tailwind.config.js b/tailwind.config.js index c1e2b6b..a387490 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -23,7 +23,20 @@ module.exports = { 900: "#131316" }, rose: colors.rose, - primary: colors.sky, + primary: { + 50: '#d6e1ff', + 100: '#d1dfff', + 200: '#b9d2fe', + 300: '#84b1fb', + 400: '#74a4f6', + 500: '#588fee', + 600: '#3c79e2', + 700: '#356fc0', + 800: '#2d6195', + 900: '#2a5574', + 950: '#173040', + }, + slate: colors.slate }, extend: { diff --git a/templates/base/admin_header.html b/templates/base/admin_header.html index 1613c57..2a9f0e8 100644 --- a/templates/base/admin_header.html +++ b/templates/base/admin_header.html @@ -2,7 +2,7 @@
-

Admin panel

+

{{ .locale.Tr "admin.admin_panel" }}

@@ -10,13 +10,13 @@
diff --git a/templates/base/base_footer.html b/templates/base/base_footer.html index b393c16..c7c9816 100644 --- a/templates/base/base_footer.html +++ b/templates/base/base_footer.html @@ -4,17 +4,34 @@ {{ end }} {{ define "footer" }} -

- - - Powered by Opengist - - ⋅ - Load: {{ loadedTime .loadStartTime }} +

+

+ + + {{ .locale.Tr "footer.powered-by" "Opengist" }} + + ⋅ + Load: {{ loadedTime .loadStartTime }}⋅ +

+
+ + + + {{ .localeName }} + -

+ +
+
+ diff --git a/templates/base/base_header.html b/templates/base/base_header.html index 415a0c3..9ed798a 100644 --- a/templates/base/base_header.html +++ b/templates/base/base_header.html @@ -25,10 +25,17 @@ ) - + - - + + {{ if dev }} + + + + {{ else }} + + + {{ end }} {{ if .htmlTitle }} {{ .htmlTitle }} - Opengist @@ -40,7 +47,7 @@
@@ -137,17 +136,17 @@
{{ if .revision }} {{ if ne .revision "HEAD" }} -

Revision {{ .revision }}

+

{{ .locale.Tr "gist.header.revision" }} {{ .revision }}

{{ end }} {{ end }}
diff --git a/templates/fs_embed.go b/templates/fs_embed.go new file mode 100644 index 0000000..b2f70ac --- /dev/null +++ b/templates/fs_embed.go @@ -0,0 +1,6 @@ +package templates + +import "embed" + +//go:embed */*.html +var Files embed.FS diff --git a/templates/pages/admin_config.html b/templates/pages/admin_config.html index d04432b..5ea3af6 100644 --- a/templates/pages/admin_config.html +++ b/templates/pages/admin_config.html @@ -3,7 +3,7 @@
-

This configuration can be overridden by a YAML config file and/or environment variables.

+

{{ .locale.Tr "admin.config-link" (join "" (toStr (.locale.Tr "admin.config-link-overriden")) "") }}

@@ -65,8 +65,8 @@
  • - Disable signup - Forbid the creation of new accounts. + {{ .locale.Tr "admin.disable-signup" }} + {{ .locale.Tr "admin.disable-signup_help" }}