Compare commits

...

27 Commits

Author SHA1 Message Date
Thomas Miceli
92bac3bf8c v1.8.1 2024-11-02 02:00:48 +01:00
Thomas Miceli
73c2fb55bc Fix confirm() popup messages (#370) 2024-11-02 01:40:10 +01:00
Thomas Miceli
75162b3ef9 Hide passkey login when login form is disabled (#369) 2024-11-02 01:06:14 +01:00
Thomas Miceli
d537153785 Fix Markdown preview (#368) 2024-11-02 01:05:43 +01:00
Thomas Miceli
97b9fa1100 Fix typos 2024-11-01 00:00:09 +01:00
Thomas Miceli
393c9756d4 v1.8.0 2024-10-31 20:48:54 +01:00
Aloys
63d4b46a41 Fix typos (#363) 2024-10-31 20:19:02 +01:00
Thomas Miceli
91c412d97e Translations update from Opengist (#339)
* Translated using Weblate (Turkish)

Currently translated at 100.0% (244 of 244 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 80.3% (196 of 244 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 80.3% (196 of 244 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 80.3% (196 of 244 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (262 of 262 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (262 of 262 strings)

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

---------

Co-authored-by: Taylan Tatlı <taylantatli90@gmail.com>
Co-authored-by: lkw123 <2020393267@qq.com>
Co-authored-by: Doracoin <doracoin@foxmail.com>
2024-10-31 18:34:04 +01:00
Thomas Miceli
7cc2b497ca Use mail handle if oauth nickname is empty (#362) 2024-10-31 18:24:15 +01:00
zdebel
d5e66d3994 Fix oauth endpoint to support detecting https in 'Forwarded' header, enabling google support (#359) 2024-10-31 15:03:35 +01:00
Thomas Miceli
4fd0832df9 Allow to define secret key & move the secret key file to parent directory (#358) 2024-10-31 14:50:13 +01:00
Thomas Miceli
20372f44e4 Change json response detection (#361) 2024-10-31 14:41:42 +01:00
Thomas Miceli
d0b4815798 Update Go deps and use Go 1.23 (#354) 2024-10-25 01:04:16 +02:00
Phani Rithvij
3cc3fb4572 package-lock.json add integrity, resolved fields (#350)
used https://github.com/jeslie0/npm-lockfile-fix

Signed-off-by: phanirithvij <phanirithvij2000@gmail.com>
2024-10-25 00:25:10 +02:00
Thomas Miceli
ca44abfc43 Fix build Postcss error (#353) 2024-10-24 23:37:04 +02:00
Thomas Miceli
2bf434f00e Add TOTP MFA (#342) 2024-10-24 23:23:00 +02:00
Thomas Miceli
df226cbd99 Add SVG parser (#346) 2024-10-14 21:20:56 +02:00
Thomas Miceli
3068588111 Send Markdown preview data as form params (#347) 2024-10-14 14:43:12 +02:00
Emmanuel Ferdman
12696d23b0 Update config file (#343)
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2024-10-13 23:47:06 +02:00
Patrick MARIE
798a0bfc28 Allow adding multiple empty lines in editor. (#345) 2024-10-13 23:45:50 +02:00
Thomas Miceli
6959929094 Add passkeys support + MFA (#341) 2024-10-07 23:56:32 +02:00
Thomas Miceli
41dc2e451b Use Docker secrets (#340) 2024-09-28 01:31:18 +02:00
Thomas Miceli
56b4fd45fd Add queriable shorter uuids (#338) 2024-09-23 18:14:56 +02:00
Thomas Miceli
605c8b892a Add/Remove admins (#337) 2024-09-23 16:55:57 +02:00
Thomas Miceli
fa8217e27f Separate OAuth unlink URL (#336) 2024-09-22 23:21:43 +02:00
Thomas Miceli
9ac7a76f4a Fix CI trigger 2024-09-22 23:21:30 +02:00
Thomas Miceli
17237713a1 Add Postgres and MySQL databases support (#335) 2024-09-20 16:01:09 +02:00
82 changed files with 4551 additions and 1183 deletions

View File

@@ -4,42 +4,46 @@ on:
branches: branches:
- master - master
- 'dev-*' - 'dev-*'
workflow_dispatch:
pull_request: pull_request:
paths-ignore: paths-ignore:
- '**.yml' - '**.yml'
- '**.md'
jobs: jobs:
lint: lint:
name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Go 1.22 - name: Set up Go 1.23
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: "1.22" go-version: "1.23"
- name: Lint - name: Lint
uses: golangci/golangci-lint-action@v3 uses: golangci/golangci-lint-action@v6
with: with:
version: v1.54 version: v1.60
skip-pkg-cache: true args: --out-format=colored-line-number --timeout=20m
args: --out-format=colored-line-number --timeout=20m
- name: Format - name: Format
run: make fmt check_changes run: make fmt check_changes
check: check:
name: Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Go 1.22 - name: Set up Go 1.23
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: "1.22" go-version: "1.23"
- name: Check Go modules - name: Check Go modules
run: make go_mod check_changes run: make go_mod check_changes
@@ -47,12 +51,58 @@ jobs:
- name: Check translations - name: Check translations
run: make check-tr run: make check-tr
test-db:
name: Test
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest"]
go: ["1.23"]
database: [postgres, mysql]
include:
- database: postgres
image: postgres:16
port: 5432:5432
- database: mysql
image: mysql:8
port: 3306:3306
runs-on: ${{ matrix.os }}
services:
database:
image: ${{ matrix.image }}
ports:
- ${{ matrix.port }}
env:
POSTGRES_PASSWORD: opengist
POSTGRES_DB: opengist_test
MYSQL_ROOT_PASSWORD: opengist
MYSQL_DATABASE: opengist_test
options: >-
--health-cmd ${{ matrix.database == 'postgres' && 'pg_isready' || '"mysqladmin ping"' }}
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go }}
- name: Run tests
run: make test TEST_DB_TYPE=${{ matrix.database }}
test: test:
name: Test
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: ["ubuntu-latest", "macOS-latest", "windows-latest"] os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
go: ["1.22"] go: ["1.23"]
database: ["sqlite"]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout - name: Checkout
@@ -64,5 +114,25 @@ jobs:
go-version: ${{ matrix.go }} go-version: ${{ matrix.go }}
- name: Run tests - name: Run tests
run: make test run: make test TEST_DB_TYPE=${{ matrix.database }}
build:
name: Build
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
go: ["1.23"]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go 1.23
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go }}
- name: Build
shell: bash
run: make

View File

@@ -13,10 +13,10 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Go 1.22 - name: Set up Go 1.23
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: "1.22" go-version: "1.23"
- name: Cross compile build - name: Cross compile build
run: make all_crosscompile run: make all_crosscompile

View File

@@ -1,5 +1,47 @@
# Changelog # Changelog
## [1.8.1](https://github.com/thomiceli/opengist/compare/v1.8.0...v1.8.1) - 2024-11-02
See here how to [update](/docs/update.md) Opengist.
### Changed
- Hide passkey login when login form is disabled (#369)
### Fixed
- Markdown preview (#368)
- confirm() popup messages (#370)
## [1.8.0](https://github.com/thomiceli/opengist/compare/v1.7.5...v1.8.0) - 2024-10-31
See here how to [update](https://opengist.io/docs/update) Opengist.
### 🔴 Deprecations
_Removed in the next SemVer MAJOR version of Opengist._
* Use the configuration option `db-uri`/`OG_DB_URI` **instead of** `db-filename`/`OG_DB_FILENAME`.\
More info [here](https://opengist.io/docs/configuration/databases/sqlite) if you plan to keep SQLite as a DBMS for Opengist.
### Added
- Postgres and MySQL databases support (#335)
- Passkeys & TOTP support + MFA (#341) (#342)
- Add/Remove admins (#337)
- Queriable shorter uuids (#338)
- Use Docker secrets (#340)
- SVG preview in Markdown (#346)
- Secret key definition & move the secret key file to its parent directory (#358)
- More translation strings (#339)
### Changed
- Separate OAuth unlink URL (#336)
### Fixed
- Adding multiple empty lines in editor. (#345)
- Config URL (#343)
- Send Markdown preview data as form params (#347)
- Fix oauth endpoint to support detecting https in 'Forwarded' header, enabling google support (#359)
- Use mail handle if OAuth nickname is empty (#362)
### Other
- Use go 1.23 and update deps (#354)
- Typos in README (#363)
## [1.7.5](https://github.com/thomiceli/opengist/compare/v1.7.4...v1.7.5) - 2024-09-12 ## [1.7.5](https://github.com/thomiceli/opengist/compare/v1.7.4...v1.7.5) - 2024-09-12
See here how to [update](/docs/update.md) Opengist. See here how to [update](/docs/update.md) Opengist.

View File

@@ -15,7 +15,7 @@ RUN apk update && \
musl-dev \ musl-dev \
libstdc++ libstdc++
COPY --from=golang:1.22-alpine /usr/local/go/ /usr/local/go/ COPY --from=golang:1.23-alpine /usr/local/go/ /usr/local/go/
ENV PATH="/usr/local/go/bin:${PATH}" ENV PATH="/usr/local/go/bin:${PATH}"
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0

View File

@@ -4,6 +4,7 @@
BINARY_NAME := opengist BINARY_NAME := opengist
GIT_TAG := $(shell git describe --tags) GIT_TAG := $(shell git describe --tags)
VERSION_PKG := github.com/thomiceli/opengist/internal/config.OpengistVersion VERSION_PKG := github.com/thomiceli/opengist/internal/config.OpengistVersion
TEST_DB_TYPE ?= sqlite
all: clean install build all: clean install build
@@ -72,7 +73,7 @@ fmt:
@go fmt ./... @go fmt ./...
test: test:
@go test ./... -p 1 @OPENGIST_TEST_DB=$(TEST_DB_TYPE) go test ./... -p 1
check-tr: check-tr:
@bash ./scripts/check-translations.sh @bash ./scripts/check-translations.sh

View File

@@ -2,9 +2,9 @@
<img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg" alt="Opengist" align="right" /> <img height="108px" src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg" alt="Opengist" align="right" />
Opengist is a **self-hosted** pastebin **powered by Git**. All snippets are stored in a Git repository and can be 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. 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. It is similar to [GitHub Gist](https://gist.github.com/), but open-source and could be self-hosted.
[Home Page](https://opengist.io) • [Documentation](https://opengist.io/docs) • [Discord](https://discord.gg/9Pm3X5scZT) • [Demo](https://demo.opengist.io) [Home Page](https://opengist.io) • [Documentation](https://opengist.io/docs) • [Discord](https://discord.gg/9Pm3X5scZT) • [Demo](https://demo.opengist.io)
@@ -13,14 +13,14 @@ It is similiar to [GitHub Gist](https://gist.github.com/), but open-source and c
![License](https://img.shields.io/github/license/thomiceli/opengist?color=blue) ![License](https://img.shields.io/github/license/thomiceli/opengist?color=blue)
[![Go CI](https://github.com/thomiceli/opengist/actions/workflows/go.yml/badge.svg)](https://github.com/thomiceli/opengist/actions/workflows/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) [![Go Report Card](https://goreportcard.com/badge/github.com/thomiceli/opengist)](https://goreportcard.com/report/github.com/thomiceli/opengist)
[![Translate](https://tr.opengist.io/widget/_/svg-badge.svg)](https://tr.opengist.io/projects/_/opengist/)
## Features ## Features
* Create public, unlisted or private snippets * Create public, unlisted or private snippets
* [Init](/docs/usage/init-via-git.md) / Clone / Pull / Push snippets **via Git** over HTTP or SSH * [Init](/docs/usage/init-via-git.md) / Clone / Pull / Push snippets **via Git** over HTTP or SSH
* Syntax highlighting ; markdown & CSV support * Syntax highlighting ; markdown & CSV support
* Search code in snippets ; browse users snippets, likes and forks * Search code in snippets; browse users snippets, likes and forks
* Embed snippets in other websites * Embed snippets in other websites
* Revisions history * Revisions history
* Like / Fork snippets * Like / Fork snippets
@@ -28,7 +28,7 @@ It is similiar to [GitHub Gist](https://gist.github.com/), but open-source and c
* OAuth2 login with GitHub, GitLab, Gitea, and OpenID Connect * OAuth2 login with GitHub, GitLab, Gitea, and OpenID Connect
* Restrict or unrestrict snippets visibility to anonymous users * Restrict or unrestrict snippets visibility to anonymous users
* Docker support * Docker support
* [More...](/docs/index.md#features) * [More...](/docs/introduction.md#features)
## Quick start ## Quick start
@@ -37,7 +37,7 @@ It is similiar to [GitHub Gist](https://gist.github.com/), but open-source and c
Docker [images](https://github.com/thomiceli/opengist/pkgs/container/opengist) are available for each release : Docker [images](https://github.com/thomiceli/opengist/pkgs/container/opengist) are available for each release :
```shell ```shell
docker pull ghcr.io/thomiceli/opengist:1.7 docker pull ghcr.io/thomiceli/opengist:1.8
``` ```
It can be used in a `docker-compose.yml` file : It can be used in a `docker-compose.yml` file :
@@ -47,11 +47,9 @@ It can be used in a `docker-compose.yml` file :
3. Opengist is now running on port 6157, you can browse http://localhost:6157 3. Opengist is now running on port 6157, you can browse http://localhost:6157
```yml ```yml
version: "3"
services: services:
opengist: opengist:
image: ghcr.io/thomiceli/opengist:1.7 image: ghcr.io/thomiceli/opengist:1.8
container_name: opengist container_name: opengist
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -78,9 +76,9 @@ Download the archive for your system from the release page [here](https://github
```shell ```shell
# example for linux amd64 # example for linux amd64
wget https://github.com/thomiceli/opengist/releases/download/v1.7.5/opengist1.7.5-linux-amd64.tar.gz wget https://github.com/thomiceli/opengist/releases/download/v1.8.1/opengist1.8.1-linux-amd64.tar.gz
tar xzvf opengist1.7.5-linux-amd64.tar.gz tar xzvf opengist1.8.1-linux-amd64.tar.gz
cd opengist cd opengist
chmod +x opengist chmod +x opengist
./opengist # with or without `--config config.yml` ./opengist # with or without `--config config.yml`
@@ -90,7 +88,7 @@ Opengist is now running on port 6157, you can browse http://localhost:6157
### From source ### From source
Requirements: [Git](https://git-scm.com/downloads) (2.28+), [Go](https://go.dev/doc/install) (1.22+), [Node.js](https://nodejs.org/en/download/) (16+), [Make](https://linux.die.net/man/1/make) (optional, but easier) Requirements: [Git](https://git-scm.com/downloads) (2.28+), [Go](https://go.dev/doc/install) (1.23+), [Node.js](https://nodejs.org/en/download/) (16+), [Make](https://linux.die.net/man/1/make) (optional, but easier)
```shell ```shell
git clone https://github.com/thomiceli/opengist git clone https://github.com/thomiceli/opengist

View File

@@ -1,5 +1,5 @@
# Learn more about Opengist configuration here: # 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/configure.md
# https://github.com/thomiceli/opengist/blob/master/docs/configuration/cheat-sheet.md # https://github.com/thomiceli/opengist/blob/master/docs/configuration/cheat-sheet.md
# Set the log level to one of the following: debug, info, warn, error, fatal. Default: warn # Set the log level to one of the following: debug, info, warn, error, fatal. Default: warn
@@ -14,8 +14,14 @@ external-url:
# Directory where Opengist will store its data. Default: ~/.opengist/ # Directory where Opengist will store its data. Default: ~/.opengist/
opengist-home: opengist-home:
# Name of the SQLite database file. Default: opengist.db # Secret key used for session store & encrypt MFA data on database. Default: <randomized 32 bytes>
db-filename: opengist.db secret-key:
# URI of the database. Default: opengist.db (SQLite)
# SQLite: file name
# PostgreSQL: postgres://user:password@host:port/database
# MySQL/MariaDB: mysql://user:password@host:port/database
db-uri: opengist.db
# Enable or disable the code search index (either `true` or `false`). Default: true # Enable or disable the code search index (either `true` or `false`). Default: true
index.enabled: true index.enabled: true
@@ -29,6 +35,7 @@ git.default-branch:
# Set the journal mode for SQLite. Default: WAL # Set the journal mode for SQLite. Default: WAL
# See https://www.sqlite.org/pragma.html#pragma_journal_mode # See https://www.sqlite.org/pragma.html#pragma_journal_mode
# For SQLite databases only.
sqlite.journal-mode: WAL sqlite.journal-mode: WAL

View File

@@ -28,11 +28,11 @@ namespace: opengist
resources: resources:
- namespace.yaml - namespace.yaml
- https://github.com/thomiceli/opengist/deploy/?ref:v1.7.5 - https://github.com/thomiceli/opengist/deploy/?ref:v1.8.1
images: images:
- name: ghcr.io/thomiceli/opengist - name: ghcr.io/thomiceli/opengist
newTag: 1.7.5 newTag: 1.8.1
patches: patches:
# Add your ingress # Add your ingress

View File

@@ -9,4 +9,10 @@ usermod -o -u "$UID" $USER
chown -R "$USER:$USER" /opengist chown -R "$USER:$USER" /opengist
chown -R "$USER:$USER" /config.yml chown -R "$USER:$USER" /config.yml
if [ -f "/run/secrets/opengist_secrets" ]; then
set -a
. /run/secrets/opengist_secrets
set +a
fi
exec su $USER -c "OG_OPENGIST_HOME=/opengist /app/opengist/opengist --config /config.yml" exec su $USER -c "OG_OPENGIST_HOME=/opengist /app/opengist/opengist --config /config.yml"

View File

@@ -36,11 +36,17 @@ export default defineConfig({
{ {
text: 'Configuration', base: '/docs/configuration', items: [ text: 'Configuration', base: '/docs/configuration', items: [
{text: 'Configure Opengist', link: '/configure'}, {text: 'Configure Opengist', link: '/configure'},
{text: 'Admin panel', link: '/admin-panel'}, {text: 'Databases', items: [
{text: 'SQLite', link: '/databases/sqlite'},
{text: 'PostgreSQL', link: '/databases/postgresql'},
{text: 'MySQL', link: '/databases/mysql'},
], collapsed: true
},
{text: 'OAuth Providers', link: '/oauth-providers'}, {text: 'OAuth Providers', link: '/oauth-providers'},
{text: 'Custom assets', link: '/custom-assets'}, {text: 'Custom assets', link: '/custom-assets'},
{text: 'Custom links', link: '/custom-links'}, {text: 'Custom links', link: '/custom-links'},
{text: 'Cheat Sheet', link: '/cheat-sheet'}, {text: 'Cheat Sheet', link: '/cheat-sheet'},
{text: 'Admin panel', link: '/admin-panel'},
], collapsed: false ], collapsed: false
}, },
{ {

View File

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

View File

@@ -0,0 +1,11 @@
# Manage admins
You can add and remove Opengist admins from the CLI.
```bash
./opengist admin toggle-admin <username>
```
```bash
$ ./opengist admin toggle-admin thomas
User thomas admin set to true
```

View File

@@ -10,7 +10,8 @@ aside: false
| log-output | OG_LOG_OUTPUT | `stdout,file` | Set the log output to one or more of the following: `stdout`, `file`. | | log-output | OG_LOG_OUTPUT | `stdout,file` | Set the log output to one or more of the following: `stdout`, `file`. |
| external-url | OG_EXTERNAL_URL | none | Public URL to access to Opengist. | | external-url | OG_EXTERNAL_URL | none | Public URL to access to Opengist. |
| opengist-home | OG_OPENGIST_HOME | home directory | Path to the directory where Opengist stores its data. | | 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. | | secret-key | OG_SECRET_KEY | randomized 32 bytes | Secret key used for session store & encrypt MFA data on database. |
| db-uri | OG_DB_URI | `opengist.db` | URI of the database. |
| index.enabled | OG_INDEX_ENABLED | `true` | Enable or disable the code search index (`true` or `false`) | | index.enabled | OG_INDEX_ENABLED | `true` | Enable or disable the code search index (`true` or `false`) |
| index.dirname | OG_INDEX_DIRNAME | `opengist.index` | Name of the directory where the code search index is stored. | | index.dirname | OG_INDEX_DIRNAME | `opengist.index` | Name of the directory where the code search index is stored. |
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) | | git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |

View File

@@ -46,3 +46,27 @@ Usage via command line :
```shell ```shell
OG_LOG_LEVEL=info ./opengist OG_LOG_LEVEL=info ./opengist
``` ```
### Using Docker Compose secrets
You can use Docker Compose secrets to not expose sensitive information in your compose file, using a `.env` file.
```dotenv
# file secrets.env
OG_GITLAB_CLIENT_KEY=your_gitlab_client_key
OG_GITLAB_SECRET=your_gitlab_secret_key
```
And then use it in your compose file :
```yml
services:
opengist:
# ...
secrets:
- opengist_secrets
secrets:
opengist_secrets:
file: ./secrets.env
```

View File

@@ -0,0 +1,47 @@
# Using MySQL/MariaDB
To use MySQL/MariaDB as the database backend, you need to set the database URI configuration to the connection string of your MySQL/MariaDB database with this format :
`mysql://<user>:<password>@<host>:<port>/<database>`
#### YAML
```yaml
# Example
db-uri: mysql://root:passwd@localhost:3306/opengist_db
```
#### Environment variable
```sh
# Example
OG_DB_URI=mysql://root:passwd@localhost:3306/opengist_db
```
### Docker Compose
```yml
services:
opengist:
image: ghcr.io/thomiceli/opengist:1
container_name: opengist
restart: unless-stopped
depends_on:
- mysql
ports:
- "6157:6157"
- "2222:2222"
volumes:
- "$HOME/.opengist:/opengist"
environment:
OG_DB_URI: mysql://opengist:secret@mysql:3306/opengist_db
# other configuration options
mysql:
image: mysql:8.4
restart: unless-stopped
volumes:
- "./opengist-database:/var/lib/mysql"
environment:
MYSQL_USER: opengist
MYSQL_PASSWORD: secret
MYSQL_DATABASE: opengist_db
MYSQL_ROOT_PASSWORD: rootsecret
```

View File

@@ -0,0 +1,46 @@
# Using PostgreSQL
To use PostgreSQL as the database backend, you need to set the database URI configuration to the connection string of your PostgreSQL database with this format :
`postgres://<user>:<password>@<host>:<port>/<database>`
#### YAML
```yaml
# Example
db-uri: postgres://postgres:passwd@localhost:5432/opengist_db
```
#### Environment variable
```sh
# Example
OG_DB_URI=postgres://postgres:passwd@localhost:5432/opengist_db
```
### Docker Compose
```yml
services:
opengist:
image: ghcr.io/thomiceli/opengist:1
container_name: opengist
restart: unless-stopped
depends_on:
- postgres
ports:
- "6157:6157"
- "2222:2222"
volumes:
- "$HOME/.opengist:/opengist"
environment:
OG_DB_URI: postgres://opengist:secret@postgres:5432/opengist_db
# other configuration options
postgres:
image: postgres:16.4
restart: unless-stopped
volumes:
- "./opengist-database:/var/lib/postgresql/data"
environment:
POSTGRES_USER: opengist
POSTGRES_PASSWORD: secret
POSTGRES_DB: opengist_db
```

View File

@@ -0,0 +1,41 @@
# Using SQLite
By default, Opengist uses SQLite as the database backend.
Because SQLite is a file-based database, there is not much configuration to tweak.
The configuration `db-uri`/`OG_DB_URI` refers to the path of the SQLite database file relative in the `$opengist-home/` directory (default `opengist.db`),
although it can be left untouched.
The SQLite journal mode is set to [`WAL` (Write-Ahead Logging)](https://www.sqlite.org/pragma.html#pragma_journal_mode) by default and can be changed.
#### YAML
```yaml
# default
db-uri: opengist.db
sqlite.journal-mode: WAL
```
#### Environment variable
```sh
# default
OG_DB_URI=opengist.db
OG_SQLITE_JOURNAL_MODE=WAL
```
### Docker Compose
```yml
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"
environment:
OG_SQLITE_JOURNAL_MODE: WAL
# other configuration options
```

View File

@@ -25,7 +25,7 @@ Opengist is now running on port 6157, you can browse http://localhost:6157
Requirements: Requirements:
* [Git](https://git-scm.com/downloads) (2.28+) * [Git](https://git-scm.com/downloads) (2.28+)
* [Go](https://go.dev/doc/install) (1.22+) * [Go](https://go.dev/doc/install) (1.23+)
* [Node.js](https://nodejs.org/en/download/) (16+) * [Node.js](https://nodejs.org/en/download/) (16+)
* [Make](https://linux.die.net/man/1/make) (optional, but easier) * [Make](https://linux.die.net/man/1/make) (optional, but easier)

View File

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

View File

@@ -13,8 +13,6 @@ It can be used in a `docker-compose.yml` file :
3. Opengist is now running on port 6157, you can browse http://localhost:6157 3. Opengist is now running on port 6157, you can browse http://localhost:6157
```yml ```yml
version: "3"
services: services:
opengist: opengist:
image: ghcr.io/thomiceli/opengist:1 image: ghcr.io/thomiceli/opengist:1

View File

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

View File

@@ -31,7 +31,7 @@ Written in [Go](https://go.dev), Opengist aims to be fast and easy to deploy.
* delete users/gists; * delete users/gists;
* clean database/filesystem by syncing gists * clean database/filesystem by syncing gists
* run `git gc` for all repositories * run `git gc` for all repositories
* SQLite database * SQLite/PostgreSQL/MySQL database
* Logging * Logging
* Docker support * Docker support

View File

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

77
go.mod
View File

@@ -1,44 +1,48 @@
module github.com/thomiceli/opengist module github.com/thomiceli/opengist
go 1.22 go 1.23
require ( require (
github.com/Kunde21/markdownfmt/v3 v3.1.0 github.com/Kunde21/markdownfmt/v3 v3.1.0
github.com/alecthomas/chroma/v2 v2.14.0 github.com/alecthomas/chroma/v2 v2.14.0
github.com/blevesearch/bleve/v2 v2.4.0 github.com/blevesearch/bleve/v2 v2.4.2
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.21.0 github.com/go-playground/validator/v10 v10.22.1
github.com/go-webauthn/webauthn v0.11.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/securecookie v1.1.2 github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.2.2 github.com/gorilla/sessions v1.4.0
github.com/hashicorp/go-memdb v1.3.4 github.com/hashicorp/go-memdb v1.3.4
github.com/labstack/echo/v4 v4.12.0 github.com/labstack/echo/v4 v4.12.0
github.com/markbates/goth v1.80.0 github.com/markbates/goth v1.80.0
github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.2 github.com/urfave/cli/v2 v2.27.5
github.com/yuin/goldmark v1.7.1 github.com/yuin/goldmark v1.7.8
github.com/yuin/goldmark-emoji v1.0.2 github.com/yuin/goldmark-emoji v1.0.4
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/mermaid v0.5.0 go.abhg.dev/goldmark/mermaid v0.5.0
golang.org/x/crypto v0.23.0 golang.org/x/crypto v0.28.0
golang.org/x/text v0.15.0 golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.10 gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/RoaringBitmap/roaring v1.9.4 // indirect github.com/RoaringBitmap/roaring v1.9.4 // indirect
github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/bits-and-blooms/bitset v1.14.3 // indirect
github.com/blevesearch/bleve_index_api v1.1.8 // indirect github.com/blevesearch/bleve_index_api v1.1.12 // indirect
github.com/blevesearch/geo v0.1.20 // indirect github.com/blevesearch/geo v0.1.20 // indirect
github.com/blevesearch/go-faiss v1.0.16 // indirect github.com/blevesearch/go-faiss v1.0.23 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.2.13 // indirect github.com/blevesearch/scorch_segment_api/v2 v2.2.16 // indirect
github.com/blevesearch/segment v0.9.1 // indirect github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
@@ -47,21 +51,32 @@ require (
github.com/blevesearch/zapx/v12 v12.3.10 // indirect github.com/blevesearch/zapx/v12 v12.3.10 // indirect
github.com/blevesearch/zapx/v13 v13.3.10 // indirect github.com/blevesearch/zapx/v13 v13.3.10 // indirect
github.com/blevesearch/zapx/v14 v14.3.10 // indirect github.com/blevesearch/zapx/v14 v14.3.10 // indirect
github.com/blevesearch/zapx/v15 v15.3.13 // indirect github.com/blevesearch/zapx/v15 v15.3.15 // indirect
github.com/blevesearch/zapx/v16 v16.1.0 // indirect github.com/blevesearch/zapx/v16 v16.1.7 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/boombuler/barcode v1.0.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-webauthn/x v0.1.15 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-tpm v0.9.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/mux v1.8.1 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
@@ -70,7 +85,8 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect github.com/mschoch/smat v0.2.0 // indirect
@@ -81,15 +97,18 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.etcd.io/bbolt v1.3.10 // indirect go.etcd.io/bbolt v1.3.11 // indirect
golang.org/x/net v0.25.0 // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/oauth2 v0.20.0 // indirect golang.org/x/net v0.30.0 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/sync v0.8.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect golang.org/x/sys v0.26.0 // indirect
modernc.org/libc v1.51.0 // indirect golang.org/x/time v0.7.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.30.0 // indirect modernc.org/sqlite v1.33.1 // indirect
) )

179
go.sum
View File

@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0= github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0=
github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc= github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc=
github.com/RoaringBitmap/roaring v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ= github.com/RoaringBitmap/roaring v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ=
@@ -11,24 +13,24 @@ github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= github.com/bits-and-blooms/bitset v1.14.3 h1:Gd2c8lSNf9pKXom5JtD7AaKO8o7fGQ2LtFj1436qilA=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.14.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.4.0 h1:2xyg+Wv60CFHYccXc+moGxbL+8QKT/dZK09AewHgKsg= github.com/blevesearch/bleve/v2 v2.4.2 h1:NooYP1mb3c0StkiY9/xviiq2LGSaE8BQBCc/pirMx0U=
github.com/blevesearch/bleve/v2 v2.4.0/go.mod h1:IhQHoFAbHgWKYavb9rQgQEJJVMuY99cKdQ0wPpst2aY= github.com/blevesearch/bleve/v2 v2.4.2/go.mod h1:ATNKj7Yl2oJv/lGuF4kx39bST2dveX6w0th2FFYLkc8=
github.com/blevesearch/bleve_index_api v1.1.8 h1:rJUccYfWqRY2/BGowlsv1lwrLKYK/zPE6hgNn1pTGdk= github.com/blevesearch/bleve_index_api v1.1.12 h1:P4bw9/G/5rulOF7SJ9l4FsDoo7UFJ+5kexNy1RXfegY=
github.com/blevesearch/bleve_index_api v1.1.8/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8= github.com/blevesearch/bleve_index_api v1.1.12/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM= github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w= github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
github.com/blevesearch/go-faiss v1.0.16 h1:lfzXzzjO1mAf15MRiRY5yz6KVGr02CyRrr7m0z70Ih8= github.com/blevesearch/go-faiss v1.0.23 h1:Wmc5AFwDLKGl2L6mjLX1Da3vCL0EKa2uHHSorcIS1Uc=
github.com/blevesearch/go-faiss v1.0.16/go.mod h1:jrxHrbl42X/RnDPI+wBoZU8joxxuRwedrxqswQ3xfU8= github.com/blevesearch/go-faiss v1.0.23/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.2.13 h1:UfbyRpIMdcaNsgciGYS9Pib7N3xd3EEw8KKbd/aDBlA= github.com/blevesearch/scorch_segment_api/v2 v2.2.16 h1:uGvKVvG7zvSxCwcm4/ehBa9cCEuZVE+/zvrSl57QUVY=
github.com/blevesearch/scorch_segment_api/v2 v2.2.13/go.mod h1:osG1bAUONZB2r/ozUJwjbuOzPvdrULWaLOm+vsMANsk= github.com/blevesearch/scorch_segment_api/v2 v2.2.16/go.mod h1:VF5oHVbIFTu+znY1v30GjSpT5+9YFs9dV2hjvuh34F0=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
@@ -45,10 +47,13 @@ github.com/blevesearch/zapx/v13 v13.3.10 h1:0KY9tuxg06rXxOZHg3DwPJBjniSlqEgVpxIq
github.com/blevesearch/zapx/v13 v13.3.10/go.mod h1:w2wjSDQ/WBVeEIvP0fvMJZAzDwqwIEzVPnCPrz93yAk= github.com/blevesearch/zapx/v13 v13.3.10/go.mod h1:w2wjSDQ/WBVeEIvP0fvMJZAzDwqwIEzVPnCPrz93yAk=
github.com/blevesearch/zapx/v14 v14.3.10 h1:SG6xlsL+W6YjhX5N3aEiL/2tcWh3DO75Bnz77pSwwKU= github.com/blevesearch/zapx/v14 v14.3.10 h1:SG6xlsL+W6YjhX5N3aEiL/2tcWh3DO75Bnz77pSwwKU=
github.com/blevesearch/zapx/v14 v14.3.10/go.mod h1:qqyuR0u230jN1yMmE4FIAuCxmahRQEOehF78m6oTgns= github.com/blevesearch/zapx/v14 v14.3.10/go.mod h1:qqyuR0u230jN1yMmE4FIAuCxmahRQEOehF78m6oTgns=
github.com/blevesearch/zapx/v15 v15.3.13 h1:6EkfaZiPlAxqXz0neniq35my6S48QI94W/wyhnpDHHQ= github.com/blevesearch/zapx/v15 v15.3.15 h1:JydcGIq279tmTrfBBSPDF/VOiCMBLQ+rJulTXrGFlGA=
github.com/blevesearch/zapx/v15 v15.3.13/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg= github.com/blevesearch/zapx/v15 v15.3.15/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg=
github.com/blevesearch/zapx/v16 v16.1.0 h1:bHsyowFqU0QA+uVDJCjifv9OvPGb8htkV52Yc/wT6xs= github.com/blevesearch/zapx/v16 v16.1.7 h1:I07qV6l1rPda19zyof9Q2J9E8cjZ57pQhNY0+ePI5vM=
github.com/blevesearch/zapx/v16 v16.1.0/go.mod h1:P0h9lKRyl4EKksAWfxwCQ5I5pLB9jH2XD8bhYHuIYuc= github.com/blevesearch/zapx/v16 v16.1.7/go.mod h1:JqQlOqlRVaYDkpLIl3JnKql8u4zKTNlVEa3nLsi0Gn8=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 h1:wMSvdj3BswqfQOXp2R1bJOAE7xIQLt2dlMQDMf836VY= github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 h1:wMSvdj3BswqfQOXp2R1bJOAE7xIQLt2dlMQDMf836VY=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA= github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
@@ -56,20 +61,22 @@ github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5D
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -80,8 +87,15 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.21.0 h1:4fZA11ovvtkdgaeev9RGWPgc1uj3H8W+rNYyH/ySBb0= github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.21.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.15 h1:eG1OhggBJTkDE8gUeOlGRbRe8E/PSVG26YG4AyFbwkU=
github.com/go-webauthn/x v0.1.15/go.mod h1:pf7VI23raFLHPO9VVIs9/u1etqwAOP0S2KoHGL6WbZ8=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
@@ -91,14 +105,18 @@ github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I= github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I=
github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U= github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -110,8 +128,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.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 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
@@ -125,6 +143,14 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 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/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -153,8 +179,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -167,6 +195,8 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -184,70 +214,79 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 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/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.4 h1:vCwMkPZSNefSUnOW2ZKRUjBSD5Ok3W78IXhGxxAEF90=
github.com/yuin/goldmark-emoji v1.0.4/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.abhg.dev/goldmark/mermaid v0.5.0 h1:mDkykpSPJ+5wCQ8bSXgzJ2KQskjXkI5Ndxz7JYDHW38= go.abhg.dev/goldmark/mermaid v0.5.0 h1:mDkykpSPJ+5wCQ8bSXgzJ2KQskjXkI5Ndxz7JYDHW38=
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs= go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs=
go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/ccgo/v4 v4.17.8/go.mod h1:buJnJ6Fn0tyAdP/dqePbrrvLyr6qslFfTbFrCuaYvtA= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.51.0 h1:kjSHjz1guHbI5iRdi6nEr/wIKSN6X4vzLd6TJMN+lHA= modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
modernc.org/libc v1.51.0/go.mod h1:15P6ublJ9FJR8YQCGy8DeQ2Uwur7iW9Hserr/T3OFZE= modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
@@ -256,8 +295,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.30.0 h1:8YhPUs/HTnlEgErn/jSYQTwHN/ex8CjHHjg+K9iG7LM= modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.30.0/go.mod h1:cgkTARJ9ugeXSNaLBPK3CqbOe7Ec7ZhWPoMFGldEYEw= modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -0,0 +1,61 @@
package totp
import (
"bytes"
"crypto/rand"
"encoding/base64"
"github.com/pquerna/otp/totp"
"html/template"
"image/png"
"strings"
)
const secretSize = 16
func GenerateQRCode(username, siteUrl string, secret []byte) (string, template.URL, error, []byte) {
var err error
if secret == nil {
secret, err = generateSecret()
if err != nil {
return "", "", err, nil
}
}
otpKey, err := totp.Generate(totp.GenerateOpts{
SecretSize: secretSize,
Issuer: "Opengist (" + strings.ReplaceAll(siteUrl, ":", "") + ")",
AccountName: username,
Secret: secret,
})
if err != nil {
return "", "", err, nil
}
qrcode, err := otpKey.Image(320, 240)
if err != nil {
return "", "", err, nil
}
var imgBytes bytes.Buffer
if err = png.Encode(&imgBytes, qrcode); err != nil {
return "", "", err, nil
}
qrcodeImage := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes()))
return otpKey.Secret(), qrcodeImage, nil, secret
}
func Validate(passcode, secret string) bool {
return totp.Validate(passcode, secret)
}
func generateSecret() ([]byte, error) {
secret := make([]byte, secretSize)
_, err := rand.Reader.Read(secret)
if err != nil {
return nil, err
}
return secret, nil
}

View File

@@ -0,0 +1,58 @@
package webauthn
import (
"encoding/binary"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/thomiceli/opengist/internal/db"
)
type user struct {
*db.User
}
func (u *user) WebAuthnID() []byte {
return uintToBytes(u.ID)
}
func (u *user) WebAuthnName() string {
return u.Username
}
func (u *user) WebAuthnDisplayName() string {
return u.Username
}
func (u *user) WebAuthnCredentials() []webauthn.Credential {
dbCreds, err := db.GetAllWACredentialsForUser(u.ID)
if err != nil {
return nil
}
return dbCreds
}
func (u *user) Exclusions() []protocol.CredentialDescriptor {
creds := u.WebAuthnCredentials()
exclusions := make([]protocol.CredentialDescriptor, len(creds))
for i, cred := range creds {
exclusions[i] = cred.Descriptor()
}
return exclusions
}
func discoverUser(rawID []byte, _ []byte) (webauthn.User, error) {
ogUser, err := db.GetUserByCredentialID(rawID)
if err != nil {
return nil, err
}
return &user{User: ogUser}, nil
}
func uintToBytes(n uint) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(n))
return b
}

View File

@@ -0,0 +1,138 @@
package webauthn
import (
"encoding/json"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"net/http"
"net/url"
)
var webAuthn *webauthn.WebAuthn
func Init(urlStr string) error {
var rpid, rporigin string
var err error
if urlStr == "" {
log.Info().Msg("External URL is not set, passkeys RP ID and Origins will be set to localhost")
rpid = "localhost"
rporigin = "http://localhost" + ":" + config.C.HttpPort
} else {
urlStruct, err := url.Parse(urlStr)
if err != nil {
return err
}
rpid = urlStruct.Hostname()
rporigin, err = protocol.FullyQualifiedOrigin(urlStr)
if err != nil {
log.Error().Err(err).Msg("Failed to get fully qualified origin from external URL")
}
}
webAuthn, err = webauthn.New(&webauthn.Config{
RPDisplayName: "Opengist",
RPID: rpid,
RPOrigins: []string{rporigin},
})
return err
}
func BeginBinding(dbUser *db.User) (credCreation *protocol.CredentialCreation, jsonSession []byte, err error) {
waUser := &user{User: dbUser}
credCreation, session, err := webAuthn.BeginRegistration(waUser, webauthn.WithAuthenticatorSelection(
protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
},
), webauthn.WithAppIdExcludeExtension("Opengist"), webauthn.WithExclusions(waUser.Exclusions()))
if err != nil {
return nil, nil, err
}
jsonSession, _ = json.Marshal(session)
return
}
func FinishBinding(dbUser *db.User, jsonSession []byte, response *http.Request) (*webauthn.Credential, error) {
waUser := &user{User: dbUser}
var session webauthn.SessionData
_ = json.Unmarshal(jsonSession, &session)
return webAuthn.FinishRegistration(waUser, session, response)
}
func BeginDiscoverableLogin() (credCreation *protocol.CredentialAssertion, jsonSession []byte, err error) {
credCreation, session, err := webAuthn.BeginDiscoverableLogin(
webauthn.WithUserVerification(protocol.VerificationPreferred),
)
jsonSession, _ = json.Marshal(session)
return
}
func FinishDiscoverableLogin(jsonSession []byte, response *http.Request) (uint, error) {
var session webauthn.SessionData
_ = json.Unmarshal(jsonSession, &session)
parsedResponse, err := protocol.ParseCredentialRequestResponse(response)
if err != nil {
return 0, err
}
waUser, cred, err := webAuthn.ValidatePasskeyLogin(discoverUser, session, parsedResponse)
if err != nil {
return 0, err
}
dbCredential, err := db.GetCredentialByID(cred.ID)
if err != nil {
return 0, err
}
if err = dbCredential.UpdateSignCount(); err != nil {
return 0, err
}
if err = dbCredential.UpdateLastUsedAt(); err != nil {
return 0, err
}
return waUser.(*user).User.ID, nil
}
func BeginLogin(dbUser *db.User) (credCreation *protocol.CredentialAssertion, jsonSession []byte, err error) {
waUser := &user{User: dbUser}
credCreation, session, err := webAuthn.BeginLogin(waUser)
jsonSession, _ = json.Marshal(session)
return
}
func FinishLogin(dbUser *db.User, jsonSession []byte, response *http.Request) error {
waUser := &user{User: dbUser}
var session webauthn.SessionData
_ = json.Unmarshal(jsonSession, &session)
cred, err := webAuthn.FinishLogin(waUser, session, response)
if err != nil {
return err
}
dbCredential, err := db.GetCredentialByID(cred.ID)
if err != nil {
return err
}
if err = dbCredential.UpdateSignCount(); err != nil {
return err
}
if err = dbCredential.UpdateLastUsedAt(); err != nil {
return err
}
return err
}

View File

@@ -12,6 +12,7 @@ var CmdAdmin = cli.Command{
Usage: "Admin commands", Usage: "Admin commands",
Subcommands: []*cli.Command{ Subcommands: []*cli.Command{
&CmdAdminResetPassword, &CmdAdminResetPassword,
&CmdAdminToggleAdmin,
}, },
} }
@@ -48,3 +49,30 @@ var CmdAdminResetPassword = cli.Command{
return nil return nil
}, },
} }
var CmdAdminToggleAdmin = cli.Command{
Name: "toggle-admin",
Usage: "Toggle the admin status for a given user",
ArgsUsage: "[username]",
Action: func(ctx *cli.Context) error {
initialize(ctx)
if ctx.NArg() < 1 {
return fmt.Errorf("username is required")
}
username := ctx.Args().Get(0)
user, err := db.GetUserByUsername(username)
if err != nil {
fmt.Printf("Cannot get user %s: %s\n", username, err)
return err
}
user.IsAdmin = !user.IsAdmin
if err = user.Update(); err != nil {
fmt.Printf("Cannot update user %s: %s\n", username, err)
}
fmt.Printf("User %s admin set to %t\n", username, user.IsAdmin)
return nil
},
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"io" "io"
"os" "os"
"path/filepath"
) )
var CmdHook = cli.Command{ var CmdHook = cli.Command{
@@ -50,7 +49,8 @@ func initialize(ctx *cli.Context) {
} }
config.InitLog() config.InitLog()
if err := db.Setup(filepath.Join(config.GetHomeDir(), config.C.DBFilename), false); err != nil { db.DeprecationDBFilename()
if err := db.Setup(config.C.DBUri, false); err != nil {
log.Fatal().Err(err).Msg("Failed to initialize database in hooks") log.Fatal().Err(err).Msg("Failed to initialize database in hooks")
} }
} }

View File

@@ -3,6 +3,7 @@ package cli
import ( import (
"fmt" "fmt"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/webauthn"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
@@ -36,7 +37,7 @@ var CmdStart = cli.Command{
Initialize(ctx) Initialize(ctx)
go web.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions")).Start() go web.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false).Start()
go ssh.Start() go ssh.Start()
<-stopCtx.Done() <-stopCtx.Done()
@@ -75,6 +76,8 @@ func Initialize(ctx *cli.Context) {
panic(err) panic(err)
} }
config.SetupSecretKey()
config.InitLog() config.InitLog()
gitVersion, err := git.GetGitVersion() gitVersion, err := git.GetGitVersion()
@@ -108,8 +111,9 @@ func Initialize(ctx *cli.Context) {
if err := os.MkdirAll(filepath.Join(homePath, "custom"), 0755); err != nil { if err := os.MkdirAll(filepath.Join(homePath, "custom"), 0755); err != nil {
log.Fatal().Err(err).Send() log.Fatal().Err(err).Send()
} }
log.Info().Msg("Database file: " + filepath.Join(homePath, config.C.DBFilename))
if err := db.Setup(filepath.Join(homePath, config.C.DBFilename), false); err != nil { db.DeprecationDBFilename()
if err := db.Setup(config.C.DBUri, false); err != nil {
log.Fatal().Err(err).Msg("Failed to initialize database") log.Fatal().Err(err).Msg("Failed to initialize database")
} }
@@ -117,6 +121,10 @@ func Initialize(ctx *cli.Context) {
log.Fatal().Err(err).Msg("Failed to initialize in memory database") log.Fatal().Err(err).Msg("Failed to initialize in memory database")
} }
if err := webauthn.Init(config.C.ExternalUrl); err != nil {
log.Error().Err(err).Msg("Failed to initialize WebAuthn")
}
if config.C.IndexEnabled { if config.C.IndexEnabled {
log.Info().Msg("Index directory: " + filepath.Join(homePath, config.C.IndexDirname)) log.Info().Msg("Index directory: " + filepath.Join(homePath, config.C.IndexDirname))
index.Init(filepath.Join(homePath, config.C.IndexDirname)) index.Init(filepath.Join(homePath, config.C.IndexDirname))

View File

@@ -22,14 +22,21 @@ var OpengistVersion = ""
var C *config var C *config
var SecretKey []byte
// Not using nested structs because the library // Not using nested structs because the library
// doesn't support dot notation in this case sadly // doesn't support dot notation in this case sadly
type config struct { type config struct {
SecretKey string `yaml:"secret-key" env:"OG_SECRET_KEY"`
LogLevel string `yaml:"log-level" env:"OG_LOG_LEVEL"` LogLevel string `yaml:"log-level" env:"OG_LOG_LEVEL"`
LogOutput string `yaml:"log-output" env:"OG_LOG_OUTPUT"` LogOutput string `yaml:"log-output" env:"OG_LOG_OUTPUT"`
ExternalUrl string `yaml:"external-url" env:"OG_EXTERNAL_URL"` ExternalUrl string `yaml:"external-url" env:"OG_EXTERNAL_URL"`
OpengistHome string `yaml:"opengist-home" env:"OG_OPENGIST_HOME"` OpengistHome string `yaml:"opengist-home" env:"OG_OPENGIST_HOME"`
DBFilename string `yaml:"db-filename" env:"OG_DB_FILENAME"`
DBUri string `yaml:"db-uri" env:"OG_DB_URI"`
DBFilename string `yaml:"db-filename" env:"OG_DB_FILENAME"` // deprecated
IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"` IndexEnabled bool `yaml:"index.enabled" env:"OG_INDEX_ENABLED"`
IndexDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"` IndexDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"`
@@ -77,10 +84,12 @@ type StaticLink struct {
func configWithDefaults() (*config, error) { func configWithDefaults() (*config, error) {
c := &config{} c := &config{}
c.SecretKey = ""
c.LogLevel = "warn" c.LogLevel = "warn"
c.LogOutput = "stdout,file" c.LogOutput = "stdout,file"
c.OpengistHome = "" c.OpengistHome = ""
c.DBFilename = "opengist.db" c.DBUri = "opengist.db"
c.IndexEnabled = true c.IndexEnabled = true
c.IndexDirname = "opengist.index" c.IndexDirname = "opengist.index"
@@ -133,6 +142,10 @@ func InitConfig(configPath string, out io.Writer) error {
C = c C = c
if err = migrateConfig(); err != nil {
return err
}
if err = os.Setenv("OG_OPENGIST_HOME_INTERNAL", GetHomeDir()); err != nil { if err = os.Setenv("OG_OPENGIST_HOME_INTERNAL", GetHomeDir()); err != nil {
return err return err
} }
@@ -228,6 +241,15 @@ func GetHomeDir() string {
return filepath.Clean(absolutePath) return filepath.Clean(absolutePath)
} }
func SetupSecretKey() {
if C.SecretKey == "" {
path := filepath.Join(GetHomeDir(), "opengist-secret.key")
SecretKey, _ = utils.GenerateSecretKey(path)
} else {
SecretKey = []byte(C.SecretKey)
}
}
func loadConfigFromYaml(c *config, configPath string, out io.Writer) error { func loadConfigFromYaml(c *config, configPath string, out io.Writer) error {
if configPath != "" { if configPath != "" {
absolutePath, _ := filepath.Abs(configPath) absolutePath, _ := filepath.Abs(configPath)

View File

@@ -0,0 +1,42 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
// auto migration for newer versions of Opengist
func migrateConfig() error {
configMigrations := []struct {
Version string
Func func() error
}{
{"1.8.0", v1_8_0},
}
for _, fn := range configMigrations {
err := fn.Func()
if err != nil {
return err
}
}
return nil
}
func v1_8_0() error {
homeDir := GetHomeDir()
moveFile(filepath.Join(filepath.Join(homeDir, "sessions"), "session-auth.key"), filepath.Join(homeDir, "opengist-secret.key"))
return nil
}
func moveFile(oldPath, newPath string) {
if _, err := os.Stat(oldPath); err != nil {
return
}
if err := os.Rename(oldPath, newPath); err == nil {
fmt.Printf("Automatically moved %s to %s\n", oldPath, newPath)
}
}

View File

@@ -5,7 +5,7 @@ import (
) )
type AdminSetting struct { type AdminSetting struct {
Key string `gorm:"uniqueIndex"` Key string `gorm:"index:,unique"`
Value string Value string
} }
@@ -49,7 +49,7 @@ func UpdateSetting(key string, value string) error {
} }
func setSetting(key string, value string) error { func setSetting(key string, value string) error {
return db.Create(&AdminSetting{Key: key, Value: value}).Error return db.FirstOrCreate(&AdminSetting{Key: key, Value: value}, &AdminSetting{Key: key}).Error
} }
func initAdminSettings(settings map[string]string) error { func initAdminSettings(settings map[string]string) error {
@@ -64,9 +64,9 @@ func initAdminSettings(settings map[string]string) error {
return nil return nil
} }
type DBAuthInfo struct{} type AuthInfo struct{}
func (auth DBAuthInfo) RequireLogin() (bool, error) { func (auth AuthInfo) RequireLogin() (bool, error) {
s, err := GetSetting(SettingRequireLogin) s, err := GetSetting(SettingRequireLogin)
if err != nil { if err != nil {
return true, err return true, err
@@ -74,7 +74,7 @@ func (auth DBAuthInfo) RequireLogin() (bool, error) {
return s == "1", nil return s == "1", nil
} }
func (auth DBAuthInfo) AllowGistsWithoutLogin() (bool, error) { func (auth AuthInfo) AllowGistsWithoutLogin() (bool, error) {
s, err := GetSetting(SettingAllowGistsWithoutLogin) s, err := GetSetting(SettingAllowGistsWithoutLogin)
if err != nil { if err != nil {
return false, err return false, err

View File

@@ -2,38 +2,133 @@ package db
import ( import (
"errors" "errors"
"fmt"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm/logger"
"net/url"
"path/filepath"
"slices" "slices"
"strings" "strings"
"time"
msqlite "github.com/glebarez/go-sqlite"
"github.com/glebarez/sqlite"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger"
) )
var db *gorm.DB var db *gorm.DB
func Setup(dbPath string, sharedCache bool) error { const (
var err error SQLite databaseType = iota
journalMode := strings.ToUpper(config.C.SqliteJournalMode) PostgreSQL
MySQL
)
if !slices.Contains([]string{"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"}, journalMode) { type databaseType int
log.Warn().Msg("Invalid SQLite journal mode: " + journalMode)
func (d databaseType) String() string {
return [...]string{"SQLite", "PostgreSQL", "MySQL"}[d]
}
type databaseInfo struct {
Type databaseType
Host string
Port string
User string
Password string
Database string
}
var DatabaseInfo *databaseInfo
func parseDBURI(uri string) (*databaseInfo, error) {
info := &databaseInfo{}
if !strings.Contains(uri, "://") {
info.Type = SQLite
if uri == "file::memory:" {
info.Database = "file::memory:"
return info, nil
}
info.Database = filepath.Join(config.GetHomeDir(), uri)
return info, nil
}
u, err := url.Parse(uri)
if err != nil {
return nil, fmt.Errorf("invalid URI: %v", err)
} }
sharedCacheStr := "" switch u.Scheme {
if sharedCache { case "postgres", "postgresql":
sharedCacheStr = "&cache=shared" info.Type = PostgreSQL
case "mysql", "mariadb":
info.Type = MySQL
default:
return nil, fmt.Errorf("unknown database: %v", err)
} }
if db, err = gorm.Open(sqlite.Open(dbPath+"?_fk=true&_journal_mode="+journalMode+sharedCacheStr), &gorm.Config{ if u.Host != "" {
Logger: logger.Default.LogMode(logger.Silent), host, port, _ := strings.Cut(u.Host, ":")
}); err != nil { info.Host = host
info.Port = port
}
if u.User != nil {
info.User = u.User.Username()
info.Password, _ = u.User.Password()
}
switch info.Type {
case PostgreSQL, MySQL:
info.Database = strings.TrimPrefix(u.Path, "/")
default:
return nil, fmt.Errorf("unknown database: %v", err)
}
return info, nil
}
func Setup(dbUri string, sharedCache bool) error {
dbInfo, err := parseDBURI(dbUri)
if err != nil {
return err return err
} }
log.Info().Msgf("Setting up a %s database connection", dbInfo.Type)
var setupFunc func(databaseInfo, bool) error
switch dbInfo.Type {
case SQLite:
setupFunc = setupSQLite
case PostgreSQL:
setupFunc = setupPostgres
case MySQL:
setupFunc = setupMySQL
default:
return fmt.Errorf("unknown database type: %v", dbInfo.Type)
}
maxAttempts := 60
retryInterval := 1 * time.Second
for attempt := 1; attempt <= maxAttempts; attempt++ {
err = setupFunc(*dbInfo, sharedCache)
if err == nil {
log.Info().Msg("Database connection established")
break
}
if attempt < maxAttempts {
log.Warn().Err(err).Msgf("Failed to connect to database (attempt %d), retrying in %v...", attempt, retryInterval)
time.Sleep(retryInterval)
} else {
return err
}
}
DatabaseInfo = dbInfo
if err = db.SetupJoinTable(&Gist{}, "Likes", &Like{}); err != nil { if err = db.SetupJoinTable(&Gist{}, "Likes", &Like{}); err != nil {
return err return err
} }
@@ -42,11 +137,11 @@ func Setup(dbPath string, sharedCache bool) error {
return err return err
} }
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}); err != nil { if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}); err != nil {
return err return err
} }
if err = ApplyMigrations(db); err != nil { if err = applyMigrations(db, dbInfo); err != nil {
return err return err
} }
@@ -75,11 +170,7 @@ func CountAll(table interface{}) (int64, error) {
} }
func IsUniqueConstraintViolation(err error) bool { func IsUniqueConstraintViolation(err error) bool {
var sqliteErr *msqlite.Error return errors.Is(err, gorm.ErrDuplicatedKey)
if errors.As(err, &sqliteErr) && sqliteErr.Code() == 2067 {
return true
}
return false
} }
func Ping() error { func Ping() error {
@@ -90,3 +181,65 @@ func Ping() error {
return sql.Ping() return sql.Ping()
} }
func setupSQLite(dbInfo databaseInfo, sharedCache bool) error {
var err error
journalMode := strings.ToUpper(config.C.SqliteJournalMode)
if !slices.Contains([]string{"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"}, journalMode) {
log.Warn().Msg("Invalid SQLite journal mode: " + journalMode)
}
sharedCacheStr := ""
if sharedCache {
sharedCacheStr = "&cache=shared"
}
db, err = gorm.Open(sqlite.Open(dbInfo.Database+"?_fk=true&_journal_mode="+journalMode+sharedCacheStr), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
TranslateError: true,
})
return err
}
func setupPostgres(dbInfo databaseInfo, sharedCache bool) error {
var err error
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", dbInfo.Host, dbInfo.Port, dbInfo.User, dbInfo.Password, dbInfo.Database)
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
TranslateError: true,
})
return err
}
func setupMySQL(dbInfo databaseInfo, sharedCache bool) error {
var err error
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", dbInfo.User, dbInfo.Password, dbInfo.Host, dbInfo.Port, dbInfo.Database)
db, err = gorm.Open(mysql.New(mysql.Config{
DSN: dsn,
DontSupportRenameIndex: true,
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
TranslateError: true,
})
return err
}
func DeprecationDBFilename() {
if config.C.DBFilename != "" {
log.Warn().Msg("The 'db-filename'/'OG_DB_FILENAME' configuration option is deprecated and will be removed in a future version. Please use 'db-uri'/'OG_DB_URI' instead.")
}
if config.C.DBUri == "" {
config.C.DBUri = config.C.DBFilename
}
}
func TruncateDatabase() error {
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{})
}

View File

@@ -101,7 +101,7 @@ func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
func GetGist(user string, gistUuid string) (*Gist, error) { func GetGist(user string, gistUuid string) (*Gist, error) {
gist := new(Gist) gist := new(Gist)
err := db.Preload("User").Preload("Forked.User"). err := db.Preload("User").Preload("Forked.User").
Where("(gists.uuid = ? OR gists.url = ?) AND users.username like ?", gistUuid, gistUuid, user). Where("(gists.uuid like ? OR gists.url = ?) AND users.username like ?", gistUuid+"%", gistUuid, user).
Joins("join users on gists.user_id = users.id"). Joins("join users on gists.user_id = users.id").
First(&gist).Error First(&gist).Error

View File

@@ -1,6 +1,7 @@
package db package db
import ( import (
"fmt"
"math/rand" "math/rand"
"time" "time"
) )
@@ -15,10 +16,21 @@ type Invitation struct {
func GetAllInvitations() ([]*Invitation, error) { func GetAllInvitations() ([]*Invitation, error) {
var invitations []*Invitation var invitations []*Invitation
err := db. dialect := db.Dialector.Name()
Order("(((expires_at >= strftime('%s', 'now')) AND ((nb_max <= 0) OR (nb_used < nb_max)))) desc"). query := db.Model(&Invitation{})
Order("id asc").
Find(&invitations).Error switch dialect {
case "sqlite":
query = query.Order("(((expires_at >= strftime('%s', 'now')) AND ((nb_max <= 0) OR (nb_used < nb_max)))) DESC")
case "postgres":
query = query.Order("(((expires_at >= EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)) AND ((nb_max <= 0) OR (nb_used < nb_max)))) DESC")
case "mysql":
query = query.Order("(((expires_at >= UNIX_TIMESTAMP()) AND ((nb_max <= 0) OR (nb_used < nb_max)))) DESC")
default:
return nil, fmt.Errorf("unsupported database dialect: %s", dialect)
}
err := query.Order("id ASC").Find(&invitations).Error
return invitations, err return invitations, err
} }

View File

@@ -11,7 +11,19 @@ type MigrationVersion struct {
Version uint Version uint
} }
func ApplyMigrations(db *gorm.DB) error { func applyMigrations(db *gorm.DB, dbInfo *databaseInfo) error {
switch dbInfo.Type {
case SQLite:
return applySqliteMigrations(db)
case PostgreSQL, MySQL:
return nil
default:
return fmt.Errorf("unknown database type: %s", dbInfo.Type)
}
}
func applySqliteMigrations(db *gorm.DB) error {
// Create migration table if it doesn't exist // Create migration table if it doesn't exist
if err := db.AutoMigrate(&MigrationVersion{}); err != nil { if err := db.AutoMigrate(&MigrationVersion{}); err != nil {
log.Fatal().Err(err).Msg("Error creating migration version table") log.Fatal().Err(err).Msg("Error creating migration version table")

122
internal/db/totp.go Normal file
View File

@@ -0,0 +1,122 @@
package db
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
ogtotp "github.com/thomiceli/opengist/internal/auth/totp"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/utils"
"slices"
)
type TOTP struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"uniqueIndex"`
User User
Secret string
RecoveryCodes jsonData `gorm:"type:json"`
CreatedAt int64
LastUsedAt int64
}
func GetTOTPByUserID(userID uint) (*TOTP, error) {
var totp TOTP
err := db.Where("user_id = ?", userID).First(&totp).Error
return &totp, err
}
func (totp *TOTP) StoreSecret(secret string) error {
secretBytes := []byte(secret)
encrypted, err := utils.AESEncrypt(config.SecretKey, secretBytes)
if err != nil {
return err
}
totp.Secret = base64.URLEncoding.EncodeToString(encrypted)
return nil
}
func (totp *TOTP) ValidateCode(code string) (bool, error) {
ciphertext, err := base64.URLEncoding.DecodeString(totp.Secret)
if err != nil {
return false, err
}
secretBytes, err := utils.AESDecrypt(config.SecretKey, ciphertext)
if err != nil {
return false, err
}
return ogtotp.Validate(code, string(secretBytes)), nil
}
func (totp *TOTP) ValidateRecoveryCode(code string) (bool, error) {
var hashedCodes []string
if err := json.Unmarshal(totp.RecoveryCodes, &hashedCodes); err != nil {
return false, err
}
for i, hashedCode := range hashedCodes {
ok, err := utils.Argon2id.Verify(code, hashedCode)
if err != nil {
return false, err
}
if ok {
codesJson, _ := json.Marshal(slices.Delete(hashedCodes, i, i+1))
totp.RecoveryCodes = codesJson
return true, db.Model(&totp).Updates(TOTP{RecoveryCodes: codesJson}).Error
}
}
return false, nil
}
func (totp *TOTP) GenerateRecoveryCodes() ([]string, error) {
codes, plainCodes, err := generateRandomCodes()
if err != nil {
return nil, err
}
codesJson, _ := json.Marshal(codes)
totp.RecoveryCodes = codesJson
return plainCodes, db.Model(&totp).Updates(TOTP{RecoveryCodes: codesJson}).Error
}
func (totp *TOTP) Create() error {
return db.Create(&totp).Error
}
func (totp *TOTP) Delete() error {
return db.Delete(&totp).Error
}
func generateRandomCodes() ([]string, []string, error) {
const count = 5
const length = 10
codes := make([]string, count)
plainCodes := make([]string, count)
for i := 0; i < count; i++ {
bytes := make([]byte, (length+1)/2)
if _, err := rand.Read(bytes); err != nil {
return nil, nil, err
}
hexCode := hex.EncodeToString(bytes)
code := fmt.Sprintf("%s-%s", hexCode[:length/2], hexCode[length/2:])
plainCodes[i] = code
hashed, err := utils.Argon2id.Hash(code)
if err != nil {
return nil, nil, err
}
codes[i] = hashed
}
return codes, plainCodes, nil
}
// -- DTO -- //
type TOTPDTO struct {
Code string `form:"code" validate:"max=50"`
}

77
internal/db/types.go Normal file
View File

@@ -0,0 +1,77 @@
package db
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
type binaryData []byte
func (b *binaryData) Value() (driver.Value, error) {
return []byte(*b), nil
}
func (b *binaryData) Scan(value interface{}) error {
valBytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to unmarshal BinaryData: %v", value)
}
*b = valBytes
return nil
}
func (*binaryData) GormDataType() string {
return "binary_data"
}
func (*binaryData) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "BLOB"
case "mysql":
return "VARBINARY(1024)"
case "postgres":
return "BYTEA"
default:
return "BLOB"
}
}
type jsonData json.RawMessage
func (j *jsonData) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
}
result := json.RawMessage{}
err := json.Unmarshal(bytes, &result)
*j = jsonData(result)
return err
}
func (j *jsonData) Value() (driver.Value, error) {
if len(*j) == 0 {
return nil, nil
}
return json.RawMessage(*j).MarshalJSON()
}
func (*jsonData) GormDataType() string {
return "json"
}
func (*jsonData) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
switch db.Dialector.Name() {
case "mysql", "sqlite":
return "JSON"
case "postgres":
return "JSONB"
}
return ""
}

View File

@@ -6,7 +6,7 @@ import (
type User struct { type User struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex"` Username string `gorm:"uniqueIndex,size:191"`
Password string Password string
IsAdmin bool IsAdmin bool
CreatedAt int64 CreatedAt int64
@@ -18,9 +18,10 @@ type User struct {
GiteaID string GiteaID string
OIDCID string `gorm:"column:oidc_id"` OIDCID string `gorm:"column:oidc_id"`
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
} }
func (user *User) BeforeDelete(tx *gorm.DB) error { func (user *User) BeforeDelete(tx *gorm.DB) error {
@@ -58,6 +59,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
return err return err
} }
err = tx.Where("user_id = ?", user.ID).Delete(&WebAuthnCredential{}).Error
if err != nil {
return err
}
// Delete all gists created by this user // Delete all gists created by this user
return tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error return tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
} }
@@ -200,6 +206,19 @@ func (user *User) DeleteProviderID(provider string) error {
return nil return nil
} }
func (user *User) HasMFA() (bool, bool, error) {
var webauthn bool
var totp bool
err := db.Model(&WebAuthnCredential{}).Select("count(*) > 0").Where("user_id = ?", user.ID).Find(&webauthn).Error
if err != nil {
return false, false, err
}
err = db.Model(&TOTP{}).Select("count(*) > 0").Where("user_id = ?", user.ID).Find(&totp).Error
return webauthn, totp, err
}
// -- DTO -- // // -- DTO -- //
type UserDTO struct { type UserDTO struct {

View File

@@ -0,0 +1,149 @@
package db
import (
"encoding/hex"
"github.com/go-webauthn/webauthn/webauthn"
"time"
)
type WebAuthnCredential struct {
ID uint `gorm:"primaryKey"`
Name string
UserID uint
User User
CredentialID binaryData `gorm:"type:binary_data"`
PublicKey binaryData `gorm:"type:binary_data"`
AttestationType string
AAGUID binaryData `gorm:"type:binary_data"`
SignCount uint32
CloneWarning bool
FlagUserPresent bool
FlagUserVerified bool
FlagBackupEligible bool
FlagBackupState bool
CreatedAt int64
LastUsedAt int64
}
func (*WebAuthnCredential) TableName() string {
return "webauthn"
}
func GetAllWACredentialsForUser(userID uint) ([]webauthn.Credential, error) {
var creds []WebAuthnCredential
err := db.Where("user_id = ?", userID).Find(&creds).Error
if err != nil {
return nil, err
}
webCreds := make([]webauthn.Credential, len(creds))
for i, cred := range creds {
webCreds[i] = webauthn.Credential{
ID: cred.CredentialID,
PublicKey: cred.PublicKey,
AttestationType: cred.AttestationType,
Authenticator: webauthn.Authenticator{
AAGUID: cred.AAGUID,
SignCount: cred.SignCount,
CloneWarning: cred.CloneWarning,
},
Flags: webauthn.CredentialFlags{
UserPresent: cred.FlagUserPresent,
UserVerified: cred.FlagUserVerified,
BackupEligible: cred.FlagBackupEligible,
BackupState: cred.FlagBackupState,
},
}
}
return webCreds, nil
}
func GetAllCredentialsForUser(userID uint) ([]WebAuthnCredential, error) {
var creds []WebAuthnCredential
err := db.Where("user_id = ?", userID).Find(&creds).Error
return creds, err
}
func GetUserByCredentialID(credID binaryData) (*User, error) {
var credential WebAuthnCredential
var err error
switch db.Dialector.Name() {
case "postgres":
hexCredID := hex.EncodeToString(credID)
if err = db.Preload("User").Where("credential_id = decode(?, 'hex')", hexCredID).First(&credential).Error; err != nil {
return nil, err
}
case "mysql":
case "sqlite":
hexCredID := hex.EncodeToString(credID)
if err = db.Preload("User").Where("credential_id = unhex(?)", hexCredID).First(&credential).Error; err != nil {
return nil, err
}
}
return &credential.User, err
}
func GetCredentialByIDDB(id uint) (*WebAuthnCredential, error) {
var cred WebAuthnCredential
err := db.Where("id = ?", id).First(&cred).Error
return &cred, err
}
func GetCredentialByID(id binaryData) (*WebAuthnCredential, error) {
var cred WebAuthnCredential
var err error
switch db.Dialector.Name() {
case "postgres":
hexCredID := hex.EncodeToString(id)
if err = db.Where("credential_id = decode(?, 'hex')", hexCredID).First(&cred).Error; err != nil {
return nil, err
}
case "mysql":
case "sqlite":
hexCredID := hex.EncodeToString(id)
if err = db.Where("credential_id = unhex(?)", hexCredID).First(&cred).Error; err != nil {
return nil, err
}
}
return &cred, err
}
func CreateFromCrendential(userID uint, name string, cred *webauthn.Credential) (*WebAuthnCredential, error) {
credDb := &WebAuthnCredential{
UserID: userID,
Name: name,
CredentialID: cred.ID,
PublicKey: cred.PublicKey,
AttestationType: cred.AttestationType,
AAGUID: cred.Authenticator.AAGUID,
SignCount: cred.Authenticator.SignCount,
CloneWarning: cred.Authenticator.CloneWarning,
FlagUserPresent: cred.Flags.UserPresent,
FlagUserVerified: cred.Flags.UserVerified,
FlagBackupEligible: cred.Flags.BackupEligible,
FlagBackupState: cred.Flags.BackupState,
}
err := db.Create(credDb).Error
return credDb, err
}
func (w *WebAuthnCredential) UpdateSignCount() error {
return db.Model(w).Update("sign_count", w.SignCount).Error
}
func (w *WebAuthnCredential) UpdateLastUsedAt() error {
return db.Model(w).Update("last_used_at", time.Now().Unix()).Error
}
func (w *WebAuthnCredential) Delete() error {
return db.Delete(w).Error
}
// -- DTO -- //
type CrendentialDTO struct {
PasskeyName string `json:"passkeyname" validate:"max=50"`
}

View File

@@ -52,6 +52,7 @@ gist.edit.change-visibility: Make
gist.edit.delete: Delete gist.edit.delete: Delete
gist.edit.cancel: Cancel gist.edit.cancel: Cancel
gist.edit.save: Save gist.edit.save: Save
gist.delete.confirm: Are you sure you want to delete this gist ?
gist.list.joined: Joined gist.list.joined: Joined
gist.list.all: All gists gist.list.all: All gists
@@ -143,6 +144,39 @@ auth.password: Password
auth.register-instead: Register instead auth.register-instead: Register instead
auth.login-instead: Login instead auth.login-instead: Login instead
auth.oauth: Continue with %s account auth.oauth: Continue with %s account
auth.mfa: Multi-factor authentication
auth.mfa.passkey: Passkey
auth.mfa.passkeys: Passkeys
auth.mfa.use-passkey: Use passkey
auth.mfa.bind-passkey: Bind passkey
auth.mfa.login-with-passkey: Login with passkey
auth.mfa.waiting-for-passkey-input: Waiting for input from browser interaction...
auth.mfa.use-passkey-to-finish: Use a passkey to finish authentication
auth.mfa.passkeys-help: Add a passkey to log to your account and to use as an MFA method.
auth.mfa.passkey-name: Name
auth.mfa.delete-passkey: Delete
auth.mfa.passkey-added-at: Added
auth.mfa.passkey-never-used: Never used
auth.mfa.passkey-last-used: Last used
auth.mfa.delete-passkey-confirm: Confirm deletion of passkey
auth.totp: Time based one-time password (TOTP)
auth.totp.help: TOTP is a two-factor authentication method that uses a shared secret to generate a one-time password.
auth.totp.use: Use TOTP
auth.totp.regenerate-recovery-codes: Regenerate recovery codes
auth.totp.already-enabled: TOTP is already enabled
auth.totp.invalid-secret: Invalid TOTP secret
auth.totp.invalid-code: Invalid TOTP code
auth.totp.code-used: The recovery code %s was used, it is now invalid. You may want to disable MFA for now or regenerate your codes.
auth.totp.disabled: TOTP successfully disabled
auth.totp.disable: Disable TOTP
auth.totp.enter-code: Enter the code from the Authenticator app
auth.totp.enter-recovery-key: or a recovery key if you lost your device
auth.totp.code: Code
auth.totp.submit: Submit
auth.totp.proceed: Proceed
auth.totp.save-recovery-codes: Save your recovery codes in a safe place. You can use these codes to recover access to your account if you lose access to your authenticator app.
auth.totp.scan-qr-code: Scan the QR code below with your authenticator app to enable two-factor authentication or enter the following string, then confirm with the generated code.
error: Error error: Error
error.page-not-found: Page not found error.page-not-found: Page not found
@@ -155,6 +189,7 @@ error.oauth-unsupported: Unsupported provider
error.cannot-bind-data: Cannot bind data error.cannot-bind-data: Cannot bind data
error.invalid-number: Invalid number error.invalid-number: Invalid number
error.invalid-character-unescaped: Invalid character unescaped error.invalid-character-unescaped: Invalid character unescaped
error.not-in-mfa-session: User is not in a MFA session
header.menu.all: All header.menu.all: All
header.menu.new: New header.menu.new: New
@@ -245,6 +280,8 @@ flash.auth.account-unlinked-oauth: Account unlinked from %s
flash.auth.user-sshkeys-not-retrievable: Could not get user keys flash.auth.user-sshkeys-not-retrievable: Could not get user keys
flash.auth.user-sshkeys-not-created: Could not create ssh key flash.auth.user-sshkeys-not-created: Could not create ssh key
flash.auth.must-be-logged-in: You must be logged in to access gists flash.auth.must-be-logged-in: You must be logged in to access gists
flash.auth.passkey-registred: Passkey %s registered
flash.auth.passkey-deleted: Passkey deleted
flash.gist.visibility-changed: Gist visibility has been changed flash.gist.visibility-changed: Gist visibility has been changed
flash.gist.deleted: Gist has been deleted flash.gist.deleted: Gist has been deleted

View File

@@ -1,5 +1,5 @@
gist.public: Herkese Açık gist.public: Herkese Açık
gist.unlisted: Liste Dışı gist.unlisted: Listelenmemiş
gist.private: Gizli gist.private: Gizli
gist.header.like: Beğen gist.header.like: Beğen
@@ -7,19 +7,19 @@ gist.header.unlike: Beğenmekten Vazgeç
gist.header.fork: Çatalla gist.header.fork: Çatalla
gist.header.edit: Düzenle gist.header.edit: Düzenle
gist.header.delete: Sil gist.header.delete: Sil
gist.header.forked-from: Çatallı gist.header.forked-from: Çatallandı
gist.header.last-active: Son aktif gist.header.last-active: Son aktivite
gist.header.select-tab: Bir sekme seç gist.header.select-tab: Bir sekme seç
gist.header.code: Kod gist.header.code: Kod
gist.header.revisions: Revizyonlar gist.header.revisions: Revizyonlar
gist.header.revision: Revizyon gist.header.revision: Revizyon
gist.header.clone-http: \%s aracılığıyla klonla gist.header.clone-http: '%s ile klonla'
gist.header.clone-http-help: HTTP temel kimlik doğrulamasını kullanarak Git ile klonlayın. gist.header.clone-http-help: HTTP temel kimlik doğrulamasını kullanarak Git ile klonlayın.
gist.header.clone-ssh: SSH aracılığıyla klonla gist.header.clone-ssh: SSH aracılığıyla klonla
gist.header.clone-ssh-help: Bir SSH anahtarı kullanarak Git ile klonlayın. gist.header.clone-ssh-help: Bir SSH anahtarı kullanarak Git ile klonlayın.
gist.header.embed: Yerleştirme gist.header.embed: Göm
gist.header.embed-help: Bu gisti web sitenize yerleştirin. gist.header.embed-help: Bu gisti web sitenize yerleştirin.
gist.header.download-zip: ZIP'i indirin gist.header.download-zip: ZIP'i İndir
gist.raw: Ham gist.raw: Ham
gist.file-truncated: Bu dosya kısaltılmıştır. gist.file-truncated: Bu dosya kısaltılmıştır.
@@ -29,27 +29,27 @@ gist.no-content: Dosya bulunamadı
gist.new.new_gist: Yeni gist gist.new.new_gist: Yeni gist
gist.new.title: Başlık gist.new.title: Başlık
gist.new.description: Description gist.new.description: ıklama
gist.new.url: URL gist.new.url: URL
gist.new.filename-with-extension: Uzantılı dosya adı gist.new.filename-with-extension: Dosya adı ve uzantısı
gist.new.indent-mode: Girinti modu gist.new.indent-mode: Girinti modu
gist.new.indent-mode-space: Boşluk gist.new.indent-mode-space: Boşluk
gist.new.indent-mode-tab: Tab gist.new.indent-mode-tab: Sekme
gist.new.indent-size: Girinti boyutu gist.new.indent-size: Girinti boyutu
gist.new.wrap-mode: '' gist.new.wrap-mode: 'Satır kaydırma modu'
gist.new.wrap-mode-no: '' gist.new.wrap-mode-no: 'Kaydırma yok'
gist.new.wrap-mode-soft: '' gist.new.wrap-mode-soft: 'Yumuşak kaydırma'
gist.new.add-file: Add file gist.new.add-file: Dosya ekle
gist.new.create-public-button: Herkese açık gist oluştur gist.new.create-public-button: Herkese açık gist oluştur
gist.new.create-unlisted-button: Liste dışı gist oluştur gist.new.create-unlisted-button: Liste dışı gist oluştur
gist.new.create-private-button: Gizli gist oluştur gist.new.create-private-button: Gizli gist oluştur
gist.new.preview: Ön izle gist.new.preview: Önizle
gist.new.create-a-new-gist: Yeni bir gist oluştur gist.new.create-a-new-gist: Yeni bir gist oluştur
gist.edit.editing: Düzenleme gist.edit.editing: Düzenleniyor
gist.edit.edit-gist: '%s düzenle' gist.edit.edit-gist: '%s düzenle'
gist.edit.change-visibility: '' gist.edit.change-visibility: 'Yap'
gist.edit.delete: Delete gist.edit.delete: Sil
gist.edit.cancel: İptal Et gist.edit.cancel: İptal Et
gist.edit.save: Kaydet gist.edit.save: Kaydet
@@ -58,210 +58,211 @@ gist.list.all: Tüm gistler
gist.list.search-results: Arama sonuçları gist.list.search-results: Arama sonuçları
gist.list.sort: Sırala gist.list.sort: Sırala
gist.list.sort-by-created: oluşturuldu gist.list.sort-by-created: oluşturuldu
gist.list.sort-by-updated: düzenlendi gist.list.sort-by-updated: güncellendi
gist.list.order-by-asc: En son yakın zamanda gist.list.order-by-asc: En eski
gist.list.order-by-desc: Son zamanlarda gist.list.order-by-desc: Yakın zamanda
gist.list.select-tab: Bir sekme seçin gist.list.select-tab: Bir sekme seçin
gist.list.liked: Beğenildi gist.list.liked: Beğenildi
gist.list.likes: beğeniler gist.list.likes: beğeniler
gist.list.forked: Çatallı gist.list.forked: Çatallandı
gist.list.forked-from: çatallandı gist.list.forked-from: Çatallandı
gist.list.forks: çatallar gist.list.forks: çatallar
gist.list.files: files gist.list.files: dosyalar
gist.list.last-active: Son aktif gist.list.last-active: Son aktivite
gist.list.no-gists: Gistler yok gist.list.no-gists: Gist yok
gist.list.all-liked-by: '%s tarafından beğenilen tüm gistler' gist.list.all-liked-by: '%s tarafından beğenilen tüm gistler'
gist.list.all-forked-by: '%s tarafından beğenilen tüm çatallar' gist.list.all-forked-by: '%s tarafından çatallanan tüm gistler'
gist.list.all-from: '%s tüm gistleri' gist.list.all-from: '%s oluşturduğu tüm gistler'
gist.search.found: bulunan gistler gist.search.found: gist bulundu
gist.search.no-results: Hiç gist bulunamadı gist.search.no-results: Hiç gist bulunamadı
gist.search.help.user: gists created by user gist.search.help.user: kullanıcı tarafından oluşturulan gistler
gist.search.help.title: gists with given title gist.search.help.title: belirtilen isme sahip gistler
gist.search.help.filename: gists having files with given name gist.search.help.filename: belirtilen isimde dosyaları olan gistler
gist.search.help.extension: gists having files with given extension gist.search.help.extension: belirtilen uzantıya sahip dosyalara sahip gistler
gist.search.help.language: gists having files with given language gist.search.help.language: belirtilen dilde dosyaları olan gistler
gist.forks: Forks gist.forks: Çatallar
gist.forks.view: View fork gist.forks.view: Çatalı görüntüle
gist.forks.no: No public forks gist.forks.no: Herkes açık çatal yok
gist.forks.for: Forks for %s gist.forks.for: '%s için çatallar'
gist.likes: Likes gist.likes: Beğeniler
gist.likes.no: No likes yet gist.likes.no: Henüz kimse beğenmedi
gist.likes.for: Likes for %s gist.likes.for: '%s için beğeniler'
gist.revisions: Revisions gist.revisions: Düzenlemeler
gist.revision.revised: revised this gist gist.revision.revised: bu gisti düzenledi
gist.revision.go-to-revision: Go to revision gist.revision.go-to-revision: Düzenlemeye git
gist.revision.file-created: file created gist.revision.file-created: dosya oluşturuldu
gist.revision.file-deleted: file deleted gist.revision.file-deleted: dosya silindi
gist.revision.file-renamed: renamed to gist.revision.file-renamed: yeniden adlandırıldı
gist.revision.diff-truncated: Diff is too large to be shown gist.revision.diff-truncated: Fark gösterilemeyecek kadar büyük
gist.revision.file-renamed-no-changes: File renamed without changes gist.revision.file-renamed-no-changes: Dosya değişiklik yapılmadan yeniden adlandırıldı
gist.revision.empty-file: Empty file gist.revision.empty-file: Boş dosya
gist.revision.no-changes: No changes gist.revision.no-changes: Değişiklik yok
gist.revision.no-revisions: No revisions to show gist.revision.no-revisions: Gösterilecek düzenleme yok
gist.revision-of: Revision of %s gist.revision-of: '%s düzenlemesi'
settings: Settings settings: Ayarlar
settings.email: Email settings.email: E-Posta
settings.email-help: Used for commits and Gravatar settings.email-help: Commit ve Gravatar için kullanılır
settings.email-set: Set email settings.email-set: E-postayı ayarla
settings.link-accounts: Link accounts settings.link-accounts: Hesapları bağla
settings.link-github-account: Link GitHub account settings.link-github-account: GitHub hesabını bağla
settings.link-gitlab-account: Link GitLab account settings.link-gitlab-account: GitLab hesabını bağla
settings.link-gitea-account: Link Gitea account settings.link-gitea-account: Gitea hesabını bağla
settings.unlink-github-account: Unlink GitHub account settings.unlink-github-account: GitHub hesabının bağlantısını kaldır
settings.unlink-gitlab-account: Unlink GitLab account settings.unlink-gitlab-account: GitLab hesabının bağlantısını kaldır
settings.unlink-gitea-account: Unlink Gitea account settings.unlink-gitea-account: Gitea hesabının bağlantısını kaldır
settings.delete-account: Delete account settings.delete-account: Hesabı sil
settings.delete-account-confirm: Are you sure you want to delete your account ? settings.delete-account-confirm: Hesabını silmek istediğinden emin misin?
settings.add-ssh-key: Add SSH key settings.add-ssh-key: SSH anahtarı ekle
settings.add-ssh-key-help: Used only to pull/push gists using Git via SSH settings.add-ssh-key-help: Sadece SSH üzerinden Git kullanarak gistlerin pull/push işlemlerinde kullanılır
settings.add-ssh-key-title: Title settings.add-ssh-key-title: Başlık
settings.add-ssh-key-content: Key settings.add-ssh-key-content: Anahtar
settings.delete-ssh-key: Delete settings.delete-ssh-key: Sil
settings.delete-ssh-key-confirm: Confirm deletion of SSH key settings.delete-ssh-key-confirm: SSH anahtarının silinmesini onaylayın
settings.ssh-key-added-at: Added settings.ssh-key-added-at: Eklendi
settings.ssh-key-never-used: Never used settings.ssh-key-never-used: Hiç kullanılmadı
settings.ssh-key-last-used: Last used settings.ssh-key-last-used: Son kullanma
settings.change-username: Change username settings.change-username: Kullanıcı adını değiştir
settings.create-password: Create password settings.create-password: Parola oluştur
settings.create-password-help: Create your password to login to Opengist via HTTP settings.create-password-help: HTTP üzerinden Opengist'e giriş yapmak için parolanızı oluşturun
settings.change-password: Change password settings.change-password: Parolayı değiştir
settings.change-password-help: Change your password to login to Opengist via HTTP settings.change-password-help: HTTP üzerinden Opengist'e giriş yapmak için parolanızı değiştirin
settings.password-label-title: Password settings.password-label-title: Parola
auth.signup-disabled: Administrator has disabled signing up auth.signup-disabled: Yönetici kaydolmayı devre dışı bıraktı
auth.login: Login auth.login: Giriş
auth.signup: Register auth.signup: Kaydol
auth.new-account: New account auth.new-account: Yeni hesap
auth.username: Username auth.username: Kullanıcı adı
auth.password: Password auth.password: Parola
auth.register-instead: Register instead auth.register-instead: Bunun yerine kayıt olun
auth.login-instead: Login instead auth.login-instead: Bunun yerine giriş yapın
auth.oauth: Continue with %s account auth.oauth: '%s hesabı ile devam et'
error: Error error: Hata
error.page-not-found: Page not found error.page-not-found: Sayfa bulunamadı
error.bad-request: Bad request error.bad-request: Geçersiz istek
error.signup-disabled: Signing up is disabled error.signup-disabled: Kullanıcı kaydı devre dışı bırakıldı
error.signup-disabled-form: Signing up via registration form is disabled error.signup-disabled-form: Kayıt formu aracılığıyla kullanıcı kaydı devre dışı bırakıldı
error.login-disabled-form: Logging in via login form is disabled error.login-disabled-form: Giriş formu üzerinden giriş yapma devre dışı bırakıldı
error.complete-oauth-login: "Cannot complete user auth: %s" error.complete-oauth-login: "Kullanıcı kimlik doğrulaması tamamlanamıyor: %s"
error.oauth-unsupported: Unsupported provider error.oauth-unsupported: Desteklenmeyen sağlayıcı
error.cannot-bind-data: Cannot bind data error.cannot-bind-data: Veri bağlanamıyor
error.invalid-number: Invalid number error.invalid-number: Geçersiz numara
error.invalid-character-unescaped: Invalid character unescaped error.invalid-character-unescaped: Geçersiz karakter için kaçış işlemi yapılamadı
header.menu.all: All header.menu.all: Tümü
header.menu.new: New header.menu.new: Yeni
header.menu.search: Search header.menu.search: Ara
header.menu.my-gists: My gists header.menu.my-gists: Gistlerim
header.menu.liked: Liked header.menu.liked: Beğenilen
header.menu.admin: Admin header.menu.admin: Yönetici
header.menu.settings: Settings header.menu.settings: Ayarlar
header.menu.logout: Logout header.menu.logout: Çıkış
header.menu.register: Register header.menu.register: Kaydol
header.menu.login: Login header.menu.login: Giriş
header.menu.light: Light header.menu.light: ık
header.menu.dark: Dark header.menu.dark: Koyu
header.menu.system: System header.menu.system: Sistem
footer.powered-by: Powered by %s footer.powered-by: '%s tarafından desteklenmektedir'
pagination.older: Older pagination.older: Daha eski
pagination.newer: Newer pagination.newer: Daha yeni
pagination.previous: Previous pagination.previous: Önceki
pagination.next: Next pagination.next: Sonraki
admin.admin_panel: Admin panel admin.admin_panel: Yönetici paneli
admin.general: General admin.general: Genel
admin.users: Users admin.users: Kullanıcılar
admin.gists: Gists admin.gists: Gistler
admin.configuration: Configuration admin.configuration: Yapılandırma
admin.invitations: Invitations admin.invitations: Davetler
admin.invitations.create: Create invitation admin.invitations.create: Davet oluştur
admin.versions: Versions admin.versions: Sürümler
admin.ssh_keys: SSH keys admin.ssh_keys: SSH anahtarları
admin.stats: Stats admin.stats: İstatistikler
admin.actions: Actions admin.actions: Eylemler
admin.actions.sync-fs: Synchronize gists from filesystem admin.actions.sync-fs: Gistleri dosya sisteminden senkronize et
admin.actions.sync-db: Synchronize gists from database admin.actions.sync-db: Gistleri veri tabanından senkronize et
admin.actions.git-gc: Garbage collect all git repositories admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
admin.actions.sync-previews: Synchronize all gists previews admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et
admin.actions.reset-hooks: Reset Git server hooks for all repositories admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla
admin.actions.index-gists: Index all gists admin.actions.index-gists: Tüm gistleri indeksle
admin.id: ID admin.id: ID
admin.user: User admin.user: Kullanıcı
admin.delete: Delete admin.delete: Sil
admin.created_at: Created admin.created_at: Oluşturulma
admin.config-link: This configuration can be %s by a YAML config file and/or environment variables. admin.config-link: Bu yapılandırma, bir YAML yapılandırma dosyası ve/veya ortam değişkenleri tarafından %s.
admin.config-link-overriden: overridden admin.config-link-overriden: geçersiz kılınabilir
admin.disable-signup: Disable signup admin.disable-signup: Kayıt işlemini devre dışı bırak
admin.disable-signup_help: Forbid the creation of new accounts. admin.disable-signup_help: Yeni hesap oluşturulmasını yasaklar.
admin.require-login: Require login admin.require-login: Giriş gerektir
admin.require-login_help: Enforce users to be logged in to see gists. admin.require-login_help: Gistleri görebilmek için kullanıcıların oturum açmasını zorunlu kılar.
admin.disable-login: Disable login form admin.disable-login: Giriş formunu devre dışı bırak
admin.disable-login_help: Forbid logging in via the login form to force using OAuth providers instead. admin.disable-login_help: Giriş formunu kullanarak giriş yapmayı yasaklar ve bunun yerine OAuth sağlayıcılarının kullanılmasını zorunlu hale getirir.
admin.disable-gravatar: Disable Gravatar admin.disable-gravatar: Gravatar'ı devre dışı bırak
admin.disable-gravatar_help: Disable the usage of Gravatar as an avatar provider. admin.disable-gravatar_help: Gravatar'ın avatar sağlayıcı olarak kullanımını devre dışı bırakır.
admin.allow-gists-without-login: admin.allow-gists-without-login: Bireysel gistlere oturum açmadan izin ver
admin.allow-gists-without-login_help: admin.allow-gists-without-login_help: Bireysel gistlerin oturum açmadan görüntülenmesine ve indirilmesine izin verir, ancak gistleri keşfetmek için oturum açmayı zorunlu kılar.
admin.users.delete_confirm: Do you want to delete this user ? admin.users.delete_confirm: Bu kullanıcıyı silmek istiyor musun?
admin.gists.title: Title admin.gists.title: Başlık
admin.gists.private: Private ? admin.gists.private: Gizlilik
admin.gists.nb-files: Nb. files admin.gists.nb-files: Dosyalar
admin.gists.nb-likes: Nb. likes admin.gists.nb-likes: Beğeniler
admin.gists.delete_confirm: Do you want to delete this gist ? admin.gists.delete_confirm: Bu gisti silmek istiyor musun?
admin.invitations.help: Invitations can be used to create an account even if signing up is disabled. admin.invitations.help: Kayıt olma özelliği devre dışı bırakılmış olsa bile, hesap oluşturmak için davetiyeler kullanılabilir.
admin.invitations.max_uses: Max uses admin.invitations.max_uses: Maksimum kullanım
admin.invitations.expires_at: Expires at admin.invitations.expires_at: Sona erme tarihi
admin.invitations.code: Code admin.invitations.code: Kod
admin.invitations.copy_link: Copy link admin.invitations.copy_link: Linki kopyala
admin.invitations.uses: Uses admin.invitations.uses: Kullanım
admin.invitations.expired: Expired admin.invitations.expired: Süresi bitti
flash.admin.user-deleted: User has been deleted flash.admin.user-deleted: Kullanıcı silindi
flash.admin.gist-deleted: Gist has been deleted flash.admin.gist-deleted: Gist silindi
flash.admin.invitation-created: Invitation has been created flash.admin.invitation-created: Davetiye oluşturuldu
flash.admin.invitation-deleted: Invitation has been deleted flash.admin.invitation-deleted: Davetiye silindi
flash.admin.sync-fs: Syncing repositories from filesystem... flash.admin.sync-fs: Depolar dosya sisteminden senkronize ediliyor...
flash.admin.sync-db: Syncing repositories from database... flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor...
flash.admin.git-gc: Garbage collecting repositories... flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor...
flash.admin.sync-previews: Syncing Gist previews... flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor...
flash.admin.reset-hooks: Resetting Git server hooks for all repositories... flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor...
flash.admin.index-gists: Indexing all gists... flash.admin.index-gists: Tüm gistler indeksleniyor...
flash.auth.username-exists: Username already exists flash.auth.username-exists: Kullanıcı adı zaten mevcut
flash.auth.invalid-credentials: Invalid credentials flash.auth.invalid-credentials: Geçersiz kimlik bilgileri
flash.auth.account-linked-oauth: Account linked to %s flash.auth.account-linked-oauth: Hesap %s ile bağlantılı
flash.auth.account-unlinked-oauth: Account unlinked from %s flash.auth.account-unlinked-oauth: Hesabın %s ile bağlantısı kaldırıldı
flash.auth.user-sshkeys-not-retrievable: Could not get user keys flash.auth.user-sshkeys-not-retrievable: Kullanıcı anahtarları alınamadı
flash.auth.user-sshkeys-not-created: Could not create ssh key flash.auth.user-sshkeys-not-created: SSH anahtarı oluşturulamadı
flash.auth.must-be-logged-in: You must be logged in to access gists flash.auth.must-be-logged-in: Gistlere erişmek için giriş yapmış olmanız gerekli
flash.gist.visibility-changed: Gist visibility has been changed flash.gist.visibility-changed: Gist görünürlüğü değiştirildi
flash.gist.deleted: Gist has been deleted flash.gist.deleted: Gist silindi
flash.gist.fork-own-gist: Unable to fork own gists flash.gist.fork-own-gist: Kendi gistlerin çatallanamaz
flash.gist.forked: Gist has been forked flash.gist.forked: Gist çatallandı
flash.user.email-updated: Email updated flash.user.email-updated: E-posta güncellendi
flash.user.invalid-ssh-key: Invalid SSH key flash.user.invalid-ssh-key: Geçersiz SSH anahtarı
flash.user.ssh-key-added: SSH key added flash.user.ssh-key-added: SSH anahtarı eklendi
flash.user.ssh-key-deleted: SSH key deleted flash.user.ssh-key-deleted: SSH anahtarı silindi
flash.user.password-updated: Password updated flash.user.password-updated: Parola güncellendi
flash.user.username-updated: Username updated flash.user.username-updated: Kullanıcı adı güncellendi
validation.is-too-long: Field %s is too long validation.is-too-long: '%s alanı çok uzun'
validation.should-not-be-empty: Field %s should not be empty validation.should-not-be-empty: '%s alanı boş olmamalı'
validation.should-not-include-sub-directory: Field %s should not include a sub directory validation.should-not-include-sub-directory: '%s alanı bir alt dizin içermemeli'
validation.should-only-contain-alphanumeric-characters: Field %s should only contain alphanumeric characters validation.should-only-contain-alphanumeric-characters: '%s alanı yalnızca alfanümerik karakterler içermelidir'
validation.should-only-contain-alphanumeric-characters-and-dashes: Field %s should only contain alphanumeric characters and dashes validation.should-only-contain-alphanumeric-characters-and-dashes: '%s alanı yalnızca alfanümerik karakterler ve tire içermelidir'
validation.not-enough: Not enough %s validation.not-enough: Yeterli %s yok
validation.invalid: Invalid %s validation.invalid: Geçersiz %s
html.title.admin-panel: Admin panel html.title.admin-panel: Yönetici paneli
settings.ssh-key-exists: SSH anahtarı zaten mevcut

View File

@@ -9,7 +9,7 @@ gist.header.edit: 编辑
gist.header.delete: 删除 gist.header.delete: 删除
gist.header.forked-from: 派生自 gist.header.forked-from: 派生自
gist.header.last-active: 最后活跃于 gist.header.last-active: 最后活跃于
gist.header.select-tab: Select a tab gist.header.select-tab: 选择一个选项
gist.header.code: 代码 gist.header.code: 代码
gist.header.revisions: 修订 gist.header.revisions: 修订
gist.header.revision: 修订 gist.header.revision: 修订
@@ -17,7 +17,7 @@ gist.header.clone-http: 通过 %s 克隆
gist.header.clone-http-help: 使用 Git 通过 HTTP 基础认证克隆。 gist.header.clone-http-help: 使用 Git 通过 HTTP 基础认证克隆。
gist.header.clone-ssh: 通过 SSH 克隆 gist.header.clone-ssh: 通过 SSH 克隆
gist.header.clone-ssh-help: 使用 Git 通过 SSH 密钥克隆。 gist.header.clone-ssh-help: 使用 Git 通过 SSH 密钥克隆。
gist.header.embed: '' gist.header.embed: '嵌入'
gist.header.embed-help: '在你的网页中嵌入此gist。' gist.header.embed-help: '在你的网页中嵌入此gist。'
gist.header.download-zip: 下载 ZIP gist.header.download-zip: 下载 ZIP
@@ -49,7 +49,7 @@ gist.edit.delete: 删除
gist.edit.cancel: 取消 gist.edit.cancel: 取消
gist.edit.save: 保存 gist.edit.save: 保存
gist.list.joined: Joined gist.list.joined: 已加入
gist.list.all: 所有 Gists gist.list.all: 所有 Gists
gist.list.search-results: 搜索结果 gist.list.search-results: 搜索结果
gist.list.sort: 排序 gist.list.sort: 排序
@@ -77,11 +77,11 @@ gist.likes.no: 还没有喜欢
gist.revisions: 修订 gist.revisions: 修订
gist.revision.revised: 修订了这个 Gist gist.revision.revised: 修订了这个 Gist
gist.revision.go-to-revision: 跳至此修订 gist.revision.go-to-revision: 跳至此修订
gist.revision.file-created: file created gist.revision.file-created: 文件已创建
gist.revision.file-deleted: file deleted gist.revision.file-deleted: 文件已被删除
gist.revision.file-renamed: 重命名为 gist.revision.file-renamed: 重命名为
gist.revision.diff-truncated: 由于变更差异过大,显示内容已被截断 gist.revision.diff-truncated: 由于变更差异过大,显示内容已被截断
gist.revision.file-renamed-no-changes: File renamed without changes gist.revision.file-renamed-no-changes: 文件已重命名,但名称与之前没有差异
gist.revision.empty-file: 空文件 gist.revision.empty-file: 空文件
gist.revision.no-changes: 没有变更 gist.revision.no-changes: 没有变更
gist.revision.no-revisions: 无可供显示的修订 gist.revision.no-revisions: 无可供显示的修订
@@ -102,7 +102,7 @@ settings.add-ssh-key-help: 用于使用 Git 通过 SSH 拉取与推送 Gist
settings.add-ssh-key-title: 标题 settings.add-ssh-key-title: 标题
settings.add-ssh-key-content: 密钥 settings.add-ssh-key-content: 密钥
settings.delete-ssh-key: 删除 settings.delete-ssh-key: 删除
settings.delete-ssh-key-confirm: Confirm deletion of SSH key settings.delete-ssh-key-confirm: 确认删除 SSH 密钥
settings.ssh-key-added-at: 添加 settings.ssh-key-added-at: 添加
settings.ssh-key-never-used: 从未使用过 settings.ssh-key-never-used: 从未使用过
settings.ssh-key-last-used: 最后使用于 settings.ssh-key-last-used: 最后使用于
@@ -123,7 +123,7 @@ header.menu.all: 全部
header.menu.new: 创建 header.menu.new: 创建
header.menu.search: 搜索 header.menu.search: 搜索
header.menu.my-gists: 我的 Gists header.menu.my-gists: 我的 Gists
header.menu.liked: Liked header.menu.liked: 喜欢的 Gists
header.menu.admin: 管理 header.menu.admin: 管理
header.menu.settings: 设置 header.menu.settings: 设置
header.menu.logout: 登出 header.menu.logout: 登出
@@ -166,8 +166,8 @@ admin.disable-login: 禁用登录表单
admin.disable-login_help: 禁止使用登录表单进行登录以强制通过 OAuth 提供方登录。 admin.disable-login_help: 禁止使用登录表单进行登录以强制通过 OAuth 提供方登录。
admin.disable-gravatar: 禁用 Gravatar admin.disable-gravatar: 禁用 Gravatar
admin.disable-gravatar_help: 停止使用 Gravatar 作为头像提供方。 admin.disable-gravatar_help: 停止使用 Gravatar 作为头像提供方。
admin.allow-gists-without-login: admin.allow-gists-without-login: 允许未登录状态下输入个人 gists
admin.allow-gists-without-login_help: admin.allow-gists-without-login_help: 允许在不登录的情况下查看和下载 gist同时需要登录才能使用 gists 的发现功能。
admin.users.delete_confirm: 你想要删除此用户吗? admin.users.delete_confirm: 你想要删除此用户吗?
admin.gists.title: 标题 admin.gists.title: 标题
@@ -176,84 +176,103 @@ admin.gists.nb-files: 文件数
admin.gists.nb-likes: 喜欢数 admin.gists.nb-likes: 喜欢数
admin.gists.delete_confirm: 你想要删除此 Gist 吗? admin.gists.delete_confirm: 你想要删除此 Gist 吗?
gist.new.url: 'URL' gist.new.url: 'URL'
gist.new.preview: '' gist.new.preview: '预览 gist'
error.page-not-found: '' error.page-not-found: '页面未找到'
gist.new.create-a-new-gist: '创建一个新的gist' gist.new.create-a-new-gist: '创建一个新的gist'
gist.edit.edit-gist: '' gist.edit.edit-gist: '编辑 %s'
gist.list.all-liked-by: '' gist.list.all-liked-by: '所有 gists 被 %s 标记喜欢'
gist.list.all-forked-by: '' gist.list.all-forked-by: '所有 gists 被 %s 派生'
gist.list.all-from: '' gist.list.all-from: '所有 gists 来自于 %s'
gist.search.found: '' gist.search.found: '以下是找到的 gists'
gist.search.no-results: '没有找到gist' gist.search.no-results: '没有找到gist'
gist.search.help.user: '由用户创建的gist' gist.search.help.user: '由用户创建的gist'
gist.search.help.title: '定标题的gist' gist.search.help.title: '包含指定标题的 gists'
gist.search.help.filename: '' gist.search.help.filename: 'gists 文件中包含指定名称'
gist.search.help.extension: '' gist.search.help.extension: 'gists 文件中包含指定插件'
gist.search.help.language: '' gist.search.help.language: 'gists 文件中包含指定的开发语言'
gist.forks.for: '' gist.forks.for: '派生到 %s'
gist.likes.for: '' gist.likes.for: '喜欢给 %s'
gist.revision-of: '' gist.revision-of: '被 %s 修订'
settings.link-gitlab-account: '' settings.link-gitlab-account: '关联 GitLab 账号'
settings.unlink-gitlab-account: '' settings.unlink-gitlab-account: '解除关联 GitLab 账号'
settings.change-username: '' settings.change-username: '修改用户名'
settings.create-password: '' settings.create-password: '创建密码'
settings.create-password-help: '' settings.create-password-help: '创建密码用于 HTTP 方式登录 Opengist'
settings.change-password: '' settings.change-password: '修改密码'
settings.change-password-help: '' settings.change-password-help: '修改您的密码用于 HTTP 方式登录 Opengist'
settings.password-label-title: '' settings.password-label-title: '密码'
error.bad-request: '' error.bad-request: '请求错误'
error.signup-disabled: '' error.signup-disabled: '注册功能已被管理员禁用'
error.signup-disabled-form: '' error.signup-disabled-form: '已禁用了表单注册功能'
error.login-disabled-form: '' error.login-disabled-form: '已禁用了表单登录功能'
error.complete-oauth-login: '' error.complete-oauth-login: '用户认证未能通过: %s'
error.oauth-unsupported: '' error.oauth-unsupported: '不支持的认证提供商'
error.cannot-bind-data: '' error.cannot-bind-data: '无法绑定数据'
error.invalid-number: '' error.invalid-number: '数字格式不正确'
error.invalid-character-unescaped: '' error.invalid-character-unescaped: '包含未转义的无效字符'
admin.invitations: '' admin.invitations: '邀请'
admin.invitations.create: '' admin.invitations.create: '创建邀请'
admin.actions.sync-previews: '' admin.actions.sync-previews: '同步所有 gists 预览'
admin.actions.reset-hooks: '' admin.actions.reset-hooks: '重置所有存储库的 Git 服务hooks'
admin.actions.index-gists: '' admin.actions.index-gists: '索引所有 gists'
admin.invitations.help: '' admin.invitations.help: '即使在禁用注册功能的情况下,邀请功能也可用于创建帐户。'
admin.invitations.max_uses: '' admin.invitations.max_uses: '最多使用次数'
admin.invitations.expires_at: '' admin.invitations.expires_at: '过期时间'
admin.invitations.code: '' admin.invitations.code: '邀请码'
admin.invitations.copy_link: '' admin.invitations.copy_link: '复制链接'
admin.invitations.uses: '' admin.invitations.uses: '使用次数'
admin.invitations.expired: '' admin.invitations.expired: '已到期'
flash.admin.user-deleted: '' flash.admin.user-deleted: '用户已删除'
flash.admin.gist-deleted: '' flash.admin.gist-deleted: 'Gist 已删除'
flash.admin.invitation-created: '' flash.admin.invitation-created: '该邀请已被创建'
flash.admin.invitation-deleted: '' flash.admin.invitation-deleted: '该邀请已被删除'
flash.admin.sync-fs: '' flash.admin.sync-fs: '正在从文件系统同步存储库...'
flash.admin.sync-db: '' flash.admin.sync-db: '正在从数据库同步存储库...'
flash.admin.git-gc: '' flash.admin.git-gc: '正在进行存储库垃圾回收...'
flash.admin.sync-previews: '' flash.admin.sync-previews: '正在同步 Gist 预览...'
flash.admin.reset-hooks: '' flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...'
flash.admin.index-gists: '' flash.admin.index-gists: '索引所有 gists...'
flash.auth.username-exists: '' flash.auth.username-exists: '用户名已存在'
flash.auth.invalid-credentials: '' flash.auth.invalid-credentials: '无效的凭证'
flash.auth.account-linked-oauth: '' flash.auth.account-linked-oauth: '帐户已关联到 %s'
flash.auth.account-unlinked-oauth: '' flash.auth.account-unlinked-oauth: '帐户与 %s 解除关联'
flash.auth.user-sshkeys-not-retrievable: '' flash.auth.user-sshkeys-not-retrievable: '无法获取用户密钥'
flash.auth.user-sshkeys-not-created: '' flash.auth.user-sshkeys-not-created: '无法创建 ssh 密钥'
flash.auth.must-be-logged-in: '' flash.auth.must-be-logged-in: '您必须登录才能访问 gists'
flash.gist.visibility-changed: '' flash.gist.visibility-changed: 'Gist可见性已更改'
flash.gist.deleted: '' flash.gist.deleted: 'Gist已被删除'
flash.gist.fork-own-gist: '' flash.gist.fork-own-gist: '无法派生自己的要点'
flash.gist.forked: '' flash.gist.forked: 'Gist 已被派生'
flash.user.email-updated: '' flash.user.email-updated: '电子邮件已更新'
flash.user.invalid-ssh-key: '' flash.user.invalid-ssh-key: 'SSH 密钥无效'
flash.user.ssh-key-added: '' flash.user.ssh-key-added: 'SSH 密钥已添加'
flash.user.ssh-key-deleted: '' flash.user.ssh-key-deleted: 'SSH 密钥已删除'
flash.user.password-updated: '' flash.user.password-updated: '密码已更新'
flash.user.username-updated: '' flash.user.username-updated: '用户名已更新'
validation.is-too-long: '' validation.is-too-long: '字段 %s 太长'
validation.should-not-be-empty: '' validation.should-not-be-empty: '字段 %s 不能为空'
validation.should-not-include-sub-directory: '' validation.should-not-include-sub-directory: '字段 %s 不应包含子目录'
validation.should-only-contain-alphanumeric-characters: '' validation.should-only-contain-alphanumeric-characters: '字段 %s 只能包含字母、数字、字符'
validation.should-only-contain-alphanumeric-characters-and-dashes: '' validation.should-only-contain-alphanumeric-characters-and-dashes: '字段 %s 应仅包含字母、数字、字符和 -'
validation.not-enough: '' validation.not-enough: '还不够 %s'
validation.invalid: '' validation.invalid: '无效 %s'
html.title.admin-panel: '' html.title.admin-panel: '管理面板'
settings.ssh-key-exists: SSH 密钥已经存在
auth.mfa.passkey-name: 密钥名称
auth.mfa.use-passkey-to-finish: 使用密钥完成身份验证
auth.mfa.delete-passkey: 删除密钥
auth.mfa.passkey-added-at: 密钥已添加
auth.mfa.passkey-never-used: 密钥从未使用
auth.mfa.passkey-last-used: 密钥上次使用时间
auth.mfa.delete-passkey-confirm: 确认删除密钥
error.not-in-mfa-session: 用户不在 MFA 会话中
auth.mfa.waiting-for-passkey-input: 等待浏览器的交互输入...
auth.mfa.passkeys-help: 添加密钥以登录您的帐户并用于 MFA 方式。
flash.auth.passkey-registred: 密钥 %s 已注册
flash.auth.passkey-deleted: 密钥已删除
auth.mfa.use-passkey: 使用通行密钥
auth.mfa.bind-passkey: 绑定通行密钥
auth.mfa.login-with-passkey: 使用通行密钥登录
auth.mfa: 多因素认证
auth.mfa.passkey: 通行密钥
auth.mfa.passkeys: 通行密钥

View File

@@ -3,6 +3,7 @@ package render
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/base64"
"fmt" "fmt"
"github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/formatters/html"
@@ -11,6 +12,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"path"
"sync" "sync"
) )
@@ -28,18 +30,22 @@ type RenderedGist struct {
} }
func HighlightFile(file *git.File) (RenderedFile, error) { func HighlightFile(file *git.File) (RenderedFile, error) {
rendered := RenderedFile{
File: file,
}
style := newStyle() style := newStyle()
lexer := newLexer(file.Filename) lexer := newLexer(file.Filename)
if lexer.Config().Name == "markdown" { if lexer.Config().Name == "markdown" {
return MarkdownFile(file) return MarkdownFile(file)
} }
if lexer.Config().Name == "XML" && path.Ext(file.Filename) == ".svg" {
return RenderSvgFile(file), nil
}
formatter := html.New(html.WithClasses(true), html.PreventSurroundingPre(true)) formatter := html.New(html.WithClasses(true), html.PreventSurroundingPre(true))
rendered := RenderedFile{
File: file,
}
iterator, err := lexer.Tokenise(nil, file.Content+"\n") iterator, err := lexer.Tokenise(nil, file.Content+"\n")
if err != nil { if err != nil {
return rendered, err return rendered, err
@@ -140,6 +146,20 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
return rendered, err return rendered, err
} }
func RenderSvgFile(file *git.File) RenderedFile {
rendered := RenderedFile{
File: file,
}
encoded := base64.StdEncoding.EncodeToString([]byte(file.Content))
content := `<img src="data:image/svg+xml;base64,` + encoded + `" />`
rendered.HTML = content
rendered.Type = "SVG"
return rendered
}
func parseFileTypeName(config chroma.Config) string { func parseFileTypeName(config chroma.Config) string {
fileType := config.Name fileType := config.Name
if fileType == "fallback" || fileType == "plaintext" { if fileType == "fallback" || fileType == "plaintext" {

View File

@@ -1,24 +1,17 @@
package render package render
import ( import (
"bufio"
"bytes" "bytes"
"github.com/Kunde21/markdownfmt/v3"
"github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
emoji "github.com/yuin/goldmark-emoji" emoji "github.com/yuin/goldmark-emoji"
highlighting "github.com/yuin/goldmark-highlighting/v2" highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
astex "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
"go.abhg.dev/goldmark/mermaid" "go.abhg.dev/goldmark/mermaid"
"strconv"
) )
func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) { func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
@@ -33,7 +26,7 @@ func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
func MarkdownFile(file *git.File) (RenderedFile, error) { func MarkdownFile(file *git.File) (RenderedFile, error) {
var buf bytes.Buffer var buf bytes.Buffer
err := newMarkdown().Convert([]byte(file.Content), &buf) err := newMarkdownWithSvgExtension().Convert([]byte(file.Content), &buf)
return RenderedFile{ return RenderedFile{
File: file, File: file,
@@ -43,77 +36,34 @@ func MarkdownFile(file *git.File) (RenderedFile, error) {
} }
func MarkdownString(content string) (string, error) { func MarkdownString(content string) (string, error) {
var buf bytes.Buffer var buf bytes.Buffer
err := newMarkdown().Convert([]byte(content), &buf) err := newMarkdownWithSvgExtension().Convert([]byte(content), &buf)
return buf.String(), err return buf.String(), err
} }
func newMarkdown() goldmark.Markdown { func newMarkdown(extraExtensions ...goldmark.Extender) goldmark.Markdown {
return goldmark.New( extensions := []goldmark.Extender{
goldmark.WithExtensions( extension.GFM,
extension.GFM, highlighting.NewHighlighting(
highlighting.NewHighlighting( highlighting.WithStyle("catppuccin-latte"),
highlighting.WithStyle("catppuccin-latte"), highlighting.WithFormatOptions(html.WithClasses(true)),
highlighting.WithFormatOptions(html.WithClasses(true))),
emoji.Emoji,
&mermaid.Extender{},
), ),
emoji.Emoji,
&mermaid.Extender{},
}
extensions = append(extensions, extraExtensions...)
return goldmark.New(
goldmark.WithExtensions(extensions...),
goldmark.WithParserOptions( goldmark.WithParserOptions(
parser.WithASTTransformers( parser.WithASTTransformers(
util.Prioritized(&CheckboxTransformer{}, 10000), util.Prioritized(&checkboxTransformer{}, 10000),
), ),
), ),
) )
} }
type CheckboxTransformer struct{} func newMarkdownWithSvgExtension() goldmark.Markdown {
return newMarkdown(&svgToImgBase64{})
func (t *CheckboxTransformer) Transform(node *ast.Document, _ text.Reader, _ parser.Context) {
i := 1
err := ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if _, ok := n.(*astex.TaskCheckBox); ok {
listitem := n.Parent().Parent()
listitem.SetAttribute([]byte("data-checkbox-nb"), []byte(strconv.Itoa(i)))
i += 1
}
}
return ast.WalkContinue, nil
})
if err != nil {
log.Err(err)
}
}
func Checkbox(content string, checkboxNb int) (string, error) {
buf := bytes.Buffer{}
w := bufio.NewWriter(&buf)
source := []byte(content)
markdown := markdownfmt.NewGoldmark()
reader := text.NewReader(source)
document := markdown.Parser().Parse(reader)
i := 1
err := ast.Walk(document, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if listItem, ok := n.(*astex.TaskCheckBox); ok {
if i == checkboxNb {
listItem.IsChecked = !listItem.IsChecked
}
i += 1
}
}
return ast.WalkContinue, nil
})
if err != nil {
return "", err
}
if err = markdown.Renderer().Render(w, source, document); err != nil {
return "", err
}
_ = w.Flush()
return buf.String(), nil
} }

View File

@@ -0,0 +1,65 @@
package render
import (
"bufio"
"bytes"
"github.com/Kunde21/markdownfmt/v3"
"github.com/rs/zerolog/log"
"github.com/yuin/goldmark/ast"
astex "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"strconv"
)
type checkboxTransformer struct{}
func (t *checkboxTransformer) Transform(node *ast.Document, _ text.Reader, _ parser.Context) {
i := 1
err := ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if _, ok := n.(*astex.TaskCheckBox); ok {
listitem := n.Parent().Parent()
listitem.SetAttribute([]byte("data-checkbox-nb"), []byte(strconv.Itoa(i)))
i += 1
}
}
return ast.WalkContinue, nil
})
if err != nil {
log.Err(err)
}
}
func Checkbox(content string, checkboxNb int) (string, error) {
buf := bytes.Buffer{}
w := bufio.NewWriter(&buf)
source := []byte(content)
markdown := markdownfmt.NewGoldmark()
reader := text.NewReader(source)
document := markdown.Parser().Parse(reader)
i := 1
err := ast.Walk(document, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if listItem, ok := n.(*astex.TaskCheckBox); ok {
if i == checkboxNb {
listItem.IsChecked = !listItem.IsChecked
}
i += 1
}
}
return ast.WalkContinue, nil
})
if err != nil {
return "", err
}
if err = markdown.Renderer().Render(w, source, document); err != nil {
return "", err
}
_ = w.Flush()
return buf.String(), nil
}

View File

@@ -0,0 +1,141 @@
package render
import (
"bytes"
"encoding/base64"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"regexp"
)
var svgRegex = regexp.MustCompile(`(?i)^[ ]{0,3}<(svg)(?:\s.*|>.*|/>.*|)(?:\r\n|\n)?$`)
type svgToImgBase64 struct{}
func (e *svgToImgBase64) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithBlockParsers(
util.Prioritized(newSvgParser(), 1),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(newSvgRenderer(), 1),
))
}
// -- SVG Block -- //
type svgBlock struct {
ast.BaseBlock
}
func (n *svgBlock) IsRaw() bool {
return true
}
func (n *svgBlock) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, nil, nil)
}
var svgBlockKind = ast.NewNodeKind("SVG")
func (n *svgBlock) Kind() ast.NodeKind {
return svgBlockKind
}
func newSvgBlock() *svgBlock {
return &svgBlock{
BaseBlock: ast.BaseBlock{},
}
}
// -- SVG Parser -- //
type svgParser struct {
}
var defaultSvgParser = &svgParser{}
func newSvgParser() parser.BlockParser {
return defaultSvgParser
}
func (b *svgParser) Trigger() []byte {
return []byte{'<'}
}
func (b *svgParser) Open(parent ast.Node, reader text.Reader, _ parser.Context) (ast.Node, parser.State) {
var node *svgBlock
line, segment := reader.PeekLine()
if !bytes.HasPrefix(line, []byte("<svg")) {
return nil, parser.None
}
if svgRegex.Match(line) {
node = newSvgBlock()
}
if node != nil {
reader.Advance(segment.Len() - util.TrimRightSpaceLength(line))
node.Lines().Append(segment)
return node, parser.NoChildren
}
return nil, parser.None
}
func (b *svgParser) Continue(node ast.Node, reader text.Reader, _ parser.Context) parser.State {
line, segment := reader.PeekLine()
if util.IsBlank(line) {
return parser.Close
}
if !bytes.HasSuffix(util.TrimRightSpace(line), []byte("</svg>")) {
node.Lines().Append(segment)
return parser.Continue | parser.NoChildren
}
node.Lines().Append(segment)
reader.Advance(segment.Len())
return parser.Close
}
func (b *svgParser) Close(_ ast.Node, _ text.Reader, _ parser.Context) {}
func (b *svgParser) CanInterruptParagraph() bool {
return true
}
func (b *svgParser) CanAcceptIndentedLine() bool {
return false
}
// -- SVG Renderer -- //
type svgRenderer struct{}
func newSvgRenderer() renderer.NodeRenderer {
return &svgRenderer{}
}
func (r *svgRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(svgBlockKind, r.renderSVG)
}
func (r *svgRenderer) renderSVG(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
rawHTML := node.(*svgBlock)
var svgContent []byte
for i := 0; i < rawHTML.Lines().Len(); i++ {
segment := rawHTML.Lines().At(i)
svgContent = append(svgContent, segment.Value(source)...)
}
encoded := base64.StdEncoding.EncodeToString(svgContent)
imgTag := `<img src="data:image/svg+xml;base64,` + encoded + `" />`
_, _ = w.Write([]byte(imgTag))
return ast.WalkContinue, nil
}

View File

@@ -39,7 +39,7 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error {
return errors.New("gist not found") return errors.New("gist not found")
} }
allowUnauthenticated, err := auth.ShouldAllowUnauthenticatedGistAccess(db.DBAuthInfo{}, true) allowUnauthenticated, err := auth.ShouldAllowUnauthenticatedGistAccess(db.AuthInfo{}, true)
if err != nil { if err != nil {
return errors.New("internal server error") return errors.New("internal server error")
} }

46
internal/utils/aes.go Normal file
View File

@@ -0,0 +1,46 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"io"
)
func AESEncrypt(key, text []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
ciphertext := make([]byte, aes.BlockSize+len(text))
iv := ciphertext[:aes.BlockSize]
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
return nil, err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(ciphertext[aes.BlockSize:], text)
return ciphertext, nil
}
func AESDecrypt(key, ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(ciphertext) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short")
}
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(ciphertext, ciphertext)
return ciphertext, nil
}

View File

@@ -6,10 +6,12 @@ import (
"os" "os"
) )
func ReadKey(filePath string) []byte { // GenerateSecretKey generates a new secret key for sessions
// Returns the key and a boolean indicating if the key was generated
func GenerateSecretKey(filePath string) ([]byte, bool) {
key, err := os.ReadFile(filePath) key, err := os.ReadFile(filePath)
if err == nil { if err == nil {
return key return key, false
} }
key = securecookie.GenerateRandomKey(32) key = securecookie.GenerateRandomKey(32)
@@ -22,5 +24,5 @@ func ReadKey(filePath string) []byte {
log.Fatal().Err(err).Msgf("Failed to save the key to %s", filePath) log.Fatal().Err(err).Msgf("Failed to save the key to %s", filePath)
} }
return key return key, true
} }

View File

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

View File

@@ -161,6 +161,9 @@ func adminConfig(ctx echo.Context) error {
setData(ctx, "htmlTitle", trH(ctx, "admin.configuration")+" - "+trH(ctx, "admin.admin_panel")) setData(ctx, "htmlTitle", trH(ctx, "admin.configuration")+" - "+trH(ctx, "admin.admin_panel"))
setData(ctx, "adminHeaderPage", "config") setData(ctx, "adminHeaderPage", "config")
setData(ctx, "dbtype", db.DatabaseInfo.Type.String())
setData(ctx, "dbname", db.DatabaseInfo.Database)
return html(ctx, "admin_config.html") return html(ctx, "admin_config.html")
} }

View File

@@ -1,9 +1,10 @@
package web package web
import ( import (
"bytes"
"context" "context"
"crypto/md5" "crypto/md5"
"encoding/json" gojson "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@@ -14,6 +15,8 @@ import (
"github.com/markbates/goth/providers/gitlab" "github.com/markbates/goth/providers/gitlab"
"github.com/markbates/goth/providers/openidConnect" "github.com/markbates/goth/providers/openidConnect"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/totp"
"github.com/thomiceli/opengist/internal/auth/webauthn"
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n" "github.com/thomiceli/opengist/internal/i18n"
@@ -166,6 +169,18 @@ func processLogin(ctx echo.Context) error {
return redirect(ctx, "/login") return redirect(ctx, "/login")
} }
// handle MFA
var hasWebauthn, hasTotp bool
if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
return errorRes(500, "Cannot check for user MFA", err)
}
if hasWebauthn || hasTotp {
sess.Values["mfaID"] = user.ID
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return redirect(ctx, "/mfa")
}
sess.Values["user"] = user.ID sess.Values["user"] = user.ID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
saveSession(sess, ctx) saveSession(sess, ctx)
@@ -174,6 +189,22 @@ func processLogin(ctx echo.Context) error {
return redirect(ctx, "/") return redirect(ctx, "/")
} }
func mfa(ctx echo.Context) error {
var err error
user := db.User{ID: getSession(ctx).Values["mfaID"].(uint)}
var hasWebauthn, hasTotp bool
if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
return errorRes(500, "Cannot check for user MFA", err)
}
setData(ctx, "hasWebauthn", hasWebauthn)
setData(ctx, "hasTotp", hasTotp)
return html(ctx, "mfa.html")
}
func oauthCallback(ctx echo.Context) error { func oauthCallback(ctx echo.Context) error {
user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request()) user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request())
if err != nil { if err != nil {
@@ -204,6 +235,10 @@ func oauthCallback(ctx echo.Context) error {
return errorRes(500, "Cannot get user", err) return errorRes(500, "Cannot get user", err)
} }
if user.NickName == "" {
user.NickName = strings.Split(user.Email, "@")[0]
}
userDB = &db.User{ userDB = &db.User{
Username: user.NickName, Username: user.NickName,
Email: user.Email, Email: user.Email,
@@ -284,6 +319,20 @@ func oauth(ctx echo.Context) error {
httpProtocol = "https" httpProtocol = "https"
} }
forwarded_hdr := ctx.Request().Header.Get("Forwarded")
if forwarded_hdr != "" {
fields := strings.Split(forwarded_hdr, ";")
fwd := make(map[string]string)
for _, v := range fields {
p := strings.Split(v, "=")
fwd[p[0]] = p[1]
}
val, ok := fwd["proto"]
if ok && val == "https" {
httpProtocol = "https"
}
}
var opengistUrl string var opengistUrl string
if config.C.ExternalUrl != "" { if config.C.ExternalUrl != "" {
opengistUrl = config.C.ExternalUrl opengistUrl = config.C.ExternalUrl
@@ -342,28 +391,6 @@ func oauth(ctx echo.Context) error {
goth.UseProviders(oidcProvider) goth.UseProviders(oidcProvider)
} }
currUser := getUserLogged(ctx)
if currUser != nil {
// Map each provider to a function that checks the relevant ID in currUser
providerIDCheckMap := map[string]func() bool{
GitHubProvider: func() bool { return currUser.GithubID != "" },
GitLabProvider: func() bool { return currUser.GitlabID != "" },
GiteaProvider: func() bool { return currUser.GiteaID != "" },
OpenIDConnect: func() bool { return currUser.OIDCID != "" },
}
// Check if the provider is valid and if the user has a linked ID
// Means that the user wants to unlink the account
if checkFunc, exists := providerIDCheckMap[provider]; exists && checkFunc() {
if err := currUser.DeleteProviderID(provider); err != nil {
return errorRes(500, "Cannot unlink account from "+cases.Title(language.English).String(provider), err)
}
addFlash(ctx, tr(ctx, "flash.auth.account-unlinked-oauth", cases.Title(language.English).String(provider)), "success")
return redirect(ctx, "/settings")
}
}
ctxValue := context.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, provider) ctxValue := context.WithValue(ctx.Request().Context(), gothic.ProviderParamKey, provider)
ctx.SetRequest(ctx.Request().WithContext(ctxValue)) ctx.SetRequest(ctx.Request().WithContext(ctxValue))
if provider != GitHubProvider && provider != GitLabProvider && provider != GiteaProvider && provider != OpenIDConnect { if provider != GitHubProvider && provider != GitLabProvider && provider != GiteaProvider && provider != OpenIDConnect {
@@ -374,6 +401,340 @@ func oauth(ctx echo.Context) error {
return nil return nil
} }
func oauthUnlink(ctx echo.Context) error {
provider := ctx.Param("provider")
currUser := getUserLogged(ctx)
// Map each provider to a function that checks the relevant ID in currUser
providerIDCheckMap := map[string]func() bool{
GitHubProvider: func() bool { return currUser.GithubID != "" },
GitLabProvider: func() bool { return currUser.GitlabID != "" },
GiteaProvider: func() bool { return currUser.GiteaID != "" },
OpenIDConnect: func() bool { return currUser.OIDCID != "" },
}
if checkFunc, exists := providerIDCheckMap[provider]; exists && checkFunc() {
if err := currUser.DeleteProviderID(provider); err != nil {
return errorRes(500, "Cannot unlink account from "+cases.Title(language.English).String(provider), err)
}
addFlash(ctx, tr(ctx, "flash.auth.account-unlinked-oauth", cases.Title(language.English).String(provider)), "success")
return redirect(ctx, "/settings")
}
return redirect(ctx, "/settings")
}
func beginWebAuthnBinding(ctx echo.Context) error {
credsCreation, jsonWaSession, err := webauthn.BeginBinding(getUserLogged(ctx))
if err != nil {
return errorRes(500, "Cannot begin WebAuthn registration", err)
}
sess := getSession(ctx)
sess.Values["webauthn_registration_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return ctx.JSON(200, credsCreation)
}
func finishWebAuthnBinding(ctx echo.Context) error {
sess := getSession(ctx)
jsonWaSession, ok := sess.Values["webauthn_registration_session"].([]byte)
if !ok {
return jsonErrorRes(401, "Cannot get WebAuthn registration session", nil)
}
user := getUserLogged(ctx)
// extract passkey name from request
body, err := io.ReadAll(ctx.Request().Body)
if err != nil {
return jsonErrorRes(400, "Failed to read request body", err)
}
ctx.Request().Body.Close()
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(body))
dto := new(db.CrendentialDTO)
_ = gojson.Unmarshal(body, &dto)
if err = ctx.Validate(dto); err != nil {
return jsonErrorRes(400, "Invalid request", err)
}
passkeyName := dto.PasskeyName
if passkeyName == "" {
passkeyName = "WebAuthn"
}
waCredential, err := webauthn.FinishBinding(user, jsonWaSession, ctx.Request())
if err != nil {
return jsonErrorRes(403, "Failed binding attempt for passkey", err)
}
if _, err = db.CreateFromCrendential(user.ID, passkeyName, waCredential); err != nil {
return jsonErrorRes(500, "Cannot create WebAuthn credential on database", err)
}
delete(sess.Values, "webauthn_registration_session")
saveSession(sess, ctx)
addFlash(ctx, tr(ctx, "flash.auth.passkey-registred", passkeyName), "success")
return json(ctx, []string{"OK"})
}
func beginWebAuthnLogin(ctx echo.Context) error {
credsCreation, jsonWaSession, err := webauthn.BeginDiscoverableLogin()
if err != nil {
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
}
sess := getSession(ctx)
sess.Values["webauthn_login_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return json(ctx, credsCreation)
}
func finishWebAuthnLogin(ctx echo.Context) error {
sess := getSession(ctx)
sessionData, ok := sess.Values["webauthn_login_session"].([]byte)
if !ok {
return jsonErrorRes(401, "Cannot get WebAuthn login session", nil)
}
userID, err := webauthn.FinishDiscoverableLogin(sessionData, ctx.Request())
if err != nil {
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
}
sess.Values["user"] = userID
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "webauthn_login_session")
saveSession(sess, ctx)
return json(ctx, []string{"OK"})
}
func beginWebAuthnAssertion(ctx echo.Context) error {
sess := getSession(ctx)
ogUser, err := db.GetUserById(sess.Values["mfaID"].(uint))
if err != nil {
return jsonErrorRes(500, "Cannot get user", err)
}
credsCreation, jsonWaSession, err := webauthn.BeginLogin(ogUser)
if err != nil {
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
}
sess.Values["webauthn_assertion_session"] = jsonWaSession
sess.Options.MaxAge = 5 * 60 // 5 minutes
saveSession(sess, ctx)
return json(ctx, credsCreation)
}
func finishWebAuthnAssertion(ctx echo.Context) error {
sess := getSession(ctx)
sessionData, ok := sess.Values["webauthn_assertion_session"].([]byte)
if !ok {
return jsonErrorRes(401, "Cannot get WebAuthn assertion session", nil)
}
userId := sess.Values["mfaID"].(uint)
ogUser, err := db.GetUserById(userId)
if err != nil {
return jsonErrorRes(500, "Cannot get user", err)
}
if err = webauthn.FinishLogin(ogUser, sessionData, ctx.Request()); err != nil {
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
}
sess.Values["user"] = userId
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "webauthn_assertion_session")
delete(sess.Values, "mfaID")
saveSession(sess, ctx)
return json(ctx, []string{"OK"})
}
func beginTotp(ctx echo.Context) error {
user := getUserLogged(ctx)
if _, hasTotp, err := user.HasMFA(); err != nil {
return errorRes(500, "Cannot check for user MFA", err)
} else if hasTotp {
addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error")
return redirect(ctx, "/settings")
}
ogUrl, err := url.Parse(getData(ctx, "baseHttpUrl").(string))
if err != nil {
return errorRes(500, "Cannot parse base URL", err)
}
sess := getSession(ctx)
generatedSecret, _ := sess.Values["generatedSecret"].([]byte)
totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(getUserLogged(ctx).Username, ogUrl.Hostname(), generatedSecret)
if err != nil {
return errorRes(500, "Cannot generate TOTP QR code", err)
}
sess.Values["totpSecret"] = totpSecret
sess.Values["generatedSecret"] = generatedSecret
saveSession(sess, ctx)
setData(ctx, "totpSecret", totpSecret)
setData(ctx, "totpQrcode", qrcode)
return html(ctx, "totp.html")
}
func finishTotp(ctx echo.Context) error {
user := getUserLogged(ctx)
if _, hasTotp, err := user.HasMFA(); err != nil {
return errorRes(500, "Cannot check for user MFA", err)
} else if hasTotp {
addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error")
return redirect(ctx, "/settings")
}
dto := &db.TOTPDTO{}
if err := ctx.Bind(dto); err != nil {
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
}
if err := ctx.Validate(dto); err != nil {
addFlash(ctx, "Invalid secret", "error")
return redirect(ctx, "/settings/totp/generate")
}
sess := getSession(ctx)
secret, ok := sess.Values["totpSecret"].(string)
if !ok {
return errorRes(500, "Cannot get TOTP secret from session", nil)
}
if !totp.Validate(dto.Code, secret) {
addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
return redirect(ctx, "/settings/totp/generate")
}
userTotp := &db.TOTP{
UserID: getUserLogged(ctx).ID,
}
if err := userTotp.StoreSecret(secret); err != nil {
return errorRes(500, "Cannot store TOTP secret", err)
}
if err := userTotp.Create(); err != nil {
return errorRes(500, "Cannot create TOTP", err)
}
addFlash(ctx, "TOTP successfully enabled", "success")
codes, err := userTotp.GenerateRecoveryCodes()
if err != nil {
return errorRes(500, "Cannot generate recovery codes", err)
}
delete(sess.Values, "totpSecret")
delete(sess.Values, "generatedSecret")
saveSession(sess, ctx)
setData(ctx, "recoveryCodes", codes)
return html(ctx, "totp.html")
}
func assertTotp(ctx echo.Context) error {
var err error
dto := &db.TOTPDTO{}
if err := ctx.Bind(dto); err != nil {
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
}
if err := ctx.Validate(dto); err != nil {
addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
return redirect(ctx, "/mfa")
}
sess := getSession(ctx)
userId := sess.Values["mfaID"].(uint)
var userTotp *db.TOTP
if userTotp, err = db.GetTOTPByUserID(userId); err != nil {
return errorRes(500, "Cannot get TOTP by UID", err)
}
redirectUrl := "/"
var validCode, validRecoveryCode bool
if validCode, err = userTotp.ValidateCode(dto.Code); err != nil {
return errorRes(500, "Cannot validate TOTP code", err)
}
if !validCode {
validRecoveryCode, err = userTotp.ValidateRecoveryCode(dto.Code)
if err != nil {
return errorRes(500, "Cannot validate TOTP code", err)
}
if !validRecoveryCode {
addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
return redirect(ctx, "/mfa")
}
addFlash(ctx, tr(ctx, "auth.totp.code-used", dto.Code), "warning")
redirectUrl = "/settings"
}
sess.Values["user"] = userId
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
delete(sess.Values, "mfaID")
saveSession(sess, ctx)
return redirect(ctx, redirectUrl)
}
func disableTotp(ctx echo.Context) error {
user := getUserLogged(ctx)
userTotp, err := db.GetTOTPByUserID(user.ID)
if err != nil {
return errorRes(500, "Cannot get TOTP by UID", err)
}
if err = userTotp.Delete(); err != nil {
return errorRes(500, "Cannot delete TOTP", err)
}
addFlash(ctx, tr(ctx, "auth.totp.disabled"), "success")
return redirect(ctx, "/settings")
}
func regenerateTotpRecoveryCodes(ctx echo.Context) error {
user := getUserLogged(ctx)
userTotp, err := db.GetTOTPByUserID(user.ID)
if err != nil {
return errorRes(500, "Cannot get TOTP by UID", err)
}
codes, err := userTotp.GenerateRecoveryCodes()
if err != nil {
return errorRes(500, "Cannot generate recovery codes", err)
}
setData(ctx, "recoveryCodes", codes)
return html(ctx, "totp.html")
}
func logout(ctx echo.Context) error { func logout(ctx echo.Context) error {
deleteSession(ctx) deleteSession(ctx)
deleteCsrfCookie(ctx) deleteCsrfCookie(ctx)
@@ -425,7 +786,7 @@ func getAvatarUrlFromProvider(provider string, identifier string) string {
} }
var result map[string]interface{} var result map[string]interface{}
err = json.Unmarshal(body, &result) err = gojson.Unmarshal(body, &result)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Cannot unmarshal Gitea response body") log.Error().Err(err).Msg("Cannot unmarshal Gitea response body")
return "" return ""

View File

@@ -2,7 +2,7 @@ package web
import ( import (
"context" "context"
"encoding/json" gojson "encoding/json"
"errors" "errors"
"fmt" "fmt"
htmlpkg "html" htmlpkg "html"
@@ -65,6 +65,9 @@ var (
"isCsv": func(i string) bool { "isCsv": func(i string) bool {
return strings.ToLower(filepath.Ext(i)) == ".csv" return strings.ToLower(filepath.Ext(i)) == ".csv"
}, },
"isSvg": func(i string) bool {
return strings.ToLower(filepath.Ext(i)) == ".svg"
},
"csvFile": func(file *git.File) *git.CsvFile { "csvFile": func(file *git.File) *git.CsvFile {
if strings.ToLower(filepath.Ext(file.Filename)) != ".csv" { if strings.ToLower(filepath.Ext(file.Filename)) != ".csv" {
return nil return nil
@@ -161,13 +164,11 @@ type Server struct {
dev bool dev bool
} }
func NewServer(isDev bool, sessionsPath string) *Server { func NewServer(isDev bool, sessionsPath string, ignoreCsrf bool) *Server {
dev = isDev dev = isDev
flashStore = sessions.NewCookieStore([]byte("opengist")) flashStore = sessions.NewCookieStore([]byte("opengist"))
userStore = sessions.NewFilesystemStore(sessionsPath, encryptKey, _ := utils.GenerateSecretKey(filepath.Join(sessionsPath, "session-encrypt.key"))
utils.ReadKey(path.Join(sessionsPath, "session-auth.key")), userStore = sessions.NewFilesystemStore(sessionsPath, config.SecretKey, encryptKey)
utils.ReadKey(path.Join(sessionsPath, "session-encrypt.key")),
)
userStore.MaxLength(10 * 1024) userStore.MaxLength(10 * 1024)
gothic.Store = userStore gothic.Store = userStore
@@ -215,10 +216,18 @@ func NewServer(isDev bool, sessionsPath string) *Server {
} }
e.HTTPErrorHandler = func(er error, ctx echo.Context) { e.HTTPErrorHandler = func(er error, ctx echo.Context) {
if err, ok := er.(*echo.HTTPError); ok { var httpErr *echo.HTTPError
setData(ctx, "error", err) if errors.As(er, &httpErr) {
if errHtml := htmlWithCode(ctx, err.Code, "error.html"); errHtml != nil { acceptJson := strings.Contains(ctx.Request().Header.Get("Accept"), "application/json")
log.Fatal().Err(errHtml).Send() setData(ctx, "error", er)
if acceptJson {
if fatalErr := jsonWithCode(ctx, httpErr.Code, httpErr); fatalErr != nil {
log.Fatal().Err(fatalErr).Send()
}
} else {
if fatalErr := htmlWithCode(ctx, httpErr.Code, "error.html"); fatalErr != nil {
log.Fatal().Err(fatalErr).Send()
}
} }
} else { } else {
log.Fatal().Err(er).Send() log.Fatal().Err(er).Send()
@@ -236,9 +245,9 @@ func NewServer(isDev bool, sessionsPath string) *Server {
// Web based routes // Web based routes
g1 := e.Group("") g1 := e.Group("")
{ {
if !dev { if !ignoreCsrf {
g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "form:_csrf", TokenLookup: "form:_csrf,header:X-CSRF-Token",
CookiePath: "/", CookiePath: "/",
CookieHTTPOnly: true, CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode, CookieSameSite: http.SameSiteStrictMode,
@@ -248,7 +257,7 @@ func NewServer(isDev bool, sessionsPath string) *Server {
g1.GET("/", create, logged) g1.GET("/", create, logged)
g1.POST("/", processCreate, logged) g1.POST("/", processCreate, logged)
g1.GET("/preview", preview, logged) g1.POST("/preview", preview, logged)
g1.GET("/healthcheck", healthcheck) g1.GET("/healthcheck", healthcheck)
g1.GET("/metrics", metrics) g1.GET("/metrics", metrics)
@@ -260,14 +269,29 @@ func NewServer(isDev bool, sessionsPath string) *Server {
g1.GET("/logout", logout) g1.GET("/logout", logout)
g1.GET("/oauth/:provider", oauth) g1.GET("/oauth/:provider", oauth)
g1.GET("/oauth/:provider/callback", oauthCallback) g1.GET("/oauth/:provider/callback", oauthCallback)
g1.GET("/oauth/:provider/unlink", oauthUnlink, logged)
g1.POST("/webauthn/bind", beginWebAuthnBinding, logged)
g1.POST("/webauthn/bind/finish", finishWebAuthnBinding, logged)
g1.POST("/webauthn/login", beginWebAuthnLogin)
g1.POST("/webauthn/login/finish", finishWebAuthnLogin)
g1.POST("/webauthn/assertion", beginWebAuthnAssertion, inMFASession)
g1.POST("/webauthn/assertion/finish", finishWebAuthnAssertion, inMFASession)
g1.GET("/mfa", mfa, inMFASession)
g1.POST("/mfa/totp/assertion", assertTotp, inMFASession)
g1.GET("/settings", userSettings, logged) g1.GET("/settings", userSettings, logged)
g1.POST("/settings/email", emailProcess, logged) g1.POST("/settings/email", emailProcess, logged)
g1.DELETE("/settings/account", accountDeleteProcess, logged) g1.DELETE("/settings/account", accountDeleteProcess, logged)
g1.POST("/settings/ssh-keys", sshKeysProcess, logged) g1.POST("/settings/ssh-keys", sshKeysProcess, logged)
g1.DELETE("/settings/ssh-keys/:id", sshKeysDelete, logged) g1.DELETE("/settings/ssh-keys/:id", sshKeysDelete, logged)
g1.DELETE("/settings/passkeys/:id", passkeyDelete, logged)
g1.PUT("/settings/password", passwordProcess, logged) g1.PUT("/settings/password", passwordProcess, logged)
g1.PUT("/settings/username", usernameProcess, logged) g1.PUT("/settings/username", usernameProcess, logged)
g1.GET("/settings/totp/generate", beginTotp, logged)
g1.POST("/settings/totp/generate", finishTotp, logged)
g1.DELETE("/settings/totp", disableTotp, logged)
g1.POST("/settings/totp/regenerate", regenerateTotpRecoveryCodes, logged)
g2 := g1.Group("/admin-panel") g2 := g1.Group("/admin-panel")
{ {
g2.Use(adminPermission) g2.Use(adminPermission)
@@ -517,6 +541,17 @@ func logged(next echo.HandlerFunc) echo.HandlerFunc {
} }
} }
func inMFASession(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
sess := getSession(ctx)
_, ok := sess.Values["mfaID"].(uint)
if !ok {
return errorRes(400, tr(ctx, "error.not-in-mfa-session"), nil)
}
return next(ctx)
}
}
func makeCheckRequireLogin(isSingleGistAccess bool) echo.MiddlewareFunc { func makeCheckRequireLogin(isSingleGistAccess bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error { return func(ctx echo.Context) error {
@@ -563,7 +598,7 @@ func parseManifestEntries() {
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("Failed to read manifest.json") log.Fatal().Err(err).Msg("Failed to read manifest.json")
} }
if err = json.Unmarshal(byteValue, &manifestEntries); err != nil { if err = gojson.Unmarshal(byteValue, &manifestEntries); err != nil {
log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json") log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json")
} }
} }

View File

@@ -26,8 +26,20 @@ func userSettings(ctx echo.Context) error {
return errorRes(500, "Cannot get SSH keys", err) return errorRes(500, "Cannot get SSH keys", err)
} }
passkeys, err := db.GetAllCredentialsForUser(user.ID)
if err != nil {
return errorRes(500, "Cannot get WebAuthn credentials", err)
}
_, hasTotp, err := user.HasMFA()
if err != nil {
return errorRes(500, "Cannot get MFA status", err)
}
setData(ctx, "email", user.Email) setData(ctx, "email", user.Email)
setData(ctx, "sshKeys", keys) setData(ctx, "sshKeys", keys)
setData(ctx, "passkeys", passkeys)
setData(ctx, "hasTotp", hasTotp)
setData(ctx, "hasPassword", user.Password != "") setData(ctx, "hasPassword", user.Password != "")
setData(ctx, "disableForm", getData(ctx, "DisableLoginForm")) setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
setData(ctx, "htmlTitle", trH(ctx, "settings")) setData(ctx, "htmlTitle", trH(ctx, "settings"))
@@ -127,6 +139,26 @@ func sshKeysDelete(ctx echo.Context) error {
return redirect(ctx, "/settings") return redirect(ctx, "/settings")
} }
func passkeyDelete(ctx echo.Context) error {
user := getUserLogged(ctx)
keyId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
return redirect(ctx, "/settings")
}
passkey, err := db.GetCredentialByIDDB(uint(keyId))
if err != nil || passkey.UserID != user.ID {
return redirect(ctx, "/settings")
}
if err := passkey.Delete(); err != nil {
return errorRes(500, "Cannot delete passkey", err)
}
addFlash(ctx, tr(ctx, "flash.auth.passkey-deleted"), "success")
return redirect(ctx, "/settings")
}
func passwordProcess(ctx echo.Context) error { func passwordProcess(ctx echo.Context) error {
user := getUserLogged(ctx) user := getUserLogged(ctx)

View File

@@ -12,12 +12,10 @@ import (
) )
func TestRegister(t *testing.T) { func TestRegister(t *testing.T) {
setup(t) s := setup(t)
s, err := newTestServer()
require.NoError(t, err, "Failed to create test server")
defer teardown(t, s) defer teardown(t, s)
err = s.request("GET", "/", nil, 302) err := s.request("GET", "/", nil, 302)
require.NoError(t, err) require.NoError(t, err)
err = s.request("GET", "/register", nil, 200) err = s.request("GET", "/register", nil, 200)
@@ -55,12 +53,10 @@ func TestRegister(t *testing.T) {
} }
func TestLogin(t *testing.T) { func TestLogin(t *testing.T) {
setup(t) s := setup(t)
s, err := newTestServer()
require.NoError(t, err, "Failed to create test server")
defer teardown(t, s) defer teardown(t, s)
err = s.request("GET", "/login", nil, 200) err := s.request("GET", "/login", nil, 200)
require.NoError(t, err) require.NoError(t, err)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"} user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
@@ -101,15 +97,13 @@ type settingSet struct {
} }
func TestAnonymous(t *testing.T) { func TestAnonymous(t *testing.T) {
setup(t) s := setup(t)
s, err := newTestServer()
require.NoError(t, err, "Failed to create test server")
defer teardown(t, s) defer teardown(t, s)
user := db.UserDTO{Username: "thomas", Password: "azeaze"} user := db.UserDTO{Username: "thomas", Password: "azeaze"}
register(t, s, user) register(t, s, user)
err = s.request("PUT", "/admin-panel/set-config", settingSet{"require-login", "1"}, 200) err := s.request("PUT", "/admin-panel/set-config", settingSet{"require-login", "1"}, 200)
require.NoError(t, err) require.NoError(t, err)
gist1 := db.GistDTO{ gist1 := db.GistDTO{
@@ -154,9 +148,7 @@ func TestAnonymous(t *testing.T) {
} }
func TestGitOperations(t *testing.T) { func TestGitOperations(t *testing.T) {
setup(t) s := setup(t)
s, err := newTestServer()
require.NoError(t, err, "Failed to create test server")
defer teardown(t, s) defer teardown(t, s)
admin := db.UserDTO{Username: "thomas", Password: "thomas"} admin := db.UserDTO{Username: "thomas", Password: "thomas"}
@@ -178,7 +170,7 @@ func TestGitOperations(t *testing.T) {
"yeah", "yeah",
}, },
} }
err = s.request("POST", "/", gist1, 302) err := s.request("POST", "/", gist1, 302)
require.NoError(t, err) require.NoError(t, err)
gist2 := db.GistDTO{ gist2 := db.GistDTO{

View File

@@ -9,12 +9,10 @@ import (
) )
func TestGists(t *testing.T) { func TestGists(t *testing.T) {
setup(t) s := setup(t)
s, err := newTestServer()
require.NoError(t, err, "Failed to create test server")
defer teardown(t, s) defer teardown(t, s)
err = s.request("GET", "/", nil, 302) err := s.request("GET", "/", nil, 302)
require.NoError(t, err) require.NoError(t, err)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"} user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
@@ -106,9 +104,7 @@ func TestGists(t *testing.T) {
} }
func TestVisibility(t *testing.T) { func TestVisibility(t *testing.T) {
setup(t) s := setup(t)
s, err := newTestServer()
require.NoError(t, err, "Failed to create test server")
defer teardown(t, s) defer teardown(t, s)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"} user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
@@ -123,7 +119,7 @@ func TestVisibility(t *testing.T) {
Name: []string{""}, Name: []string{""},
Content: []string{"yeah"}, Content: []string{"yeah"},
} }
err = s.request("POST", "/", gist1, 302) err := s.request("POST", "/", gist1, 302)
require.NoError(t, err) require.NoError(t, err)
gist1db, err := db.GetGistByID("1") gist1db, err := db.GetGistByID("1")
@@ -150,9 +146,7 @@ func TestVisibility(t *testing.T) {
} }
func TestLikeFork(t *testing.T) { func TestLikeFork(t *testing.T) {
setup(t) s := setup(t)
s, err := newTestServer()
require.NoError(t, err, "Failed to create test server")
defer teardown(t, s) defer teardown(t, s)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"} user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
@@ -167,7 +161,7 @@ func TestLikeFork(t *testing.T) {
Name: []string{""}, Name: []string{""},
Content: []string{"yeah"}, Content: []string{"yeah"},
} }
err = s.request("POST", "/", gist1, 302) err := s.request("POST", "/", gist1, 302)
require.NoError(t, err) require.NoError(t, err)
s.sessionCookie = "" s.sessionCookie = ""
@@ -211,9 +205,7 @@ func TestLikeFork(t *testing.T) {
} }
func TestCustomUrl(t *testing.T) { func TestCustomUrl(t *testing.T) {
setup(t) s := setup(t)
s, err := newTestServer()
require.NoError(t, err, "Failed to create test server")
defer teardown(t, s) defer teardown(t, s)
user1 := db.UserDTO{Username: "thomas", Password: "thomas"} user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
@@ -229,7 +221,7 @@ func TestCustomUrl(t *testing.T) {
Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"},
Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"},
} }
err = s.request("POST", "/", gist1, 302) err := s.request("POST", "/", gist1, 302)
require.NoError(t, err) require.NoError(t, err)
gist1db, err := db.GetGistByID("1") gist1db, err := db.GetGistByID("1")

View File

@@ -24,6 +24,8 @@ import (
"github.com/thomiceli/opengist/internal/web" "github.com/thomiceli/opengist/internal/web"
) )
var databaseType string
type testServer struct { type testServer struct {
server *web.Server server *web.Server
sessionCookie string sessionCookie string
@@ -31,7 +33,7 @@ type testServer struct {
func newTestServer() (*testServer, error) { func newTestServer() (*testServer, error) {
s := &testServer{ s := &testServer{
server: web.NewServer(true, path.Join(config.GetHomeDir(), "tmp", "sessions")), server: web.NewServer(true, path.Join(config.GetHomeDir(), "tmp", "sessions"), true),
} }
go s.start() go s.start()
@@ -131,7 +133,18 @@ func structToURLValues(s interface{}) url.Values {
return v return v
} }
func setup(t *testing.T) { func setup(t *testing.T) *testServer {
var databaseDsn string
databaseType = os.Getenv("OPENGIST_TEST_DB")
switch databaseType {
case "sqlite":
databaseDsn = "file::memory:"
case "postgres":
databaseDsn = "postgres://postgres:opengist@localhost:5432/opengist_test"
case "mysql":
databaseDsn = "mysql://root:opengist@localhost:3306/opengist_test"
}
_ = os.Setenv("OPENGIST_SKIP_GIT_HOOKS", "1") _ = os.Setenv("OPENGIST_SKIP_GIT_HOOKS", "1")
err := config.InitConfig("", io.Discard) err := config.InitConfig("", io.Discard)
@@ -140,6 +153,8 @@ func setup(t *testing.T) {
err = os.MkdirAll(filepath.Join(config.GetHomeDir()), 0755) err = os.MkdirAll(filepath.Join(config.GetHomeDir()), 0755)
require.NoError(t, err, "Could not create Opengist home directory") require.NoError(t, err, "Could not create Opengist home directory")
config.SetupSecretKey()
git.ReposDirectory = path.Join("tests") git.ReposDirectory = path.Join("tests")
config.C.IndexEnabled = false config.C.IndexEnabled = false
@@ -155,23 +170,32 @@ func setup(t *testing.T) {
err = os.MkdirAll(filepath.Join(homePath, "tmp", "repos"), 0755) err = os.MkdirAll(filepath.Join(homePath, "tmp", "repos"), 0755)
require.NoError(t, err, "Could not create tmp repos directory") require.NoError(t, err, "Could not create tmp repos directory")
err = db.Setup("file::memory:", true) err = db.Setup(databaseDsn, true)
require.NoError(t, err, "Could not initialize database") require.NoError(t, err, "Could not initialize database")
if err != nil {
log.Fatal().Err(err).Msg("Could not initialize database")
}
err = memdb.Setup() err = memdb.Setup()
require.NoError(t, err, "Could not initialize in memory database") require.NoError(t, err, "Could not initialize in memory database")
// err = index.Open(filepath.Join(homePath, "testsindex", "opengist.index")) // err = index.Open(filepath.Join(homePath, "testsindex", "opengist.index"))
// require.NoError(t, err, "Could not open index") // require.NoError(t, err, "Could not open index")
s, err := newTestServer()
require.NoError(t, err, "Failed to create test server")
return s
} }
func teardown(t *testing.T, s *testServer) { func teardown(t *testing.T, s *testServer) {
s.stop() s.stop()
err := db.Close() //err := db.Close()
require.NoError(t, err, "Could not close database") //require.NoError(t, err, "Could not close database")
err = os.RemoveAll(path.Join(config.GetHomeDir(), "tests")) err := os.RemoveAll(path.Join(config.GetHomeDir(), "tests"))
require.NoError(t, err, "Could not remove repos directory") require.NoError(t, err, "Could not remove repos directory")
err = os.RemoveAll(path.Join(config.GetHomeDir(), "tmp", "repos")) err = os.RemoveAll(path.Join(config.GetHomeDir(), "tmp", "repos"))
@@ -180,6 +204,9 @@ func teardown(t *testing.T, s *testServer) {
err = os.RemoveAll(path.Join(config.GetHomeDir(), "tmp", "sessions")) err = os.RemoveAll(path.Join(config.GetHomeDir(), "tmp", "sessions"))
require.NoError(t, err, "Could not remove repos directory") require.NoError(t, err, "Could not remove repos directory")
err = db.TruncateDatabase()
require.NoError(t, err, "Could not truncate database")
// err = os.RemoveAll(path.Join(config.C.OpengistHome, "testsindex")) // err = os.RemoveAll(path.Join(config.C.OpengistHome, "testsindex"))
// require.NoError(t, err, "Could not remove repos directory") // require.NoError(t, err, "Could not remove repos directory")

View File

@@ -46,6 +46,14 @@ func htmlWithCode(ctx echo.Context, code int, template string) error {
return ctx.Render(code, template, ctx.Request().Context().Value(dataKey)) return ctx.Render(code, template, ctx.Request().Context().Value(dataKey))
} }
func json(ctx echo.Context, data any) error {
return jsonWithCode(ctx, 200, data)
}
func jsonWithCode(ctx echo.Context, code int, data any) error {
return ctx.JSON(code, data)
}
func redirect(ctx echo.Context, location string) error { func redirect(ctx echo.Context, location string) error {
return ctx.Redirect(302, config.C.ExternalUrl+location) return ctx.Redirect(302, config.C.ExternalUrl+location)
} }
@@ -67,6 +75,15 @@ func errorRes(code int, message string, err error) error {
return &echo.HTTPError{Code: code, Message: message, Internal: err} return &echo.HTTPError{Code: code, Message: message, Internal: err}
} }
func jsonErrorRes(code int, message string, err error) error {
if code >= 500 {
var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
skipLogger.Error().Err(err).Msg(message)
}
return &echo.HTTPError{Code: code, Message: message, Internal: err}
}
func getUserLogged(ctx echo.Context) *db.User { func getUserLogged(ctx echo.Context) *db.User {
user := getData(ctx, "userLogged") user := getData(ctx, "userLogged")
if user != nil { if user != nil {
@@ -80,6 +97,7 @@ func setErrorFlashes(ctx echo.Context) {
setData(ctx, "flashErrors", sess.Flashes("error")) setData(ctx, "flashErrors", sess.Flashes("error"))
setData(ctx, "flashSuccess", sess.Flashes("success")) setData(ctx, "flashSuccess", sess.Flashes("success"))
setData(ctx, "flashWarnings", sess.Flashes("warning"))
_ = sess.Save(ctx.Request(), ctx.Response()) _ = sess.Save(ctx.Request(), ctx.Response())
} }
@@ -102,14 +120,15 @@ func saveSession(sess *sessions.Session, ctx echo.Context) {
func deleteSession(ctx echo.Context) { func deleteSession(ctx echo.Context) {
sess := getSession(ctx) sess := getSession(ctx)
sess.Options.MaxAge = -1 sess.Options.MaxAge = -1
sess.Values["user"] = nil
saveSession(sess, ctx) saveSession(sess, ctx)
} }
func setCsrfHtmlForm(ctx echo.Context) { func setCsrfHtmlForm(ctx echo.Context) {
var csrf string
if csrfToken, ok := ctx.Get("csrf").(string); ok { if csrfToken, ok := ctx.Get("csrf").(string); ok {
setData(ctx, "csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrfToken+`">`)) csrf = csrfToken
} }
setData(ctx, "csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrf+`">`))
} }
func deleteCsrfCookie(ctx echo.Context) { func deleteCsrfCookie(ctx echo.Context) {

1842
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import {EditorView, gutter, keymap, lineNumbers} from "@codemirror/view"; import {EditorView, gutter, keymap, lineNumbers} from "@codemirror/view";
import {Compartment, EditorState, Facet, Line, SelectionRange} from "@codemirror/state"; import {Compartment, EditorState, Facet, Line, SelectionRange} from "@codemirror/state";
import {indentLess} from "@codemirror/commands"; import {defaultKeymap, indentLess} from "@codemirror/commands";
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
EditorView.theme({}, {dark: true}); EditorView.theme({}, {dark: true});
@@ -27,7 +27,10 @@ document.addEventListener("DOMContentLoaded", () => {
extensions: [ extensions: [
lineNumbers(), lineNumbers(),
gutter({class: "cm-mygutter"}), gutter({class: "cm-mygutter"}),
keymap.of([{key: "Tab", run: customIndentMore, shift: indentLess}]), keymap.of([
{key: "Tab", run: customIndentMore, shift: indentLess},
...defaultKeymap,
]),
indentSize.of(EditorState.tabSize.of(2)), indentSize.of(EditorState.tabSize.of(2)),
wrapMode.of([]), wrapMode.of([]),
indentType.of(txtFacet.of("space")), indentType.of(txtFacet.of("space")),
@@ -68,11 +71,16 @@ document.addEventListener("DOMContentLoaded", () => {
cmeditor!.classList.remove("hidden-important"); cmeditor!.classList.remove("hidden-important");
return; return;
} else { } else {
fetch(`${baseUrl}/preview?` + new URLSearchParams({ const formData = new FormData();
content: editor.state.doc.toString() formData.append('content', editor.state.doc.toString());
}), { let csrf = document.querySelector<HTMLInputElement>('form#create input[name="_csrf"]').value
method: 'GET', fetch(`${baseUrl}/preview`, {
method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
body: formData,
headers: {
'X-CSRF-Token': csrf
}
}).then(r => r.text()).then(r => { }).then(r => r.text()).then(r => {
let divpreview = dom.querySelector("div.preview") as HTMLElement; let divpreview = dom.querySelector("div.preview") as HTMLElement;
divpreview!.innerHTML = r; divpreview!.innerHTML = r;

4
public/embed.scss vendored
View File

@@ -107,6 +107,10 @@ dl.dl-config dd {
@apply overflow-auto whitespace-pre; @apply overflow-auto whitespace-pre;
} }
.markdown-body img {
@apply bg-transparent dark:bg-transparent;
}
.chroma.preview.markdown pre code { .chroma.preview.markdown pre code {
@apply p-4; @apply p-4;
} }

4
public/style.css vendored
View File

@@ -167,6 +167,10 @@ dl.dl-config dd {
@apply overflow-auto whitespace-pre !important; @apply overflow-auto whitespace-pre !important;
} }
.markdown-body img {
@apply bg-transparent dark:bg-transparent !important;
}
.chroma.preview.markdown pre code { .chroma.preview.markdown pre code {
@apply p-4 !important; @apply p-4 !important;
} }

View File

@@ -7,6 +7,8 @@ module.exports = {
], ],
theme: { theme: {
colors: { colors: {
transparent: 'transparent',
current: 'currentColor',
white: colors.white, white: colors.white,
black: colors.black, black: colors.black,
gray: { gray: {

View File

@@ -11,6 +11,7 @@ module.exports = {
current: 'currentColor', current: 'currentColor',
white: colors.white, white: colors.white,
black: colors.black, black: colors.black,
yellow: colors.yellow,
gray: { gray: {
50: "#EEEFF1", 50: "#EEEFF1",
100: "#DEDFE3", 100: "#DEDFE3",

View File

@@ -14,7 +14,8 @@ export default defineConfig({
'./public/editor.ts', './public/editor.ts',
'./public/admin.ts', './public/admin.ts',
'./public/gist.ts', './public/gist.ts',
'./public/embed.ts' './public/embed.ts',
'./public/webauthn.ts'
] ]
}, },
assetsInlineLimit: 0, assetsInlineLimit: 0,

182
public/webauthn.ts Normal file
View File

@@ -0,0 +1,182 @@
let loginMethod = "login"
function encodeArrayBufferToBase64Url(buffer) {
const base64 = btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function decodeBase64UrlToArrayBuffer(base64Url) {
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) {
base64 += '=';
}
const binaryString = atob(base64);
const buffer = new ArrayBuffer(binaryString.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < binaryString.length; i++) {
view[i] = binaryString.charCodeAt(i);
}
return buffer;
}
async function bindPasskey() {
// @ts-ignore
const baseUrl = window.opengist_base_url || '';
let waitText = document.getElementById("login-passkey-wait");
try {
this.classList.add('hidden');
waitText.classList.remove('hidden');
let csrf = document.querySelector<HTMLInputElement>('form#webauthn input[name="_csrf"]').value
const beginResponse = await fetch(`${baseUrl}/webauthn/bind`, {
headers: {
'Accept': 'application/json',
},
method: 'POST',
credentials: 'include',
body: new FormData(document.querySelector<HTMLFormElement>('form#webauthn'))
});
const beginData = await beginResponse.json();
beginData.publicKey.challenge = decodeBase64UrlToArrayBuffer(beginData.publicKey.challenge);
beginData.publicKey.user.id = decodeBase64UrlToArrayBuffer(beginData.publicKey.user.id);
for (const cred of beginData.publicKey.excludeCredentials ?? []) {
cred.id = decodeBase64UrlToArrayBuffer(cred.id);
}
const credential = await navigator.credentials.create({
publicKey: beginData.publicKey,
});
if (!credential || !credential.rawId || !credential.response) {
throw new Error('Credential object is missing required properties');
}
const finishResponse = await fetch(`${baseUrl}/webauthn/bind/finish`, {
method: 'POST',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': csrf
},
body: JSON.stringify({
id: credential.id,
rawId: encodeArrayBufferToBase64Url(credential.rawId),
response: {
attestationObject: encodeArrayBufferToBase64Url(credential.response.attestationObject),
clientDataJSON: encodeArrayBufferToBase64Url(credential.response.clientDataJSON),
},
type: credential.type,
passkeyname: document.querySelector<HTMLInputElement>('form#webauthn input[name="passkeyname"]').value
}),
});
const finishData = await finishResponse.json();
setTimeout(() => {
window.location.reload();
}, 100);
} catch (error) {
console.error('Error during passkey registration:', error);
waitText.classList.add('hidden');
this.classList.remove('hidden');
alert(error);
}
}
async function loginWithPasskey() {
// @ts-ignore
const baseUrl = window.opengist_base_url || '';
let waitText = document.getElementById("login-passkey-wait");
try {
this.classList.add('hidden');
waitText.classList.remove('hidden');
let csrf = document.querySelector<HTMLInputElement>('form#webauthn input[name="_csrf"]').value
const beginResponse = await fetch(`${baseUrl}/webauthn/${loginMethod}`, {
headers: {
'Accept': 'application/json',
},
method: 'POST',
credentials: 'include',
body: new FormData(document.querySelector<HTMLFormElement>('form#webauthn'))
});
const beginData = await beginResponse.json();
beginData.publicKey.challenge = decodeBase64UrlToArrayBuffer(beginData.publicKey.challenge);
if (beginData.publicKey.allowCredentials) {
beginData.publicKey.allowCredentials = beginData.publicKey.allowCredentials.map(cred => ({
...cred,
id: decodeBase64UrlToArrayBuffer(cred.id),
}));
}
const credential = await navigator.credentials.get({
publicKey: beginData.publicKey,
});
if (!credential || !credential.rawId || !credential.response) {
throw new Error('Credential object is missing required properties');
}
const finishResponse = await fetch(`${baseUrl}/webauthn/${loginMethod}/finish`, {
method: 'POST',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': csrf
},
body: JSON.stringify({
id: credential.id,
rawId: encodeArrayBufferToBase64Url(credential.rawId),
response: {
authenticatorData: encodeArrayBufferToBase64Url(credential.response.authenticatorData),
clientDataJSON: encodeArrayBufferToBase64Url(credential.response.clientDataJSON),
signature: encodeArrayBufferToBase64Url(credential.response.signature),
userHandle: encodeArrayBufferToBase64Url(credential.response.userHandle),
},
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
}),
});
const finishData = await finishResponse.json();
if (!finishResponse.ok) {
throw new Error(finishData.message || 'Unknown error');
}
setTimeout(() => {
window.location.href = '/';
}, 100);
} catch (error) {
console.error('Login error:', error);
waitText.classList.add('hidden');
this.classList.remove('hidden');
alert(error);
}
}
document.addEventListener('DOMContentLoaded', () => {
const registerButton = document.getElementById('bind-passkey-button');
if (registerButton) {
registerButton.addEventListener('click', bindPasskey);
}
if (document.documentURI.includes('/mfa')) {
loginMethod = "assertion"
}
const loginButton = document.getElementById('login-passkey-button');
if (loginButton) {
loginButton.addEventListener('click', loginWithPasskey);
}
});

View File

@@ -302,6 +302,20 @@
</div> </div>
</div> </div>
{{end}} {{end}}
{{range .flashWarnings}}
<div class="mt-4 rounded-md bg-gray-50 dark:bg-gray-800 border-l-4 border-yellow-500 dark:border-yellow-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-600 dark:text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-600 dark:text-yellow-400">{{.}}</p>
</div>
</div>
</div>
{{end}}
</div> </div>
{{ end }} {{ end }}

View File

@@ -75,9 +75,9 @@
{{ .locale.Tr "gist.header.edit" }} {{ .locale.Tr "gist.header.edit" }}
</a> </a>
</div> </div>
<form id="delete" onsubmit="return confirm('Are you sure you want to delete this gist ?')" class="ml-2 flex items-center" method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/delete"> <form id="delete" class="ml-2 flex items-center" method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/delete">
{{ .csrfHtml }} {{ .csrfHtml }}
<button type="submit" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-rose-600 dark:text-rose-400 hover:bg-rose-500 hover:text-white dark:hover:bg-rose-600 hover:border-rose-600 dark:hover:border-rose-700 dark:hover:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"> <button type="submit" onclick="return confirm('{{ .locale.Tr "gist.delete.confirm" }}')" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-rose-600 dark:text-rose-400 hover:bg-rose-500 hover:text-white dark:hover:bg-rose-600 hover:border-rose-600 dark:hover:border-rose-700 dark:hover:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>

View File

@@ -3,7 +3,7 @@
<div class="grid gap-4 grid-cols-1 md:grid-cols-2"> <div class="grid gap-4 grid-cols-1 md:grid-cols-2">
<div class="p-6 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700"> <div class="p-6 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
<p class="italic text-xs text-gray-400 dark:text-gray-400 mb-4">{{ .locale.Tr "admin.config-link" (join "<a target=\"_blank\" href=\"https://github.com/thomiceli/opengist/blob/master/docs/configuration/index.md#configuration\">" (toStr (.locale.Tr "admin.config-link-overriden")) "</a>") }}</p> <p class="italic text-xs text-gray-400 dark:text-gray-400 mb-4">{{ .locale.Tr "admin.config-link" (join "<a target=\"_blank\" href=\"https://github.com/thomiceli/opengist/blob/master/docs/configuration/configure.md#configuration\">" (toStr (.locale.Tr "admin.config-link-overriden")) "</a>") }}</p>
<dl class="dl-config"> <dl class="dl-config">
<div class="relative col-span-3"> <div class="relative col-span-3">
<div class="absolute inset-0 flex items-center" aria-hidden="true"> <div class="absolute inset-0 flex items-center" aria-hidden="true">
@@ -17,11 +17,11 @@
<dt>Log output</dt><dd>{{ .c.LogOutput }}</dd> <dt>Log output</dt><dd>{{ .c.LogOutput }}</dd>
<dt>External URL</dt><dd>{{ .c.ExternalUrl }}</dd> <dt>External URL</dt><dd>{{ .c.ExternalUrl }}</dd>
<dt>Opengist home</dt><dd>{{ .c.OpengistHome }}</dd> <dt>Opengist home</dt><dd>{{ .c.OpengistHome }}</dd>
<dt>DB filename</dt><dd>{{ .c.DBFilename }}</dd> <dt>Database type</dt><dd>{{ .dbtype }}{{ if eq .dbtype "SQLite" }} ({{ .c.SqliteJournalMode }}){{ end }}</dd>
<dt>Database name</dt><dd>{{ .dbname }}</dd>
<dt>Index Enabled</dt><dd>{{ .c.IndexEnabled }}</dd> <dt>Index Enabled</dt><dd>{{ .c.IndexEnabled }}</dd>
<dt>Index Dirname</dt><dd>{{ .c.IndexDirname }}</dd> <dt>Index Dirname</dt><dd>{{ .c.IndexDirname }}</dd>
<dt>Git default branch</dt><dd>{{ .c.GitDefaultBranch }}</dd> <dt>Git default branch</dt><dd>{{ .c.GitDefaultBranch }}</dd>
<dt>SQLite Journal Mode</dt><dd>{{ .c.SqliteJournalMode }}</dd>
<div class="relative col-span-3 mt-4"> <div class="relative col-span-3 mt-4">
<div class="absolute inset-0 flex items-center" aria-hidden="true"> <div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-gray-300"></div> <div class="w-full border-t border-gray-300"></div>

View File

@@ -28,9 +28,9 @@
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.NbLikes }}</td> <td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.NbLikes }}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span class="moment-timestamp-date">{{ $gist.CreatedAt }}</span></td> <td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span class="moment-timestamp-date">{{ $gist.CreatedAt }}</span></td>
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> <td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<form action="{{ $.c.ExternalUrl }}/admin-panel/gists/{{ $gist.ID }}/delete" method="POST" onsubmit="return confirm('{{ $.locale.Tr "admin.gists.delete_confirm" }}')"> <form action="{{ $.c.ExternalUrl }}/admin-panel/gists/{{ $gist.ID }}/delete" method="POST">
{{ $.csrfHtml }} {{ $.csrfHtml }}
<button type="submit" class="text-rose-500 hover:text-rose-600">{{ $.locale.Tr "admin.delete" }}</button> <button type="submit" onclick="return confirm('{{ $.locale.Tr "admin.gists.delete_confirm" }}')" class="text-rose-500 hover:text-rose-600">{{ $.locale.Tr "admin.delete" }}</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -53,9 +53,9 @@
<td class="whitespace-nowrap py-2 px-2 text-sm">{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}</td> <td class="whitespace-nowrap py-2 px-2 text-sm">{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm"><span class="moment-timestamp-date">{{ $invitation.ExpiresAt }}</span></td> <td class="whitespace-nowrap px-2 py-2 text-sm"><span class="moment-timestamp-date">{{ $invitation.ExpiresAt }}</span></td>
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> <td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<form action="{{ $.c.ExternalUrl }}/admin-panel/invitations/{{ $invitation.ID }}/delete" method="POST" onsubmit="return confirm('{{ $.locale.Tr "admin.users.delete_confirm" }}')"> <form action="{{ $.c.ExternalUrl }}/admin-panel/invitations/{{ $invitation.ID }}/delete" method="POST">
{{ $.csrfHtml }} {{ $.csrfHtml }}
<button type="submit" class="text-rose-500 hover:text-rose-600">{{ $.locale.Tr "admin.delete" }}</button> <button type="submit" onclick="return confirm('{{ $.locale.Tr "admin.users.delete_confirm" }}')" class="text-rose-500 hover:text-rose-600">{{ $.locale.Tr "admin.delete" }}</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -20,9 +20,9 @@
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><a href="{{ $.c.ExternalUrl }}/{{ $user.Username }}">{{ $user.Username }}</a></td> <td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><a href="{{ $.c.ExternalUrl }}/{{ $user.Username }}">{{ $user.Username }}</a></td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span class="moment-timestamp-date">{{ $user.CreatedAt }}</span></td> <td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span class="moment-timestamp-date">{{ $user.CreatedAt }}</span></td>
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> <td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<form action="{{ $.c.ExternalUrl }}/admin-panel/users/{{ $user.ID }}/delete" method="POST" onsubmit="return confirm('{{ $.locale.Tr "admin.users.delete_confirm" }}')"> <form action="{{ $.c.ExternalUrl }}/admin-panel/users/{{ $user.ID }}/delete" method="POST">
{{ $.csrfHtml }} {{ $.csrfHtml }}
<button type="submit" class="text-rose-500 hover:text-rose-600">{{ $.locale.Tr "admin.delete" }}</button> <button type="submit" class="text-rose-500 hover:text-rose-600" onclick="return confirm('{{ $.locale.Tr "admin.users.delete_confirm" }}')">{{ $.locale.Tr "admin.delete" }}</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -11,83 +11,110 @@
{{ if .disableSignup }} {{ if .disableSignup }}
<p class="italic">{{ .locale.Tr "auth.signup-disabled" }}</p> <p class="italic">{{ .locale.Tr "auth.signup-disabled" }}</p>
{{ else }} {{ else }}
<div class="sm:col-span-6"> <div class="grid sm:grid-cols-2">
<div class="mt-8 sm:w-full sm:max-w-md"> <div class="">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10"> <div class="mt-8 sm:w-full sm:max-w-md">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
{{ if not .disableForm }}
<form class="space-y-6" method="post">
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "auth.username" }} </label>
<div class="mt-1">
<input id="username" name="username" type="text" required class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div>
</div>
<div class="mt-8">
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "auth.password" }} </label>
<div class="mt-1">
<input id="password" name="password" type="password" autocomplete="current-password" required class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div>
</div>
{{ if .isLoginPage }}
<div class="flex">
<div class="flex-auto">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.login" }}</button>
</div>
{{ if not .DisableSignup }}
<span class="float-right text-sm py-2 underline"><a href="{{ $.c.ExternalUrl }}/register">{{ .locale.Tr "auth.register-instead" }} →</a></span>
{{ end }}
</div>
{{ else }}
<div class="flex">
<div class="flex-auto">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.signup" }}</button>
</div>
<span class="float-right text-sm py-2 underline"><a href="{{ $.c.ExternalUrl }}/login">{{ .locale.Tr "auth.login-instead" }} →</a></span>
</div>
{{ end }}
{{ .csrfHtml }}
</form>
{{ end }}
{{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }}
{{ if not .disableForm }} {{ if not .disableForm }}
<div class="relative my-4"> <form class="space-y-6" method="post">
<div class="absolute inset-0 flex items-center" aria-hidden="true"> <div>
<div class="w-full border-t border-gray-200 dark:border-gray-700"></div> <label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "auth.username" }} </label>
<div class="mt-1">
<input id="username" name="username" type="text" required class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div> </div>
</div> </div>
<br />
<div class="mt-8">
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "auth.password" }} </label>
<div class="mt-1">
<input id="password" name="password" type="password" autocomplete="current-password" required class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div>
</div>
{{ if .isLoginPage }}
<div class="flex">
<div class="flex-auto">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.login" }}</button>
</div>
{{ if not .DisableSignup }}
<span class="float-right text-sm py-2 underline"><a href="{{ $.c.ExternalUrl }}/register">{{ .locale.Tr "auth.register-instead" }} →</a></span>
{{ end }}
</div>
{{ else }}
<div class="flex">
<div class="flex-auto">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.signup" }}</button>
</div>
<span class="float-right text-sm py-2 underline"><a href="{{ $.c.ExternalUrl }}/login">{{ .locale.Tr "auth.login-instead" }} →</a></span>
</div>
{{ end }}
{{ .csrfHtml }}
</form>
{{ end }} {{ end }}
<div> {{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }}
{{ if .githubOauth }} {{ if not .disableForm }}
<a href="{{ $.c.ExternalUrl }}/oauth/github" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3"> <div class="relative my-4">
{{ .locale.Tr "auth.oauth" "GitHub"}} <div class="absolute inset-0 flex items-center" aria-hidden="true">
</a> <div class="w-full border-t border-gray-200 dark:border-gray-700"></div>
</div>
</div>
<br />
{{ end }} {{ end }}
{{ if .gitlabOauth }} <div>
<a href="{{ $.c.ExternalUrl }}/oauth/gitlab" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3"> {{ if .githubOauth }}
{{ .locale.Tr "auth.oauth" .c.GitlabName}} <a href="{{ $.c.ExternalUrl }}/oauth/github" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
</a> {{ .locale.Tr "auth.oauth" "GitHub"}}
{{ end }} </a>
{{ if .giteaOauth }} {{ end }}
<a href="{{ $.c.ExternalUrl }}/oauth/gitea" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3"> {{ if .gitlabOauth }}
{{ .locale.Tr "auth.oauth" .c.GiteaName }} <a href="{{ $.c.ExternalUrl }}/oauth/gitlab" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
</a> {{ .locale.Tr "auth.oauth" .c.GitlabName}}
{{ end }} </a>
{{ if .oidcOauth }} {{ end }}
<a href="{{ $.c.ExternalUrl }}/oauth/openid-connect" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3"> {{ if .giteaOauth }}
Continue with OpenID account <a href="{{ $.c.ExternalUrl }}/oauth/gitea" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
</a> {{ .locale.Tr "auth.oauth" .c.GiteaName }}
{{ end }} </a>
</div> {{ end }}
{{ end }} {{ if .oidcOauth }}
<a href="{{ $.c.ExternalUrl }}/oauth/openid-connect" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
Continue with OpenID account
</a>
{{ end }}
</div>
{{ end }}
</div>
</div> </div>
</div> </div>
{{ if and (.isLoginPage) (not .disableForm) }}
<div class="">
<div class="mt-8 sm:w-full sm:max-w-md">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10 ">
<p class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.passkey" }}</p>
<div class="flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-14">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
</div>
<div class="flex items-center justify-center mt-4">
<form id="webauthn">
{{ .csrfHtml }}
<button type="button" id="login-passkey-button" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.mfa.login-with-passkey" }}</button>
</form>
</div>
<div class="flex items-center justify-center">
<p id="login-passkey-wait" class="hidden text-sm font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.waiting-for-passkey-input" }}</p>
</div>
</div>
</div>
</div>
{{ end }}
</div> </div>
{{ end }} {{ end }}
</main> </main>
</div> </div>
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
{{ template "footer" .}} {{ template "footer" .}}

View File

@@ -28,9 +28,9 @@
</div> </div>
</div> </div>
</form> </form>
<form id="delete" onsubmit="return confirm('Are you sure you want to delete this gist ?')" class="ml-2 flex items-center" method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/delete"> <form id="delete" class="ml-2 flex items-center" method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/delete">
{{ .csrfHtml }} {{ .csrfHtml }}
<button type="submit" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-rose-600 dark:text-rose-400 hover:bg-rose-500 hover:text-white dark:hover:bg-rose-600 hover:border-rose-600 dark:hover:border-rose-700 dark:hover:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"> <button type="submit" onclick="return confirm('{{ .locale.Tr "gist.delete.confirm" }}')" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-rose-600 dark:text-rose-400 hover:bg-rose-500 hover:text-white dark:hover:bg-rose-600 hover:border-rose-600 dark:hover:border-rose-700 dark:hover:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>

View File

@@ -68,6 +68,8 @@
</table> </table>
{{ else if isMarkdown $file.Filename }} {{ else if isMarkdown $file.Filename }}
<div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div> <div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div>
{{ else if isSvg $file.Filename }}
<div class="p-8 flex justify-center">{{ $file.HTML | safe }}</div>
{{ else }} {{ else }}
<div class="code"> <div class="code">
{{ $fileslug := slug $file.Filename }} {{ $fileslug := slug $file.Filename }}

View File

@@ -32,6 +32,8 @@
</table> </table>
{{ else if isMarkdown $file.Filename }} {{ else if isMarkdown $file.Filename }}
<div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div> <div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div>
{{ else if isSvg $file.Filename }}
<div class="p-8 flex justify-center">{{ $file.HTML | safe }}</div>
{{ else }} {{ else }}
<div class="code dark:bg-gray-900"> <div class="code dark:bg-gray-900">
{{ $fileslug := slug $file.Filename }} {{ $fileslug := slug $file.Filename }}

60
templates/pages/mfa.html vendored Normal file
View File

@@ -0,0 +1,60 @@
{{ template "header" .}}
<div class="py-10">
<header class="pb-4">
<div>
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "auth.mfa" }}</h1>
</div>
</header>
<main>
<div class="mt-8 sm:w-full sm:max-w-md mx-auto grid gap-y-4">
{{ if .hasWebauthn }}
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10 ">
<div class="flex items-center justify-center">
<p class="block text-md font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.use-passkey-to-finish" }}</p>
</div>
<div class="flex items-center justify-center mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-14">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
</div>
<div class="flex items-center justify-center mt-4">
<form id="webauthn">
{{ .csrfHtml }}
<button type="button" id="login-passkey-button" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.mfa.use-passkey" }}</button>
</form>
</div>
<div class="flex items-center justify-center">
<p id="login-passkey-wait" class="hidden text-sm font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.waiting-for-passkey-input" }}</p>
</div>
</div>
{{ end }}
{{ if .hasTotp }}
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10 ">
<div class="flex items-center justify-center">
<p class="block text-md font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.totp.enter-code" }}</p>
</div>
<div class="flex items-center justify-center">
<p class="block text-sm font-medium items-center text-slate-600 dark:text-slate-400">{{ .locale.Tr "auth.totp.enter-recovery-key" }}</p>
</div>
<div class="flex items-center justify-center mt-4">
<form method="post" action="{{ $.c.ExternalUrl }}/mfa/totp/assertion">
{{ .csrfHtml }}
<label for="code" class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.totp.code" }}</label>
<div class="mt-1">
<input id="code" name="code" type="text" required autocomplete="off" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
</div>
<button type="submit" class="mt-2 inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.submit" }}</button>
</form>
</div>
</div>
{{ end }}
</div>
</main>
</div>
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
{{ template "footer" .}}

View File

@@ -96,7 +96,7 @@
{{ if .githubOauth }} {{ if .githubOauth }}
{{ if .userLogged.GithubID }} {{ if .userLogged.GithubID }}
<a href="{{ $.c.ExternalUrl }}/oauth/github" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3" <a href="{{ $.c.ExternalUrl }}/oauth/github/unlink" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3"
onclick="return confirm('Are you sure you want to unlink your GitHub account? You may lose access to Opengist if it\'s your only way to log in.')"> onclick="return confirm('Are you sure you want to unlink your GitHub account? You may lose access to Opengist if it\'s your only way to log in.')">
{{ .locale.Tr "settings.unlink-github-account" }} {{ .locale.Tr "settings.unlink-github-account" }}
</a> </a>
@@ -109,7 +109,7 @@
{{ if .gitlabOauth }} {{ if .gitlabOauth }}
{{ if .userLogged.GitlabID }} {{ if .userLogged.GitlabID }}
<a href="{{ $.c.ExternalUrl }}/oauth/gitlab" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3" <a href="{{ $.c.ExternalUrl }}/oauth/gitlab/unlink" class="block w-full mb-2 text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3"
onclick="return confirm('Are you sure you want to unlink your GitLab account? You may lose access to Opengist if it\'s your only way to log in.')"> onclick="return confirm('Are you sure you want to unlink your GitLab account? You may lose access to Opengist if it\'s your only way to log in.')">
{{ .locale.Tr "settings.unlink-gitlab-account" }} {{ .locale.Tr "settings.unlink-gitlab-account" }}
</a> </a>
@@ -122,7 +122,7 @@
{{ if .giteaOauth }} {{ if .giteaOauth }}
{{ if .userLogged.GiteaID }} {{ if .userLogged.GiteaID }}
<a href="{{ $.c.ExternalUrl }}/oauth/gitea" class="block w-full text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-200 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3" <a href="{{ $.c.ExternalUrl }}/oauth/gitea/unlink" class="block w-full text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-200 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3"
onclick="return confirm('Are you sure you want to unlink your Gitea account? You may lose access to Opengist if it\'s your only way to log in.')"> onclick="return confirm('Are you sure you want to unlink your Gitea account? You may lose access to Opengist if it\'s your only way to log in.')">
{{ .locale.Tr "settings.unlink-gitea-account" }} {{ .locale.Tr "settings.unlink-gitea-account" }}
</a> </a>
@@ -134,7 +134,7 @@
{{ end }} {{ end }}
{{ if .oidcOauth }} {{ if .oidcOauth }}
{{ if .userLogged.OIDCID }} {{ if .userLogged.OIDCID }}
<a href="{{ $.c.ExternalUrl }}/oauth/openid-connect" class="block w-full text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-200 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3" <a href="{{ $.c.ExternalUrl }}/oauth/openid-connect/unlink" class="block w-full text-center whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncReposFromFS }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-200 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3"
onclick="return confirm('Are you sure you want to unlink your OpenID account? You may lose access to Opengist if it\'s your only way to log in.')"> onclick="return confirm('Are you sure you want to unlink your OpenID account? You may lose access to Opengist if it\'s your only way to log in.')">
Unlink OpenID account Unlink OpenID account
</a> </a>
@@ -148,6 +148,90 @@
</div> </div>
</div> </div>
{{ end }} {{ end }}
<div class="w-full">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
{{ .locale.Tr "auth.totp" }}
</h2>
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
{{ .locale.Tr "auth.totp.help" }}
</h3>
{{ if .hasTotp }}
<div class="flex">
<form method="post" action="{{ $.c.ExternalUrl }}/settings/totp" onconfirm="" class="mr-2">
<input type="hidden" name="_method" value="DELETE" />
{{ .csrfHtml }}
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">{{ .locale.Tr "auth.totp.disable" }}</button>
</form>
<form method="post" action="{{ $.c.ExternalUrl }}/settings/totp/regenerate" onconfirm="">
{{ .csrfHtml }}
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.regenerate-recovery-codes" }}</button>
</form>
</div>
{{ else }}
<a href="{{ $.c.ExternalUrl }}/settings/totp/generate" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.use" }}</a>
{{ end }}
</div>
</div>
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
<div class="w-full">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
{{ .locale.Tr "auth.mfa.passkeys" }}
</h2>
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
{{ .locale.Tr "auth.mfa.passkeys-help" }}
</h3>
<form class="space-y-6" id="webauthn">
<div>
<label for="passkeyname" class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.passkey-name" }}</label>
<div class="mt-1">
<input id="passkeyname" name="passkeyname" type="text" required autocomplete="off" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
</div>
</div>
{{ .csrfHtml }}
<button id="bind-passkey-button" type="button" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.mfa.bind-passkey" }}</button>
</form>
<div class="flex items-center justify-center mt-4">
<p id="login-passkey-wait" class="hidden text-sm font-medium items-center text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.mfa.waiting-for-passkey-input" }}</p>
</div>
</div>
</div>
<div>
<div class="mt-6 flow-root">
<ul role="list" class="-my-5 divide-y divide-gray-300 dark:divide-gray-700 list-none">
{{ if .passkeys }}
{{ range $passkey := .passkeys }}
<li class="py-5">
<div class="inline-flex">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 mr-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
<div>
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .Name }}</h3>
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "auth.mfa.passkey-added-at" }} <span class="moment-timestamp-date">{{ .CreatedAt }}</span></p>
{{ if eq .LastUsedAt 0 }}
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "auth.mfa.passkey-never-used" }}</p>
{{ else }}
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "auth.mfa.passkey-last-used" }} <span class="moment-timestamp">{{ .LastUsedAt }}</span></p>
{{ end }}
</div>
<form action="{{ $.c.ExternalUrl }}/settings/passkeys/{{.ID}}" method="post" class="inline-block">
<input type="hidden" name="_method" value="DELETE">
{{ $.csrfHtml }}
<button type="submit" onclick="return confirm('{{ $.locale.Tr "auth.mfa.delete-passkey-confirm" }}');" class="align-middle items-center leading-2 ml-2 px-3 py-1 border border-transparent border-gray-200 dark:border-gray-700 text-xs font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">{{ $.locale.Tr "auth.mfa.delete-passkey" }}</button>
</form>
</div>
</li>
{{ end }}
{{ end }}
</ul>
</div>
</div>
</div>
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8"> <div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
<div class="w-full"> <div class="w-full">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10"> <div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
@@ -196,11 +280,11 @@
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.ssh-key-last-used" }} <span class="moment-timestamp">{{ .LastUsedAt }}</span></p> <p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.ssh-key-last-used" }} <span class="moment-timestamp">{{ .LastUsedAt }}</span></p>
{{ end }} {{ end }}
</div> </div>
<form action="{{ $.c.ExternalUrl }}/settings/ssh-keys/{{.ID}}" method="post" class="inline-block" onsubmit="return confirm('{{ $.locale.Tr "settings.delete-ssh-key-confirm" }}')"> <form action="{{ $.c.ExternalUrl }}/settings/ssh-keys/{{.ID}}" method="post" class="inline-block">
<input type="hidden" name="_method" value="DELETE"> <input type="hidden" name="_method" value="DELETE">
{{ $.csrfHtml }} {{ $.csrfHtml }}
<button type="submit" class="align-middle items-center leading-2 ml-2 px-3 py-1 border border-transparent border-gray-200 dark:border-gray-700 text-xs font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">{{ $.locale.Tr "settings.delete-ssh-key" }}</button> <button type="submit" onclick="return confirm('{{ $.locale.Tr "settings.delete-ssh-key-confirm" }}')" class="align-middle items-center leading-2 ml-2 px-3 py-1 border border-transparent border-gray-200 dark:border-gray-700 text-xs font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">{{ $.locale.Tr "settings.delete-ssh-key" }}</button>
</form> </form>
</div> </div>
</li> </li>
@@ -215,9 +299,9 @@
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300"> <h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
{{ .locale.Tr "settings.delete-account" }} {{ .locale.Tr "settings.delete-account" }}
</h2> </h2>
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/account" method="post" onsubmit="return confirm('{{ .locale.Tr "settings.delete-account-confirm" }}')"> <form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/account" method="post">
<input type="hidden" name="_method" value="DELETE"> <input type="hidden" name="_method" value="DELETE">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500 mt-2">{{ .locale.Tr "settings.delete-account" }}</button> <button type="submit" onclick="return confirm('{{ .locale.Tr "settings.delete-account-confirm" }}')" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500 mt-2">{{ .locale.Tr "settings.delete-account" }}</button>
{{ .csrfHtml }} {{ .csrfHtml }}
</form> </form>
</div> </div>
@@ -225,4 +309,8 @@
</div> </div>
</main> </main>
</div> </div>
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
{{ template "footer" .}} {{ template "footer" .}}

54
templates/pages/totp.html vendored Normal file
View File

@@ -0,0 +1,54 @@
{{ template "header" .}}
<div class="py-10">
<header class="pb-4">
<div>
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "auth.totp" }}</h1>
</div>
</header>
<main>
{{ if .recoveryCodes }}
<p class="mb-4">{{ .locale.Tr "auth.totp.save-recovery-codes" }}</p>
<div class="mt-8 sm:w-full sm:max-w-md mx-auto flex">
<div class="mx-auto bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<ul class="list-none">
<code>
{{ range .recoveryCodes }}
<li>{{ . }}</li>
{{ end }}
</code>
</ul>
</div>
</div>
<div class="mt-8 sm:w-full sm:max-w-md mx-auto flex flex-col items-center">
<a href="{{ $.c.ExternalUrl }}/settings" class="px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.proceed" }}</a>
</div>
{{ else }}
<p class="mb-2">{{ .locale.Tr "auth.totp.scan-qr-code" }}</p>
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
<div class="mt-4">
<p class="mb-4"><code>{{.totpSecret}}</code></p>
<img src="{{.totpQrcode}}" alt="{{.totpSecret}}">
</div>
<div>
<form method="post" action="{{ $.c.ExternalUrl }}/settings/totp/generate">
{{ .csrfHtml }}
<label for="code" class="block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "auth.totp.code" }}</label>
<div class="mt-1">
<input id="code" name="code" type="text" required autocomplete="off" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
</div>
<button type="submit" class="mt-4 inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "auth.totp.submit" }}</button>
</form>
</div>
</div>
{{ end }}
</main>
</div>
{{ template "footer" .}}