Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92bac3bf8c | ||
|
|
73c2fb55bc | ||
|
|
75162b3ef9 | ||
|
|
d537153785 | ||
|
|
97b9fa1100 | ||
|
|
393c9756d4 | ||
|
|
63d4b46a41 | ||
|
|
91c412d97e | ||
|
|
7cc2b497ca | ||
|
|
d5e66d3994 | ||
|
|
4fd0832df9 | ||
|
|
20372f44e4 | ||
|
|
d0b4815798 | ||
|
|
3cc3fb4572 | ||
|
|
ca44abfc43 | ||
|
|
2bf434f00e | ||
|
|
df226cbd99 | ||
|
|
3068588111 | ||
|
|
12696d23b0 | ||
|
|
798a0bfc28 | ||
|
|
6959929094 | ||
|
|
41dc2e451b | ||
|
|
56b4fd45fd | ||
|
|
605c8b892a | ||
|
|
fa8217e27f | ||
|
|
9ac7a76f4a | ||
|
|
17237713a1 |
90
.github/workflows/go.yml
vendored
90
.github/workflows/go.yml
vendored
@@ -4,42 +4,46 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- 'dev-*'
|
||||
workflow_dispatch:
|
||||
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.yml'
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go 1.22
|
||||
- name: Set up Go 1.23
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.22"
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.54
|
||||
skip-pkg-cache: true
|
||||
args: --out-format=colored-line-number --timeout=20m
|
||||
version: v1.60
|
||||
args: --out-format=colored-line-number --timeout=20m
|
||||
|
||||
- name: Format
|
||||
run: make fmt check_changes
|
||||
|
||||
check:
|
||||
name: Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go 1.22
|
||||
- name: Set up Go 1.23
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.22"
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Check Go modules
|
||||
run: make go_mod check_changes
|
||||
@@ -47,12 +51,58 @@ jobs:
|
||||
- name: Check translations
|
||||
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:
|
||||
name: Test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ["ubuntu-latest", "macOS-latest", "windows-latest"]
|
||||
go: ["1.22"]
|
||||
go: ["1.23"]
|
||||
database: ["sqlite"]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -64,5 +114,25 @@ jobs:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- 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
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go 1.22
|
||||
- name: Set up Go 1.23
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.22"
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Cross compile build
|
||||
run: make all_crosscompile
|
||||
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,5 +1,47 @@
|
||||
# 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
|
||||
See here how to [update](/docs/update.md) Opengist.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ RUN apk update && \
|
||||
musl-dev \
|
||||
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 CGO_ENABLED=0
|
||||
|
||||
|
||||
3
Makefile
3
Makefile
@@ -4,6 +4,7 @@
|
||||
BINARY_NAME := opengist
|
||||
GIT_TAG := $(shell git describe --tags)
|
||||
VERSION_PKG := github.com/thomiceli/opengist/internal/config.OpengistVersion
|
||||
TEST_DB_TYPE ?= sqlite
|
||||
|
||||
all: clean install build
|
||||
|
||||
@@ -72,7 +73,7 @@ fmt:
|
||||
@go fmt ./...
|
||||
|
||||
test:
|
||||
@go test ./... -p 1
|
||||
@OPENGIST_TEST_DB=$(TEST_DB_TYPE) go test ./... -p 1
|
||||
|
||||
check-tr:
|
||||
@bash ./scripts/check-translations.sh
|
||||
22
README.md
22
README.md
@@ -2,9 +2,9 @@
|
||||
|
||||
<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.
|
||||
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)
|
||||
|
||||
@@ -13,14 +13,14 @@ It is similiar to [GitHub Gist](https://gist.github.com/), but open-source and c
|
||||

|
||||
[](https://github.com/thomiceli/opengist/actions/workflows/go.yml)
|
||||
[](https://goreportcard.com/report/github.com/thomiceli/opengist)
|
||||
|
||||
[](https://tr.opengist.io/projects/_/opengist/)
|
||||
|
||||
## Features
|
||||
|
||||
* Create public, unlisted or private snippets
|
||||
* [Init](/docs/usage/init-via-git.md) / Clone / Pull / Push snippets **via Git** over HTTP or SSH
|
||||
* Syntax highlighting ; markdown & CSV support
|
||||
* Search code in snippets ; browse users snippets, likes and forks
|
||||
* Search code in snippets; browse users snippets, likes and forks
|
||||
* Embed snippets in other websites
|
||||
* Revisions history
|
||||
* 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
|
||||
* Restrict or unrestrict snippets visibility to anonymous users
|
||||
* Docker support
|
||||
* [More...](/docs/index.md#features)
|
||||
* [More...](/docs/introduction.md#features)
|
||||
|
||||
## 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 :
|
||||
|
||||
```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 :
|
||||
@@ -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
|
||||
|
||||
```yml
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
opengist:
|
||||
image: ghcr.io/thomiceli/opengist:1.7
|
||||
image: ghcr.io/thomiceli/opengist:1.8
|
||||
container_name: opengist
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -78,9 +76,9 @@ Download the archive for your system from the release page [here](https://github
|
||||
|
||||
```shell
|
||||
# example for linux amd64
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.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
|
||||
chmod +x opengist
|
||||
./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
|
||||
|
||||
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
|
||||
git clone https://github.com/thomiceli/opengist
|
||||
|
||||
13
config.yml
13
config.yml
@@ -1,5 +1,5 @@
|
||||
# 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
|
||||
|
||||
# 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/
|
||||
opengist-home:
|
||||
|
||||
# Name of the SQLite database file. Default: opengist.db
|
||||
db-filename: opengist.db
|
||||
# Secret key used for session store & encrypt MFA data on database. Default: <randomized 32 bytes>
|
||||
secret-key:
|
||||
|
||||
# URI of the database. Default: opengist.db (SQLite)
|
||||
# SQLite: file name
|
||||
# PostgreSQL: postgres://user:password@host:port/database
|
||||
# MySQL/MariaDB: mysql://user:password@host:port/database
|
||||
db-uri: opengist.db
|
||||
|
||||
# Enable or disable the code search index (either `true` or `false`). Default: true
|
||||
index.enabled: true
|
||||
@@ -29,6 +35,7 @@ git.default-branch:
|
||||
|
||||
# Set the journal mode for SQLite. Default: WAL
|
||||
# See https://www.sqlite.org/pragma.html#pragma_journal_mode
|
||||
# For SQLite databases only.
|
||||
sqlite.journal-mode: WAL
|
||||
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ namespace: opengist
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- https://github.com/thomiceli/opengist/deploy/?ref:v1.7.5
|
||||
- https://github.com/thomiceli/opengist/deploy/?ref:v1.8.1
|
||||
|
||||
images:
|
||||
- name: ghcr.io/thomiceli/opengist
|
||||
newTag: 1.7.5
|
||||
newTag: 1.8.1
|
||||
|
||||
patches:
|
||||
# Add your ingress
|
||||
|
||||
@@ -9,4 +9,10 @@ usermod -o -u "$UID" $USER
|
||||
chown -R "$USER:$USER" /opengist
|
||||
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"
|
||||
|
||||
@@ -36,11 +36,17 @@ export default defineConfig({
|
||||
{
|
||||
text: 'Configuration', base: '/docs/configuration', items: [
|
||||
{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: 'Custom assets', link: '/custom-assets'},
|
||||
{text: 'Custom links', link: '/custom-links'},
|
||||
{text: 'Cheat Sheet', link: '/cheat-sheet'},
|
||||
{text: 'Admin panel', link: '/admin-panel'},
|
||||
], collapsed: false
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ export default {
|
||||
<div class="mx-auto lg:text-center">
|
||||
<img class="rotating h-36 mx-auto my-8 " src="https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg" alt="" >
|
||||
<a target="_blank" href="https://github.com/thomiceli/opengist/releases" class="inline-flex items-center rounded-full bg-indigo-100 hover:bg-indigo-200 px-4 py-1.5 text-lg font-medium text-indigo-700">
|
||||
<span class="pr-1">Released 1.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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
|
||||
</svg>
|
||||
|
||||
11
docs/administration/manage-admins.md
Normal file
11
docs/administration/manage-admins.md
Normal 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
|
||||
```
|
||||
@@ -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`. |
|
||||
| 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. |
|
||||
| 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.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) |
|
||||
|
||||
@@ -46,3 +46,27 @@ Usage via command line :
|
||||
```shell
|
||||
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
|
||||
```
|
||||
47
docs/configuration/databases/mysql.md
Normal file
47
docs/configuration/databases/mysql.md
Normal 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
|
||||
```
|
||||
46
docs/configuration/databases/postgresql.md
Normal file
46
docs/configuration/databases/postgresql.md
Normal 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
|
||||
```
|
||||
41
docs/configuration/databases/sqlite.md
Normal file
41
docs/configuration/databases/sqlite.md
Normal 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
|
||||
```
|
||||
@@ -25,7 +25,7 @@ Opengist is now running on port 6157, you can browse http://localhost:6157
|
||||
|
||||
Requirements:
|
||||
* [Git](https://git-scm.com/downloads) (2.28+)
|
||||
* [Go](https://go.dev/doc/install) (1.22+)
|
||||
* [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)
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ Download the archive for your system from the release page [here](https://github
|
||||
|
||||
```shell
|
||||
# example for linux amd64
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.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
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
@@ -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
|
||||
|
||||
```yml
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
opengist:
|
||||
image: ghcr.io/thomiceli/opengist:1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Requirements:
|
||||
* [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+)
|
||||
* [Make](https://linux.die.net/man/1/make) (optional, but easier)
|
||||
|
||||
@@ -10,7 +10,7 @@ Requirements:
|
||||
git clone https://github.com/thomiceli/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
|
||||
./opengist
|
||||
|
||||
@@ -31,7 +31,7 @@ Written in [Go](https://go.dev), Opengist aims to be fast and easy to deploy.
|
||||
* delete users/gists;
|
||||
* clean database/filesystem by syncing gists
|
||||
* run `git gc` for all repositories
|
||||
* SQLite database
|
||||
* SQLite/PostgreSQL/MySQL database
|
||||
* Logging
|
||||
* Docker support
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ Stop the running instance; then like your first installation of Opengist, downlo
|
||||
|
||||
```shell
|
||||
# example for linux amd64
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.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
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
77
go.mod
77
go.mod
@@ -1,44 +1,48 @@
|
||||
module github.com/thomiceli/opengist
|
||||
|
||||
go 1.22
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/Kunde21/markdownfmt/v3 v3.1.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/glebarez/go-sqlite v1.22.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/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/labstack/echo/v4 v4.12.0
|
||||
github.com/markbates/goth v1.80.0
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/urfave/cli/v2 v2.27.2
|
||||
github.com/yuin/goldmark v1.7.1
|
||||
github.com/yuin/goldmark-emoji v1.0.2
|
||||
github.com/urfave/cli/v2 v2.27.5
|
||||
github.com/yuin/goldmark v1.7.8
|
||||
github.com/yuin/goldmark-emoji v1.0.4
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
go.abhg.dev/goldmark/mermaid v0.5.0
|
||||
golang.org/x/crypto v0.23.0
|
||||
golang.org/x/text v0.15.0
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/text v0.19.0
|
||||
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 (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.13.0 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.1.8 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.14.3 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.1.12 // 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/gtreap v0.1.1 // 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/snowballstem v0.9.0 // 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/v13 v13.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/v16 v16.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.3.15 // indirect
|
||||
github.com/blevesearch/zapx/v16 v16.1.7 // 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/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.15 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-tpm v0.9.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -70,7 +85,8 @@ require (
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // 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/reflect2 v1.0.2 // 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/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
go.etcd.io/bbolt v1.3.10 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/oauth2 v0.20.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
modernc.org/libc v1.51.0 // indirect
|
||||
go.etcd.io/bbolt v1.3.11 // indirect
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/oauth2 v0.23.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/time v0.7.0 // indirect
|
||||
google.golang.org/protobuf v1.35.1 // indirect
|
||||
modernc.org/libc v1.61.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/sqlite v1.30.0 // indirect
|
||||
modernc.org/sqlite v1.33.1 // indirect
|
||||
)
|
||||
|
||||
179
go.sum
179
go.sum
@@ -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/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc=
|
||||
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/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.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
|
||||
github.com/bits-and-blooms/bitset v1.13.0/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.0/go.mod h1:IhQHoFAbHgWKYavb9rQgQEJJVMuY99cKdQ0wPpst2aY=
|
||||
github.com/blevesearch/bleve_index_api v1.1.8 h1:rJUccYfWqRY2/BGowlsv1lwrLKYK/zPE6hgNn1pTGdk=
|
||||
github.com/blevesearch/bleve_index_api v1.1.8/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
|
||||
github.com/bits-and-blooms/bitset v1.14.3 h1:Gd2c8lSNf9pKXom5JtD7AaKO8o7fGQ2LtFj1436qilA=
|
||||
github.com/bits-and-blooms/bitset v1.14.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blevesearch/bleve/v2 v2.4.2 h1:NooYP1mb3c0StkiY9/xviiq2LGSaE8BQBCc/pirMx0U=
|
||||
github.com/blevesearch/bleve/v2 v2.4.2/go.mod h1:ATNKj7Yl2oJv/lGuF4kx39bST2dveX6w0th2FFYLkc8=
|
||||
github.com/blevesearch/bleve_index_api v1.1.12 h1:P4bw9/G/5rulOF7SJ9l4FsDoo7UFJ+5kexNy1RXfegY=
|
||||
github.com/blevesearch/bleve_index_api v1.1.12/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
|
||||
github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
|
||||
github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
|
||||
github.com/blevesearch/go-faiss v1.0.16 h1:lfzXzzjO1mAf15MRiRY5yz6KVGr02CyRrr7m0z70Ih8=
|
||||
github.com/blevesearch/go-faiss v1.0.16/go.mod h1:jrxHrbl42X/RnDPI+wBoZU8joxxuRwedrxqswQ3xfU8=
|
||||
github.com/blevesearch/go-faiss v1.0.23 h1:Wmc5AFwDLKGl2L6mjLX1Da3vCL0EKa2uHHSorcIS1Uc=
|
||||
github.com/blevesearch/go-faiss v1.0.23/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
|
||||
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
|
||||
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
|
||||
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
|
||||
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.2.13 h1:UfbyRpIMdcaNsgciGYS9Pib7N3xd3EEw8KKbd/aDBlA=
|
||||
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 h1:uGvKVvG7zvSxCwcm4/ehBa9cCEuZVE+/zvrSl57QUVY=
|
||||
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/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
|
||||
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/v14 v14.3.10 h1:SG6xlsL+W6YjhX5N3aEiL/2tcWh3DO75Bnz77pSwwKU=
|
||||
github.com/blevesearch/zapx/v14 v14.3.10/go.mod h1:qqyuR0u230jN1yMmE4FIAuCxmahRQEOehF78m6oTgns=
|
||||
github.com/blevesearch/zapx/v15 v15.3.13 h1:6EkfaZiPlAxqXz0neniq35my6S48QI94W/wyhnpDHHQ=
|
||||
github.com/blevesearch/zapx/v15 v15.3.13/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg=
|
||||
github.com/blevesearch/zapx/v16 v16.1.0 h1:bHsyowFqU0QA+uVDJCjifv9OvPGb8htkV52Yc/wT6xs=
|
||||
github.com/blevesearch/zapx/v16 v16.1.0/go.mod h1:P0h9lKRyl4EKksAWfxwCQ5I5pLB9jH2XD8bhYHuIYuc=
|
||||
github.com/blevesearch/zapx/v15 v15.3.15 h1:JydcGIq279tmTrfBBSPDF/VOiCMBLQ+rJulTXrGFlGA=
|
||||
github.com/blevesearch/zapx/v15 v15.3.15/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg=
|
||||
github.com/blevesearch/zapx/v16 v16.1.7 h1:I07qV6l1rPda19zyof9Q2J9E8cjZ57pQhNY0+ePI5vM=
|
||||
github.com/blevesearch/zapx/v16 v16.1.7/go.mod h1:JqQlOqlRVaYDkpLIl3JnKql8u4zKTNlVEa3nLsi0Gn8=
|
||||
github.com/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/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
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/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/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/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
@@ -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/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.21.0 h1:4fZA11ovvtkdgaeev9RGWPgc1uj3H8W+rNYyH/ySBb0=
|
||||
github.com/go-playground/validator/v10 v10.21.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-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/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
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/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I=
|
||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -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/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
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.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
@@ -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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
||||
github.com/jackc/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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
|
||||
github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
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/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.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
|
||||
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-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.4 h1:vCwMkPZSNefSUnOW2ZKRUjBSD5Ok3W78IXhGxxAEF90=
|
||||
github.com/yuin/goldmark-emoji v1.0.4/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||
go.abhg.dev/goldmark/mermaid v0.5.0 h1:mDkykpSPJ+5wCQ8bSXgzJ2KQskjXkI5Ndxz7JYDHW38=
|
||||
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs=
|
||||
go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0=
|
||||
go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
|
||||
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
||||
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
|
||||
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
|
||||
modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs=
|
||||
modernc.org/ccgo/v4 v4.17.8/go.mod h1:buJnJ6Fn0tyAdP/dqePbrrvLyr6qslFfTbFrCuaYvtA=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
|
||||
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
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.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.51.0 h1:kjSHjz1guHbI5iRdi6nEr/wIKSN6X4vzLd6TJMN+lHA=
|
||||
modernc.org/libc v1.51.0/go.mod h1:15P6ublJ9FJR8YQCGy8DeQ2Uwur7iW9Hserr/T3OFZE=
|
||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
@@ -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/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
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.30.0/go.mod h1:cgkTARJ9ugeXSNaLBPK3CqbOe7Ec7ZhWPoMFGldEYEw=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
61
internal/auth/totp/totp.go
Normal file
61
internal/auth/totp/totp.go
Normal 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
|
||||
}
|
||||
58
internal/auth/webauthn/user.go
Normal file
58
internal/auth/webauthn/user.go
Normal 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
|
||||
}
|
||||
138
internal/auth/webauthn/webauthn.go
Normal file
138
internal/auth/webauthn/webauthn.go
Normal 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
|
||||
}
|
||||
@@ -12,6 +12,7 @@ var CmdAdmin = cli.Command{
|
||||
Usage: "Admin commands",
|
||||
Subcommands: []*cli.Command{
|
||||
&CmdAdminResetPassword,
|
||||
&CmdAdminToggleAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,3 +49,30 @@ var CmdAdminResetPassword = cli.Command{
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var CmdHook = cli.Command{
|
||||
@@ -50,7 +49,8 @@ func initialize(ctx *cli.Context) {
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/auth/webauthn"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
@@ -36,7 +37,7 @@ var CmdStart = cli.Command{
|
||||
|
||||
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()
|
||||
|
||||
<-stopCtx.Done()
|
||||
@@ -75,6 +76,8 @@ func Initialize(ctx *cli.Context) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
config.SetupSecretKey()
|
||||
|
||||
config.InitLog()
|
||||
|
||||
gitVersion, err := git.GetGitVersion()
|
||||
@@ -108,8 +111,9 @@ func Initialize(ctx *cli.Context) {
|
||||
if err := os.MkdirAll(filepath.Join(homePath, "custom"), 0755); err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -117,6 +121,10 @@ func Initialize(ctx *cli.Context) {
|
||||
log.Fatal().Err(err).Msg("Failed to initialize in memory database")
|
||||
}
|
||||
|
||||
if err := webauthn.Init(config.C.ExternalUrl); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to initialize WebAuthn")
|
||||
}
|
||||
|
||||
if config.C.IndexEnabled {
|
||||
log.Info().Msg("Index directory: " + filepath.Join(homePath, config.C.IndexDirname))
|
||||
index.Init(filepath.Join(homePath, config.C.IndexDirname))
|
||||
|
||||
@@ -22,14 +22,21 @@ var OpengistVersion = ""
|
||||
|
||||
var C *config
|
||||
|
||||
var SecretKey []byte
|
||||
|
||||
// Not using nested structs because the library
|
||||
// doesn't support dot notation in this case sadly
|
||||
type config struct {
|
||||
SecretKey string `yaml:"secret-key" env:"OG_SECRET_KEY"`
|
||||
|
||||
LogLevel string `yaml:"log-level" env:"OG_LOG_LEVEL"`
|
||||
LogOutput string `yaml:"log-output" env:"OG_LOG_OUTPUT"`
|
||||
ExternalUrl string `yaml:"external-url" env:"OG_EXTERNAL_URL"`
|
||||
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"`
|
||||
IndexDirname string `yaml:"index.dirname" env:"OG_INDEX_DIRNAME"`
|
||||
|
||||
@@ -77,10 +84,12 @@ type StaticLink struct {
|
||||
func configWithDefaults() (*config, error) {
|
||||
c := &config{}
|
||||
|
||||
c.SecretKey = ""
|
||||
|
||||
c.LogLevel = "warn"
|
||||
c.LogOutput = "stdout,file"
|
||||
c.OpengistHome = ""
|
||||
c.DBFilename = "opengist.db"
|
||||
c.DBUri = "opengist.db"
|
||||
c.IndexEnabled = true
|
||||
c.IndexDirname = "opengist.index"
|
||||
|
||||
@@ -133,6 +142,10 @@ func InitConfig(configPath string, out io.Writer) error {
|
||||
|
||||
C = c
|
||||
|
||||
if err = migrateConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = os.Setenv("OG_OPENGIST_HOME_INTERNAL", GetHomeDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -228,6 +241,15 @@ func GetHomeDir() string {
|
||||
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 {
|
||||
if configPath != "" {
|
||||
absolutePath, _ := filepath.Abs(configPath)
|
||||
|
||||
42
internal/config/migrate.go
Normal file
42
internal/config/migrate.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
)
|
||||
|
||||
type AdminSetting struct {
|
||||
Key string `gorm:"uniqueIndex"`
|
||||
Key string `gorm:"index:,unique"`
|
||||
Value string
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func UpdateSetting(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 {
|
||||
@@ -64,9 +64,9 @@ func initAdminSettings(settings map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type DBAuthInfo struct{}
|
||||
type AuthInfo struct{}
|
||||
|
||||
func (auth DBAuthInfo) RequireLogin() (bool, error) {
|
||||
func (auth AuthInfo) RequireLogin() (bool, error) {
|
||||
s, err := GetSetting(SettingRequireLogin)
|
||||
if err != nil {
|
||||
return true, err
|
||||
@@ -74,7 +74,7 @@ func (auth DBAuthInfo) RequireLogin() (bool, error) {
|
||||
return s == "1", nil
|
||||
}
|
||||
|
||||
func (auth DBAuthInfo) AllowGistsWithoutLogin() (bool, error) {
|
||||
func (auth AuthInfo) AllowGistsWithoutLogin() (bool, error) {
|
||||
s, err := GetSetting(SettingAllowGistsWithoutLogin)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -2,38 +2,133 @@ package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm/logger"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
msqlite "github.com/glebarez/go-sqlite"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
func Setup(dbPath string, sharedCache bool) error {
|
||||
var err error
|
||||
journalMode := strings.ToUpper(config.C.SqliteJournalMode)
|
||||
const (
|
||||
SQLite databaseType = iota
|
||||
PostgreSQL
|
||||
MySQL
|
||||
)
|
||||
|
||||
if !slices.Contains([]string{"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"}, journalMode) {
|
||||
log.Warn().Msg("Invalid SQLite journal mode: " + journalMode)
|
||||
type databaseType int
|
||||
|
||||
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 := ""
|
||||
if sharedCache {
|
||||
sharedCacheStr = "&cache=shared"
|
||||
switch u.Scheme {
|
||||
case "postgres", "postgresql":
|
||||
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{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}); err != nil {
|
||||
if u.Host != "" {
|
||||
host, port, _ := strings.Cut(u.Host, ":")
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -42,11 +137,11 @@ func Setup(dbPath string, sharedCache bool) error {
|
||||
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
|
||||
}
|
||||
|
||||
if err = ApplyMigrations(db); err != nil {
|
||||
if err = applyMigrations(db, dbInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -75,11 +170,7 @@ func CountAll(table interface{}) (int64, error) {
|
||||
}
|
||||
|
||||
func IsUniqueConstraintViolation(err error) bool {
|
||||
var sqliteErr *msqlite.Error
|
||||
if errors.As(err, &sqliteErr) && sqliteErr.Code() == 2067 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return errors.Is(err, gorm.ErrDuplicatedKey)
|
||||
}
|
||||
|
||||
func Ping() error {
|
||||
@@ -90,3 +181,65 @@ func Ping() error {
|
||||
|
||||
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{})
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
|
||||
func GetGist(user string, gistUuid string) (*Gist, error) {
|
||||
gist := new(Gist)
|
||||
err := db.Preload("User").Preload("Forked.User").
|
||||
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").
|
||||
First(&gist).Error
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
@@ -15,10 +16,21 @@ type Invitation struct {
|
||||
|
||||
func GetAllInvitations() ([]*Invitation, error) {
|
||||
var invitations []*Invitation
|
||||
err := db.
|
||||
Order("(((expires_at >= strftime('%s', 'now')) AND ((nb_max <= 0) OR (nb_used < nb_max)))) desc").
|
||||
Order("id asc").
|
||||
Find(&invitations).Error
|
||||
dialect := db.Dialector.Name()
|
||||
query := db.Model(&Invitation{})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,7 +11,19 @@ type MigrationVersion struct {
|
||||
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
|
||||
if err := db.AutoMigrate(&MigrationVersion{}); err != nil {
|
||||
log.Fatal().Err(err).Msg("Error creating migration version table")
|
||||
|
||||
122
internal/db/totp.go
Normal file
122
internal/db/totp.go
Normal 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
77
internal/db/types.go
Normal 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 ""
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"uniqueIndex"`
|
||||
Username string `gorm:"uniqueIndex,size:191"`
|
||||
Password string
|
||||
IsAdmin bool
|
||||
CreatedAt int64
|
||||
@@ -18,9 +18,10 @@ type User struct {
|
||||
GiteaID string
|
||||
OIDCID string `gorm:"column:oidc_id"`
|
||||
|
||||
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
}
|
||||
|
||||
func (user *User) BeforeDelete(tx *gorm.DB) error {
|
||||
@@ -58,6 +59,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Where("user_id = ?", user.ID).Delete(&WebAuthnCredential{}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete all gists created by this user
|
||||
return tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
|
||||
}
|
||||
@@ -200,6 +206,19 @@ func (user *User) DeleteProviderID(provider string) error {
|
||||
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 -- //
|
||||
|
||||
type UserDTO struct {
|
||||
|
||||
149
internal/db/webauth_credential.go
Normal file
149
internal/db/webauth_credential.go
Normal 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"`
|
||||
}
|
||||
@@ -52,6 +52,7 @@ gist.edit.change-visibility: Make
|
||||
gist.edit.delete: Delete
|
||||
gist.edit.cancel: Cancel
|
||||
gist.edit.save: Save
|
||||
gist.delete.confirm: Are you sure you want to delete this gist ?
|
||||
|
||||
gist.list.joined: Joined
|
||||
gist.list.all: All gists
|
||||
@@ -143,6 +144,39 @@ auth.password: Password
|
||||
auth.register-instead: Register instead
|
||||
auth.login-instead: Login instead
|
||||
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.page-not-found: Page not found
|
||||
@@ -155,6 +189,7 @@ error.oauth-unsupported: Unsupported provider
|
||||
error.cannot-bind-data: Cannot bind data
|
||||
error.invalid-number: Invalid number
|
||||
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.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-created: Could not create ssh key
|
||||
flash.auth.must-be-logged-in: You must be logged in to access gists
|
||||
flash.auth.passkey-registred: Passkey %s registered
|
||||
flash.auth.passkey-deleted: Passkey deleted
|
||||
|
||||
flash.gist.visibility-changed: Gist visibility has been changed
|
||||
flash.gist.deleted: Gist has been deleted
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
gist.public: Herkese Açık
|
||||
gist.unlisted: Liste Dışı
|
||||
gist.unlisted: Listelenmemiş
|
||||
gist.private: Gizli
|
||||
|
||||
gist.header.like: Beğen
|
||||
@@ -7,19 +7,19 @@ gist.header.unlike: Beğenmekten Vazgeç
|
||||
gist.header.fork: Çatalla
|
||||
gist.header.edit: Düzenle
|
||||
gist.header.delete: Sil
|
||||
gist.header.forked-from: Çatallı
|
||||
gist.header.last-active: Son aktif
|
||||
gist.header.forked-from: Çatallandı
|
||||
gist.header.last-active: Son aktivite
|
||||
gist.header.select-tab: Bir sekme seç
|
||||
gist.header.code: Kod
|
||||
gist.header.revisions: Revizyonlar
|
||||
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-ssh: SSH aracılığıyla klonla
|
||||
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.download-zip: ZIP'i indirin
|
||||
gist.header.download-zip: ZIP'i İndir
|
||||
|
||||
gist.raw: Ham
|
||||
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.title: Başlık
|
||||
gist.new.description: Description
|
||||
gist.new.description: Açıklama
|
||||
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-space: Boşluk
|
||||
gist.new.indent-mode-tab: Tab
|
||||
gist.new.indent-mode-tab: Sekme
|
||||
gist.new.indent-size: Girinti boyutu
|
||||
gist.new.wrap-mode: ''
|
||||
gist.new.wrap-mode-no: ''
|
||||
gist.new.wrap-mode-soft: ''
|
||||
gist.new.add-file: Add file
|
||||
gist.new.wrap-mode: 'Satır kaydırma modu'
|
||||
gist.new.wrap-mode-no: 'Kaydırma yok'
|
||||
gist.new.wrap-mode-soft: 'Yumuşak kaydırma'
|
||||
gist.new.add-file: Dosya ekle
|
||||
gist.new.create-public-button: Herkese açık gist oluştur
|
||||
gist.new.create-unlisted-button: Liste dışı 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.edit.editing: Düzenleme
|
||||
gist.edit.editing: Düzenleniyor
|
||||
gist.edit.edit-gist: '%s düzenle'
|
||||
gist.edit.change-visibility: ''
|
||||
gist.edit.delete: Delete
|
||||
gist.edit.change-visibility: 'Yap'
|
||||
gist.edit.delete: Sil
|
||||
gist.edit.cancel: İptal Et
|
||||
gist.edit.save: Kaydet
|
||||
|
||||
@@ -58,210 +58,211 @@ gist.list.all: Tüm gistler
|
||||
gist.list.search-results: Arama sonuçları
|
||||
gist.list.sort: Sırala
|
||||
gist.list.sort-by-created: oluşturuldu
|
||||
gist.list.sort-by-updated: düzenlendi
|
||||
gist.list.order-by-asc: En son yakın zamanda
|
||||
gist.list.order-by-desc: Son zamanlarda
|
||||
gist.list.sort-by-updated: güncellendi
|
||||
gist.list.order-by-asc: En eski
|
||||
gist.list.order-by-desc: Yakın zamanda
|
||||
gist.list.select-tab: Bir sekme seçin
|
||||
gist.list.liked: Beğenildi
|
||||
gist.list.likes: beğeniler
|
||||
gist.list.forked: Çatallı
|
||||
gist.list.forked-from: çatallandı
|
||||
gist.list.forked: Çatallandı
|
||||
gist.list.forked-from: Çatallandı
|
||||
gist.list.forks: çatallar
|
||||
gist.list.files: files
|
||||
gist.list.last-active: Son aktif
|
||||
gist.list.no-gists: Gistler yok
|
||||
gist.list.files: dosyalar
|
||||
gist.list.last-active: Son aktivite
|
||||
gist.list.no-gists: Gist yok
|
||||
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-from: '%s tüm gistleri'
|
||||
gist.list.all-forked-by: '%s tarafından çatallanan tüm gistler'
|
||||
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.help.user: gists created by user
|
||||
gist.search.help.title: gists with given title
|
||||
gist.search.help.filename: gists having files with given name
|
||||
gist.search.help.extension: gists having files with given extension
|
||||
gist.search.help.language: gists having files with given language
|
||||
gist.search.help.user: kullanıcı tarafından oluşturulan gistler
|
||||
gist.search.help.title: belirtilen isme sahip gistler
|
||||
gist.search.help.filename: belirtilen isimde dosyaları olan gistler
|
||||
gist.search.help.extension: belirtilen uzantıya sahip dosyalara sahip gistler
|
||||
gist.search.help.language: belirtilen dilde dosyaları olan gistler
|
||||
|
||||
gist.forks: Forks
|
||||
gist.forks.view: View fork
|
||||
gist.forks.no: No public forks
|
||||
gist.forks.for: Forks for %s
|
||||
gist.forks: Çatallar
|
||||
gist.forks.view: Çatalı görüntüle
|
||||
gist.forks.no: Herkes açık çatal yok
|
||||
gist.forks.for: '%s için çatallar'
|
||||
|
||||
gist.likes: Likes
|
||||
gist.likes.no: No likes yet
|
||||
gist.likes.for: Likes for %s
|
||||
gist.likes: Beğeniler
|
||||
gist.likes.no: Henüz kimse beğenmedi
|
||||
gist.likes.for: '%s için beğeniler'
|
||||
|
||||
gist.revisions: Revisions
|
||||
gist.revision.revised: revised this gist
|
||||
gist.revision.go-to-revision: Go to revision
|
||||
gist.revision.file-created: file created
|
||||
gist.revision.file-deleted: file deleted
|
||||
gist.revision.file-renamed: renamed to
|
||||
gist.revision.diff-truncated: Diff is too large to be shown
|
||||
gist.revision.file-renamed-no-changes: File renamed without changes
|
||||
gist.revision.empty-file: Empty file
|
||||
gist.revision.no-changes: No changes
|
||||
gist.revision.no-revisions: No revisions to show
|
||||
gist.revision-of: Revision of %s
|
||||
gist.revisions: Düzenlemeler
|
||||
gist.revision.revised: bu gisti düzenledi
|
||||
gist.revision.go-to-revision: Düzenlemeye git
|
||||
gist.revision.file-created: dosya oluşturuldu
|
||||
gist.revision.file-deleted: dosya silindi
|
||||
gist.revision.file-renamed: yeniden adlandırıldı
|
||||
gist.revision.diff-truncated: Fark gösterilemeyecek kadar büyük
|
||||
gist.revision.file-renamed-no-changes: Dosya değişiklik yapılmadan yeniden adlandırıldı
|
||||
gist.revision.empty-file: Boş dosya
|
||||
gist.revision.no-changes: Değişiklik yok
|
||||
gist.revision.no-revisions: Gösterilecek düzenleme yok
|
||||
gist.revision-of: '%s düzenlemesi'
|
||||
|
||||
settings: Settings
|
||||
settings.email: Email
|
||||
settings.email-help: Used for commits and Gravatar
|
||||
settings.email-set: Set email
|
||||
settings.link-accounts: Link accounts
|
||||
settings.link-github-account: Link GitHub account
|
||||
settings.link-gitlab-account: Link GitLab account
|
||||
settings.link-gitea-account: Link Gitea account
|
||||
settings.unlink-github-account: Unlink GitHub account
|
||||
settings.unlink-gitlab-account: Unlink GitLab account
|
||||
settings.unlink-gitea-account: Unlink Gitea account
|
||||
settings.delete-account: Delete account
|
||||
settings.delete-account-confirm: Are you sure you want to delete your account ?
|
||||
settings.add-ssh-key: Add SSH key
|
||||
settings.add-ssh-key-help: Used only to pull/push gists using Git via SSH
|
||||
settings.add-ssh-key-title: Title
|
||||
settings.add-ssh-key-content: Key
|
||||
settings.delete-ssh-key: Delete
|
||||
settings.delete-ssh-key-confirm: Confirm deletion of SSH key
|
||||
settings.ssh-key-added-at: Added
|
||||
settings.ssh-key-never-used: Never used
|
||||
settings.ssh-key-last-used: Last used
|
||||
settings.change-username: Change username
|
||||
settings.create-password: Create password
|
||||
settings.create-password-help: Create your password to login to Opengist via HTTP
|
||||
settings.change-password: Change password
|
||||
settings.change-password-help: Change your password to login to Opengist via HTTP
|
||||
settings.password-label-title: Password
|
||||
settings: Ayarlar
|
||||
settings.email: E-Posta
|
||||
settings.email-help: Commit ve Gravatar için kullanılır
|
||||
settings.email-set: E-postayı ayarla
|
||||
settings.link-accounts: Hesapları bağla
|
||||
settings.link-github-account: GitHub hesabını bağla
|
||||
settings.link-gitlab-account: GitLab hesabını bağla
|
||||
settings.link-gitea-account: Gitea hesabını bağla
|
||||
settings.unlink-github-account: GitHub hesabının bağlantısını kaldır
|
||||
settings.unlink-gitlab-account: GitLab hesabının bağlantısını kaldır
|
||||
settings.unlink-gitea-account: Gitea hesabının bağlantısını kaldır
|
||||
settings.delete-account: Hesabı sil
|
||||
settings.delete-account-confirm: Hesabını silmek istediğinden emin misin?
|
||||
settings.add-ssh-key: SSH anahtarı ekle
|
||||
settings.add-ssh-key-help: Sadece SSH üzerinden Git kullanarak gistlerin pull/push işlemlerinde kullanılır
|
||||
settings.add-ssh-key-title: Başlık
|
||||
settings.add-ssh-key-content: Anahtar
|
||||
settings.delete-ssh-key: Sil
|
||||
settings.delete-ssh-key-confirm: SSH anahtarının silinmesini onaylayın
|
||||
settings.ssh-key-added-at: Eklendi
|
||||
settings.ssh-key-never-used: Hiç kullanılmadı
|
||||
settings.ssh-key-last-used: Son kullanma
|
||||
settings.change-username: Kullanıcı adını değiştir
|
||||
settings.create-password: Parola oluştur
|
||||
settings.create-password-help: HTTP üzerinden Opengist'e giriş yapmak için parolanızı oluşturun
|
||||
settings.change-password: Parolayı değiştir
|
||||
settings.change-password-help: HTTP üzerinden Opengist'e giriş yapmak için parolanızı değiştirin
|
||||
settings.password-label-title: Parola
|
||||
|
||||
auth.signup-disabled: Administrator has disabled signing up
|
||||
auth.login: Login
|
||||
auth.signup: Register
|
||||
auth.new-account: New account
|
||||
auth.username: Username
|
||||
auth.password: Password
|
||||
auth.register-instead: Register instead
|
||||
auth.login-instead: Login instead
|
||||
auth.oauth: Continue with %s account
|
||||
auth.signup-disabled: Yönetici kaydolmayı devre dışı bıraktı
|
||||
auth.login: Giriş
|
||||
auth.signup: Kaydol
|
||||
auth.new-account: Yeni hesap
|
||||
auth.username: Kullanıcı adı
|
||||
auth.password: Parola
|
||||
auth.register-instead: Bunun yerine kayıt olun
|
||||
auth.login-instead: Bunun yerine giriş yapın
|
||||
auth.oauth: '%s hesabı ile devam et'
|
||||
|
||||
error: Error
|
||||
error.page-not-found: Page not found
|
||||
error.bad-request: Bad request
|
||||
error.signup-disabled: Signing up is disabled
|
||||
error.signup-disabled-form: Signing up via registration form is disabled
|
||||
error.login-disabled-form: Logging in via login form is disabled
|
||||
error.complete-oauth-login: "Cannot complete user auth: %s"
|
||||
error.oauth-unsupported: Unsupported provider
|
||||
error.cannot-bind-data: Cannot bind data
|
||||
error.invalid-number: Invalid number
|
||||
error.invalid-character-unescaped: Invalid character unescaped
|
||||
error: Hata
|
||||
error.page-not-found: Sayfa bulunamadı
|
||||
error.bad-request: Geçersiz istek
|
||||
error.signup-disabled: Kullanıcı kaydı devre dışı bırakıldı
|
||||
error.signup-disabled-form: Kayıt formu aracılığıyla kullanıcı kaydı devre dışı bırakıldı
|
||||
error.login-disabled-form: Giriş formu üzerinden giriş yapma devre dışı bırakıldı
|
||||
error.complete-oauth-login: "Kullanıcı kimlik doğrulaması tamamlanamıyor: %s"
|
||||
error.oauth-unsupported: Desteklenmeyen sağlayıcı
|
||||
error.cannot-bind-data: Veri bağlanamıyor
|
||||
error.invalid-number: Geçersiz numara
|
||||
error.invalid-character-unescaped: Geçersiz karakter için kaçış işlemi yapılamadı
|
||||
|
||||
header.menu.all: All
|
||||
header.menu.new: New
|
||||
header.menu.search: Search
|
||||
header.menu.my-gists: My gists
|
||||
header.menu.liked: Liked
|
||||
header.menu.admin: Admin
|
||||
header.menu.settings: Settings
|
||||
header.menu.logout: Logout
|
||||
header.menu.register: Register
|
||||
header.menu.login: Login
|
||||
header.menu.light: Light
|
||||
header.menu.dark: Dark
|
||||
header.menu.system: System
|
||||
footer.powered-by: Powered by %s
|
||||
header.menu.all: Tümü
|
||||
header.menu.new: Yeni
|
||||
header.menu.search: Ara
|
||||
header.menu.my-gists: Gistlerim
|
||||
header.menu.liked: Beğenilen
|
||||
header.menu.admin: Yönetici
|
||||
header.menu.settings: Ayarlar
|
||||
header.menu.logout: Çıkış
|
||||
header.menu.register: Kaydol
|
||||
header.menu.login: Giriş
|
||||
header.menu.light: Açık
|
||||
header.menu.dark: Koyu
|
||||
header.menu.system: Sistem
|
||||
footer.powered-by: '%s tarafından desteklenmektedir'
|
||||
|
||||
pagination.older: Older
|
||||
pagination.newer: Newer
|
||||
pagination.previous: Previous
|
||||
pagination.next: Next
|
||||
pagination.older: Daha eski
|
||||
pagination.newer: Daha yeni
|
||||
pagination.previous: Önceki
|
||||
pagination.next: Sonraki
|
||||
|
||||
admin.admin_panel: Admin panel
|
||||
admin.general: General
|
||||
admin.users: Users
|
||||
admin.gists: Gists
|
||||
admin.configuration: Configuration
|
||||
admin.invitations: Invitations
|
||||
admin.invitations.create: Create invitation
|
||||
admin.versions: Versions
|
||||
admin.ssh_keys: SSH keys
|
||||
admin.stats: Stats
|
||||
admin.actions: Actions
|
||||
admin.actions.sync-fs: Synchronize gists from filesystem
|
||||
admin.actions.sync-db: Synchronize gists from database
|
||||
admin.actions.git-gc: Garbage collect all git repositories
|
||||
admin.actions.sync-previews: Synchronize all gists previews
|
||||
admin.actions.reset-hooks: Reset Git server hooks for all repositories
|
||||
admin.actions.index-gists: Index all gists
|
||||
admin.admin_panel: Yönetici paneli
|
||||
admin.general: Genel
|
||||
admin.users: Kullanıcılar
|
||||
admin.gists: Gistler
|
||||
admin.configuration: Yapılandırma
|
||||
admin.invitations: Davetler
|
||||
admin.invitations.create: Davet oluştur
|
||||
admin.versions: Sürümler
|
||||
admin.ssh_keys: SSH anahtarları
|
||||
admin.stats: İstatistikler
|
||||
admin.actions: Eylemler
|
||||
admin.actions.sync-fs: Gistleri dosya sisteminden senkronize et
|
||||
admin.actions.sync-db: Gistleri veri tabanından senkronize et
|
||||
admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
|
||||
admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et
|
||||
admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla
|
||||
admin.actions.index-gists: Tüm gistleri indeksle
|
||||
admin.id: ID
|
||||
admin.user: User
|
||||
admin.delete: Delete
|
||||
admin.created_at: Created
|
||||
admin.user: Kullanıcı
|
||||
admin.delete: Sil
|
||||
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-overriden: overridden
|
||||
admin.disable-signup: Disable signup
|
||||
admin.disable-signup_help: Forbid the creation of new accounts.
|
||||
admin.require-login: Require login
|
||||
admin.require-login_help: Enforce users to be logged in to see gists.
|
||||
admin.disable-login: Disable login form
|
||||
admin.disable-login_help: Forbid logging in via the login form to force using OAuth providers instead.
|
||||
admin.disable-gravatar: Disable Gravatar
|
||||
admin.disable-gravatar_help: Disable the usage of Gravatar as an avatar provider.
|
||||
admin.allow-gists-without-login:
|
||||
admin.allow-gists-without-login_help:
|
||||
admin.users.delete_confirm: Do you want to delete this user ?
|
||||
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: geçersiz kılınabilir
|
||||
admin.disable-signup: Kayıt işlemini devre dışı bırak
|
||||
admin.disable-signup_help: Yeni hesap oluşturulmasını yasaklar.
|
||||
admin.require-login: Giriş gerektir
|
||||
admin.require-login_help: Gistleri görebilmek için kullanıcıların oturum açmasını zorunlu kılar.
|
||||
admin.disable-login: Giriş formunu devre dışı bırak
|
||||
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: Gravatar'ı devre dışı bırak
|
||||
admin.disable-gravatar_help: Gravatar'ın avatar sağlayıcı olarak kullanımını devre dışı bırakır.
|
||||
admin.allow-gists-without-login: Bireysel gistlere oturum açmadan izin ver
|
||||
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: Bu kullanıcıyı silmek istiyor musun?
|
||||
|
||||
admin.gists.title: Title
|
||||
admin.gists.private: Private ?
|
||||
admin.gists.nb-files: Nb. files
|
||||
admin.gists.nb-likes: Nb. likes
|
||||
admin.gists.delete_confirm: Do you want to delete this gist ?
|
||||
admin.gists.title: Başlık
|
||||
admin.gists.private: Gizlilik
|
||||
admin.gists.nb-files: Dosyalar
|
||||
admin.gists.nb-likes: Beğeniler
|
||||
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.max_uses: Max uses
|
||||
admin.invitations.expires_at: Expires at
|
||||
admin.invitations.code: Code
|
||||
admin.invitations.copy_link: Copy link
|
||||
admin.invitations.uses: Uses
|
||||
admin.invitations.expired: Expired
|
||||
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: Maksimum kullanım
|
||||
admin.invitations.expires_at: Sona erme tarihi
|
||||
admin.invitations.code: Kod
|
||||
admin.invitations.copy_link: Linki kopyala
|
||||
admin.invitations.uses: Kullanım
|
||||
admin.invitations.expired: Süresi bitti
|
||||
|
||||
flash.admin.user-deleted: User has been deleted
|
||||
flash.admin.gist-deleted: Gist has been deleted
|
||||
flash.admin.invitation-created: Invitation has been created
|
||||
flash.admin.invitation-deleted: Invitation has been deleted
|
||||
flash.admin.sync-fs: Syncing repositories from filesystem...
|
||||
flash.admin.sync-db: Syncing repositories from database...
|
||||
flash.admin.git-gc: Garbage collecting repositories...
|
||||
flash.admin.sync-previews: Syncing Gist previews...
|
||||
flash.admin.reset-hooks: Resetting Git server hooks for all repositories...
|
||||
flash.admin.index-gists: Indexing all gists...
|
||||
flash.admin.user-deleted: Kullanıcı silindi
|
||||
flash.admin.gist-deleted: Gist silindi
|
||||
flash.admin.invitation-created: Davetiye oluşturuldu
|
||||
flash.admin.invitation-deleted: Davetiye silindi
|
||||
flash.admin.sync-fs: Depolar dosya sisteminden senkronize ediliyor...
|
||||
flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor...
|
||||
flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor...
|
||||
flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor...
|
||||
flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor...
|
||||
flash.admin.index-gists: Tüm gistler indeksleniyor...
|
||||
|
||||
flash.auth.username-exists: Username already exists
|
||||
flash.auth.invalid-credentials: Invalid credentials
|
||||
flash.auth.account-linked-oauth: Account linked to %s
|
||||
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-created: Could not create ssh key
|
||||
flash.auth.must-be-logged-in: You must be logged in to access gists
|
||||
flash.auth.username-exists: Kullanıcı adı zaten mevcut
|
||||
flash.auth.invalid-credentials: Geçersiz kimlik bilgileri
|
||||
flash.auth.account-linked-oauth: Hesap %s ile bağlantılı
|
||||
flash.auth.account-unlinked-oauth: Hesabın %s ile bağlantısı kaldırıldı
|
||||
flash.auth.user-sshkeys-not-retrievable: Kullanıcı anahtarları alınamadı
|
||||
flash.auth.user-sshkeys-not-created: SSH anahtarı oluşturulamadı
|
||||
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.deleted: Gist has been deleted
|
||||
flash.gist.fork-own-gist: Unable to fork own gists
|
||||
flash.gist.forked: Gist has been forked
|
||||
flash.gist.visibility-changed: Gist görünürlüğü değiştirildi
|
||||
flash.gist.deleted: Gist silindi
|
||||
flash.gist.fork-own-gist: Kendi gistlerin çatallanamaz
|
||||
flash.gist.forked: Gist çatallandı
|
||||
|
||||
flash.user.email-updated: Email updated
|
||||
flash.user.invalid-ssh-key: Invalid SSH key
|
||||
flash.user.ssh-key-added: SSH key added
|
||||
flash.user.ssh-key-deleted: SSH key deleted
|
||||
flash.user.password-updated: Password updated
|
||||
flash.user.username-updated: Username updated
|
||||
flash.user.email-updated: E-posta güncellendi
|
||||
flash.user.invalid-ssh-key: Geçersiz SSH anahtarı
|
||||
flash.user.ssh-key-added: SSH anahtarı eklendi
|
||||
flash.user.ssh-key-deleted: SSH anahtarı silindi
|
||||
flash.user.password-updated: Parola güncellendi
|
||||
flash.user.username-updated: Kullanıcı adı güncellendi
|
||||
|
||||
validation.is-too-long: Field %s is too long
|
||||
validation.should-not-be-empty: Field %s should not be empty
|
||||
validation.should-not-include-sub-directory: Field %s should not include a sub directory
|
||||
validation.should-only-contain-alphanumeric-characters: Field %s should only contain alphanumeric characters
|
||||
validation.should-only-contain-alphanumeric-characters-and-dashes: Field %s should only contain alphanumeric characters and dashes
|
||||
validation.not-enough: Not enough %s
|
||||
validation.invalid: Invalid %s
|
||||
validation.is-too-long: '%s alanı çok uzun'
|
||||
validation.should-not-be-empty: '%s alanı boş olmamalı'
|
||||
validation.should-not-include-sub-directory: '%s alanı bir alt dizin içermemeli'
|
||||
validation.should-only-contain-alphanumeric-characters: '%s alanı yalnızca alfanümerik karakterler içermelidir'
|
||||
validation.should-only-contain-alphanumeric-characters-and-dashes: '%s alanı yalnızca alfanümerik karakterler ve tire içermelidir'
|
||||
validation.not-enough: Yeterli %s yok
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ gist.header.edit: 编辑
|
||||
gist.header.delete: 删除
|
||||
gist.header.forked-from: 派生自
|
||||
gist.header.last-active: 最后活跃于
|
||||
gist.header.select-tab: Select a tab
|
||||
gist.header.select-tab: 选择一个选项
|
||||
gist.header.code: 代码
|
||||
gist.header.revisions: 修订
|
||||
gist.header.revision: 修订
|
||||
@@ -17,7 +17,7 @@ gist.header.clone-http: 通过 %s 克隆
|
||||
gist.header.clone-http-help: 使用 Git 通过 HTTP 基础认证克隆。
|
||||
gist.header.clone-ssh: 通过 SSH 克隆
|
||||
gist.header.clone-ssh-help: 使用 Git 通过 SSH 密钥克隆。
|
||||
gist.header.embed: ''
|
||||
gist.header.embed: '嵌入'
|
||||
gist.header.embed-help: '在你的网页中嵌入此gist。'
|
||||
gist.header.download-zip: 下载 ZIP
|
||||
|
||||
@@ -49,7 +49,7 @@ gist.edit.delete: 删除
|
||||
gist.edit.cancel: 取消
|
||||
gist.edit.save: 保存
|
||||
|
||||
gist.list.joined: Joined
|
||||
gist.list.joined: 已加入
|
||||
gist.list.all: 所有 Gists
|
||||
gist.list.search-results: 搜索结果
|
||||
gist.list.sort: 排序
|
||||
@@ -77,11 +77,11 @@ gist.likes.no: 还没有喜欢
|
||||
gist.revisions: 修订
|
||||
gist.revision.revised: 修订了这个 Gist
|
||||
gist.revision.go-to-revision: 跳至此修订
|
||||
gist.revision.file-created: file created
|
||||
gist.revision.file-deleted: file deleted
|
||||
gist.revision.file-created: 文件已创建
|
||||
gist.revision.file-deleted: 文件已被删除
|
||||
gist.revision.file-renamed: 重命名为
|
||||
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.no-changes: 没有变更
|
||||
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-content: 密钥
|
||||
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-never-used: 从未使用过
|
||||
settings.ssh-key-last-used: 最后使用于
|
||||
@@ -123,7 +123,7 @@ header.menu.all: 全部
|
||||
header.menu.new: 创建
|
||||
header.menu.search: 搜索
|
||||
header.menu.my-gists: 我的 Gists
|
||||
header.menu.liked: Liked
|
||||
header.menu.liked: 喜欢的 Gists
|
||||
header.menu.admin: 管理
|
||||
header.menu.settings: 设置
|
||||
header.menu.logout: 登出
|
||||
@@ -166,8 +166,8 @@ admin.disable-login: 禁用登录表单
|
||||
admin.disable-login_help: 禁止使用登录表单进行登录以强制通过 OAuth 提供方登录。
|
||||
admin.disable-gravatar: 禁用 Gravatar
|
||||
admin.disable-gravatar_help: 停止使用 Gravatar 作为头像提供方。
|
||||
admin.allow-gists-without-login:
|
||||
admin.allow-gists-without-login_help:
|
||||
admin.allow-gists-without-login: 允许未登录状态下输入个人 gists
|
||||
admin.allow-gists-without-login_help: 允许在不登录的情况下查看和下载 gist,同时需要登录才能使用 gists 的发现功能。
|
||||
admin.users.delete_confirm: 你想要删除此用户吗?
|
||||
|
||||
admin.gists.title: 标题
|
||||
@@ -176,84 +176,103 @@ admin.gists.nb-files: 文件数
|
||||
admin.gists.nb-likes: 喜欢数
|
||||
admin.gists.delete_confirm: 你想要删除此 Gist 吗?
|
||||
gist.new.url: 'URL'
|
||||
gist.new.preview: ''
|
||||
error.page-not-found: ''
|
||||
gist.new.preview: '预览 gist'
|
||||
error.page-not-found: '页面未找到'
|
||||
gist.new.create-a-new-gist: '创建一个新的gist'
|
||||
gist.edit.edit-gist: ''
|
||||
gist.list.all-liked-by: ''
|
||||
gist.list.all-forked-by: ''
|
||||
gist.list.all-from: ''
|
||||
gist.search.found: ''
|
||||
gist.edit.edit-gist: '编辑 %s'
|
||||
gist.list.all-liked-by: '所有 gists 被 %s 标记喜欢'
|
||||
gist.list.all-forked-by: '所有 gists 被 %s 派生'
|
||||
gist.list.all-from: '所有 gists 来自于 %s'
|
||||
gist.search.found: '以下是找到的 gists'
|
||||
gist.search.no-results: '没有找到gist'
|
||||
gist.search.help.user: '由用户创建的gist'
|
||||
gist.search.help.title: '给定标题的gist'
|
||||
gist.search.help.filename: ''
|
||||
gist.search.help.extension: ''
|
||||
gist.search.help.language: ''
|
||||
gist.forks.for: ''
|
||||
gist.likes.for: ''
|
||||
gist.revision-of: ''
|
||||
settings.link-gitlab-account: ''
|
||||
settings.unlink-gitlab-account: ''
|
||||
settings.change-username: ''
|
||||
settings.create-password: ''
|
||||
settings.create-password-help: ''
|
||||
settings.change-password: ''
|
||||
settings.change-password-help: ''
|
||||
settings.password-label-title: ''
|
||||
error.bad-request: ''
|
||||
error.signup-disabled: ''
|
||||
error.signup-disabled-form: ''
|
||||
error.login-disabled-form: ''
|
||||
error.complete-oauth-login: ''
|
||||
error.oauth-unsupported: ''
|
||||
error.cannot-bind-data: ''
|
||||
error.invalid-number: ''
|
||||
error.invalid-character-unescaped: ''
|
||||
admin.invitations: ''
|
||||
admin.invitations.create: ''
|
||||
admin.actions.sync-previews: ''
|
||||
admin.actions.reset-hooks: ''
|
||||
admin.actions.index-gists: ''
|
||||
admin.invitations.help: ''
|
||||
admin.invitations.max_uses: ''
|
||||
admin.invitations.expires_at: ''
|
||||
admin.invitations.code: ''
|
||||
admin.invitations.copy_link: ''
|
||||
admin.invitations.uses: ''
|
||||
admin.invitations.expired: ''
|
||||
flash.admin.user-deleted: ''
|
||||
flash.admin.gist-deleted: ''
|
||||
flash.admin.invitation-created: ''
|
||||
flash.admin.invitation-deleted: ''
|
||||
flash.admin.sync-fs: ''
|
||||
flash.admin.sync-db: ''
|
||||
flash.admin.git-gc: ''
|
||||
flash.admin.sync-previews: ''
|
||||
flash.admin.reset-hooks: ''
|
||||
flash.admin.index-gists: ''
|
||||
flash.auth.username-exists: ''
|
||||
flash.auth.invalid-credentials: ''
|
||||
flash.auth.account-linked-oauth: ''
|
||||
flash.auth.account-unlinked-oauth: ''
|
||||
flash.auth.user-sshkeys-not-retrievable: ''
|
||||
flash.auth.user-sshkeys-not-created: ''
|
||||
flash.auth.must-be-logged-in: ''
|
||||
flash.gist.visibility-changed: ''
|
||||
flash.gist.deleted: ''
|
||||
flash.gist.fork-own-gist: ''
|
||||
flash.gist.forked: ''
|
||||
flash.user.email-updated: ''
|
||||
flash.user.invalid-ssh-key: ''
|
||||
flash.user.ssh-key-added: ''
|
||||
flash.user.ssh-key-deleted: ''
|
||||
flash.user.password-updated: ''
|
||||
flash.user.username-updated: ''
|
||||
validation.is-too-long: ''
|
||||
validation.should-not-be-empty: ''
|
||||
validation.should-not-include-sub-directory: ''
|
||||
validation.should-only-contain-alphanumeric-characters: ''
|
||||
validation.should-only-contain-alphanumeric-characters-and-dashes: ''
|
||||
validation.not-enough: ''
|
||||
validation.invalid: ''
|
||||
html.title.admin-panel: ''
|
||||
gist.search.help.title: '包含指定标题的 gists'
|
||||
gist.search.help.filename: 'gists 文件中包含指定名称'
|
||||
gist.search.help.extension: 'gists 文件中包含指定插件'
|
||||
gist.search.help.language: 'gists 文件中包含指定的开发语言'
|
||||
gist.forks.for: '派生到 %s'
|
||||
gist.likes.for: '喜欢给 %s'
|
||||
gist.revision-of: '被 %s 修订'
|
||||
settings.link-gitlab-account: '关联 GitLab 账号'
|
||||
settings.unlink-gitlab-account: '解除关联 GitLab 账号'
|
||||
settings.change-username: '修改用户名'
|
||||
settings.create-password: '创建密码'
|
||||
settings.create-password-help: '创建密码用于 HTTP 方式登录 Opengist'
|
||||
settings.change-password: '修改密码'
|
||||
settings.change-password-help: '修改您的密码用于 HTTP 方式登录 Opengist'
|
||||
settings.password-label-title: '密码'
|
||||
error.bad-request: '请求错误'
|
||||
error.signup-disabled: '注册功能已被管理员禁用'
|
||||
error.signup-disabled-form: '已禁用了表单注册功能'
|
||||
error.login-disabled-form: '已禁用了表单登录功能'
|
||||
error.complete-oauth-login: '用户认证未能通过: %s'
|
||||
error.oauth-unsupported: '不支持的认证提供商'
|
||||
error.cannot-bind-data: '无法绑定数据'
|
||||
error.invalid-number: '数字格式不正确'
|
||||
error.invalid-character-unescaped: '包含未转义的无效字符'
|
||||
admin.invitations: '邀请'
|
||||
admin.invitations.create: '创建邀请'
|
||||
admin.actions.sync-previews: '同步所有 gists 预览'
|
||||
admin.actions.reset-hooks: '重置所有存储库的 Git 服务hooks'
|
||||
admin.actions.index-gists: '索引所有 gists'
|
||||
admin.invitations.help: '即使在禁用注册功能的情况下,邀请功能也可用于创建帐户。'
|
||||
admin.invitations.max_uses: '最多使用次数'
|
||||
admin.invitations.expires_at: '过期时间'
|
||||
admin.invitations.code: '邀请码'
|
||||
admin.invitations.copy_link: '复制链接'
|
||||
admin.invitations.uses: '使用次数'
|
||||
admin.invitations.expired: '已到期'
|
||||
flash.admin.user-deleted: '用户已删除'
|
||||
flash.admin.gist-deleted: 'Gist 已删除'
|
||||
flash.admin.invitation-created: '该邀请已被创建'
|
||||
flash.admin.invitation-deleted: '该邀请已被删除'
|
||||
flash.admin.sync-fs: '正在从文件系统同步存储库...'
|
||||
flash.admin.sync-db: '正在从数据库同步存储库...'
|
||||
flash.admin.git-gc: '正在进行存储库垃圾回收...'
|
||||
flash.admin.sync-previews: '正在同步 Gist 预览...'
|
||||
flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...'
|
||||
flash.admin.index-gists: '索引所有 gists...'
|
||||
flash.auth.username-exists: '用户名已存在'
|
||||
flash.auth.invalid-credentials: '无效的凭证'
|
||||
flash.auth.account-linked-oauth: '帐户已关联到 %s'
|
||||
flash.auth.account-unlinked-oauth: '帐户与 %s 解除关联'
|
||||
flash.auth.user-sshkeys-not-retrievable: '无法获取用户密钥'
|
||||
flash.auth.user-sshkeys-not-created: '无法创建 ssh 密钥'
|
||||
flash.auth.must-be-logged-in: '您必须登录才能访问 gists'
|
||||
flash.gist.visibility-changed: 'Gist可见性已更改'
|
||||
flash.gist.deleted: 'Gist已被删除'
|
||||
flash.gist.fork-own-gist: '无法派生自己的要点'
|
||||
flash.gist.forked: 'Gist 已被派生'
|
||||
flash.user.email-updated: '电子邮件已更新'
|
||||
flash.user.invalid-ssh-key: 'SSH 密钥无效'
|
||||
flash.user.ssh-key-added: 'SSH 密钥已添加'
|
||||
flash.user.ssh-key-deleted: 'SSH 密钥已删除'
|
||||
flash.user.password-updated: '密码已更新'
|
||||
flash.user.username-updated: '用户名已更新'
|
||||
validation.is-too-long: '字段 %s 太长'
|
||||
validation.should-not-be-empty: '字段 %s 不能为空'
|
||||
validation.should-not-include-sub-directory: '字段 %s 不应包含子目录'
|
||||
validation.should-only-contain-alphanumeric-characters: '字段 %s 只能包含字母、数字、字符'
|
||||
validation.should-only-contain-alphanumeric-characters-and-dashes: '字段 %s 应仅包含字母、数字、字符和 ‘-’'
|
||||
validation.not-enough: '还不够 %s'
|
||||
validation.invalid: '无效 %s'
|
||||
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: 通行密钥
|
||||
|
||||
@@ -3,6 +3,7 @@ package render
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/formatters/html"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"path"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -28,18 +30,22 @@ type RenderedGist struct {
|
||||
}
|
||||
|
||||
func HighlightFile(file *git.File) (RenderedFile, error) {
|
||||
rendered := RenderedFile{
|
||||
File: file,
|
||||
}
|
||||
|
||||
style := newStyle()
|
||||
lexer := newLexer(file.Filename)
|
||||
|
||||
if lexer.Config().Name == "markdown" {
|
||||
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))
|
||||
|
||||
rendered := RenderedFile{
|
||||
File: file,
|
||||
}
|
||||
|
||||
iterator, err := lexer.Tokenise(nil, file.Content+"\n")
|
||||
if err != nil {
|
||||
return rendered, err
|
||||
@@ -140,6 +146,20 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
|
||||
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 {
|
||||
fileType := config.Name
|
||||
if fileType == "fallback" || fileType == "plaintext" {
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"github.com/Kunde21/markdownfmt/v3"
|
||||
"github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"github.com/yuin/goldmark"
|
||||
emoji "github.com/yuin/goldmark-emoji"
|
||||
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
astex "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
"go.abhg.dev/goldmark/mermaid"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
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) {
|
||||
var buf bytes.Buffer
|
||||
err := newMarkdown().Convert([]byte(file.Content), &buf)
|
||||
err := newMarkdownWithSvgExtension().Convert([]byte(file.Content), &buf)
|
||||
|
||||
return RenderedFile{
|
||||
File: file,
|
||||
@@ -43,77 +36,34 @@ func MarkdownFile(file *git.File) (RenderedFile, error) {
|
||||
}
|
||||
func MarkdownString(content string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := newMarkdown().Convert([]byte(content), &buf)
|
||||
err := newMarkdownWithSvgExtension().Convert([]byte(content), &buf)
|
||||
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
func newMarkdown() goldmark.Markdown {
|
||||
return goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
highlighting.NewHighlighting(
|
||||
highlighting.WithStyle("catppuccin-latte"),
|
||||
highlighting.WithFormatOptions(html.WithClasses(true))),
|
||||
emoji.Emoji,
|
||||
&mermaid.Extender{},
|
||||
func newMarkdown(extraExtensions ...goldmark.Extender) goldmark.Markdown {
|
||||
extensions := []goldmark.Extender{
|
||||
extension.GFM,
|
||||
highlighting.NewHighlighting(
|
||||
highlighting.WithStyle("catppuccin-latte"),
|
||||
highlighting.WithFormatOptions(html.WithClasses(true)),
|
||||
),
|
||||
emoji.Emoji,
|
||||
&mermaid.Extender{},
|
||||
}
|
||||
|
||||
extensions = append(extensions, extraExtensions...)
|
||||
|
||||
return goldmark.New(
|
||||
goldmark.WithExtensions(extensions...),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithASTTransformers(
|
||||
util.Prioritized(&CheckboxTransformer{}, 10000),
|
||||
util.Prioritized(&checkboxTransformer{}, 10000),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
func newMarkdownWithSvgExtension() goldmark.Markdown {
|
||||
return newMarkdown(&svgToImgBase64{})
|
||||
}
|
||||
|
||||
65
internal/render/markdown_checkbox.go
Normal file
65
internal/render/markdown_checkbox.go
Normal 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
|
||||
}
|
||||
141
internal/render/markdown_svg.go
Normal file
141
internal/render/markdown_svg.go
Normal 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
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error {
|
||||
return errors.New("gist not found")
|
||||
}
|
||||
|
||||
allowUnauthenticated, err := auth.ShouldAllowUnauthenticatedGistAccess(db.DBAuthInfo{}, true)
|
||||
allowUnauthenticated, err := auth.ShouldAllowUnauthenticatedGistAccess(db.AuthInfo{}, true)
|
||||
if err != nil {
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
46
internal/utils/aes.go
Normal file
46
internal/utils/aes.go
Normal 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
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"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)
|
||||
if err == nil {
|
||||
return key
|
||||
return key, false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return key
|
||||
return key, true
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
|
||||
name := fl.Field().String()
|
||||
|
||||
restrictedNames := map[string]struct{}{}
|
||||
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics"} {
|
||||
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn"} {
|
||||
restrictedNames[restrictedName] = struct{}{}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,9 @@ func adminConfig(ctx echo.Context) error {
|
||||
setData(ctx, "htmlTitle", trH(ctx, "admin.configuration")+" - "+trH(ctx, "admin.admin_panel"))
|
||||
setData(ctx, "adminHeaderPage", "config")
|
||||
|
||||
setData(ctx, "dbtype", db.DatabaseInfo.Type.String())
|
||||
setData(ctx, "dbname", db.DatabaseInfo.Database)
|
||||
|
||||
return html(ctx, "admin_config.html")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
gojson "encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
"github.com/markbates/goth/providers/gitlab"
|
||||
"github.com/markbates/goth/providers/openidConnect"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/auth/totp"
|
||||
"github.com/thomiceli/opengist/internal/auth/webauthn"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/i18n"
|
||||
@@ -166,6 +169,18 @@ func processLogin(ctx echo.Context) error {
|
||||
return redirect(ctx, "/login")
|
||||
}
|
||||
|
||||
// handle MFA
|
||||
var hasWebauthn, hasTotp bool
|
||||
if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
|
||||
return errorRes(500, "Cannot check for user MFA", err)
|
||||
}
|
||||
if hasWebauthn || hasTotp {
|
||||
sess.Values["mfaID"] = user.ID
|
||||
sess.Options.MaxAge = 5 * 60 // 5 minutes
|
||||
saveSession(sess, ctx)
|
||||
return redirect(ctx, "/mfa")
|
||||
}
|
||||
|
||||
sess.Values["user"] = user.ID
|
||||
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
|
||||
saveSession(sess, ctx)
|
||||
@@ -174,6 +189,22 @@ func processLogin(ctx echo.Context) error {
|
||||
return redirect(ctx, "/")
|
||||
}
|
||||
|
||||
func mfa(ctx echo.Context) error {
|
||||
var err error
|
||||
|
||||
user := db.User{ID: getSession(ctx).Values["mfaID"].(uint)}
|
||||
|
||||
var hasWebauthn, hasTotp bool
|
||||
if hasWebauthn, hasTotp, err = user.HasMFA(); err != nil {
|
||||
return errorRes(500, "Cannot check for user MFA", err)
|
||||
}
|
||||
|
||||
setData(ctx, "hasWebauthn", hasWebauthn)
|
||||
setData(ctx, "hasTotp", hasTotp)
|
||||
|
||||
return html(ctx, "mfa.html")
|
||||
}
|
||||
|
||||
func oauthCallback(ctx echo.Context) error {
|
||||
user, err := gothic.CompleteUserAuth(ctx.Response(), ctx.Request())
|
||||
if err != nil {
|
||||
@@ -204,6 +235,10 @@ func oauthCallback(ctx echo.Context) error {
|
||||
return errorRes(500, "Cannot get user", err)
|
||||
}
|
||||
|
||||
if user.NickName == "" {
|
||||
user.NickName = strings.Split(user.Email, "@")[0]
|
||||
}
|
||||
|
||||
userDB = &db.User{
|
||||
Username: user.NickName,
|
||||
Email: user.Email,
|
||||
@@ -284,6 +319,20 @@ func oauth(ctx echo.Context) error {
|
||||
httpProtocol = "https"
|
||||
}
|
||||
|
||||
forwarded_hdr := ctx.Request().Header.Get("Forwarded")
|
||||
if forwarded_hdr != "" {
|
||||
fields := strings.Split(forwarded_hdr, ";")
|
||||
fwd := make(map[string]string)
|
||||
for _, v := range fields {
|
||||
p := strings.Split(v, "=")
|
||||
fwd[p[0]] = p[1]
|
||||
}
|
||||
val, ok := fwd["proto"]
|
||||
if ok && val == "https" {
|
||||
httpProtocol = "https"
|
||||
}
|
||||
}
|
||||
|
||||
var opengistUrl string
|
||||
if config.C.ExternalUrl != "" {
|
||||
opengistUrl = config.C.ExternalUrl
|
||||
@@ -342,28 +391,6 @@ func oauth(ctx echo.Context) error {
|
||||
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)
|
||||
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
|
||||
if provider != GitHubProvider && provider != GitLabProvider && provider != GiteaProvider && provider != OpenIDConnect {
|
||||
@@ -374,6 +401,340 @@ func oauth(ctx echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func oauthUnlink(ctx echo.Context) error {
|
||||
provider := ctx.Param("provider")
|
||||
|
||||
currUser := getUserLogged(ctx)
|
||||
// Map each provider to a function that checks the relevant ID in currUser
|
||||
providerIDCheckMap := map[string]func() bool{
|
||||
GitHubProvider: func() bool { return currUser.GithubID != "" },
|
||||
GitLabProvider: func() bool { return currUser.GitlabID != "" },
|
||||
GiteaProvider: func() bool { return currUser.GiteaID != "" },
|
||||
OpenIDConnect: func() bool { return currUser.OIDCID != "" },
|
||||
}
|
||||
|
||||
if checkFunc, exists := providerIDCheckMap[provider]; exists && checkFunc() {
|
||||
if err := currUser.DeleteProviderID(provider); err != nil {
|
||||
return errorRes(500, "Cannot unlink account from "+cases.Title(language.English).String(provider), err)
|
||||
}
|
||||
|
||||
addFlash(ctx, tr(ctx, "flash.auth.account-unlinked-oauth", cases.Title(language.English).String(provider)), "success")
|
||||
return redirect(ctx, "/settings")
|
||||
}
|
||||
|
||||
return redirect(ctx, "/settings")
|
||||
}
|
||||
|
||||
func beginWebAuthnBinding(ctx echo.Context) error {
|
||||
credsCreation, jsonWaSession, err := webauthn.BeginBinding(getUserLogged(ctx))
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot begin WebAuthn registration", err)
|
||||
}
|
||||
|
||||
sess := getSession(ctx)
|
||||
sess.Values["webauthn_registration_session"] = jsonWaSession
|
||||
sess.Options.MaxAge = 5 * 60 // 5 minutes
|
||||
saveSession(sess, ctx)
|
||||
|
||||
return ctx.JSON(200, credsCreation)
|
||||
}
|
||||
|
||||
func finishWebAuthnBinding(ctx echo.Context) error {
|
||||
sess := getSession(ctx)
|
||||
jsonWaSession, ok := sess.Values["webauthn_registration_session"].([]byte)
|
||||
if !ok {
|
||||
return jsonErrorRes(401, "Cannot get WebAuthn registration session", nil)
|
||||
}
|
||||
|
||||
user := getUserLogged(ctx)
|
||||
|
||||
// extract passkey name from request
|
||||
body, err := io.ReadAll(ctx.Request().Body)
|
||||
if err != nil {
|
||||
return jsonErrorRes(400, "Failed to read request body", err)
|
||||
}
|
||||
ctx.Request().Body.Close()
|
||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
dto := new(db.CrendentialDTO)
|
||||
_ = gojson.Unmarshal(body, &dto)
|
||||
|
||||
if err = ctx.Validate(dto); err != nil {
|
||||
return jsonErrorRes(400, "Invalid request", err)
|
||||
}
|
||||
passkeyName := dto.PasskeyName
|
||||
if passkeyName == "" {
|
||||
passkeyName = "WebAuthn"
|
||||
}
|
||||
|
||||
waCredential, err := webauthn.FinishBinding(user, jsonWaSession, ctx.Request())
|
||||
if err != nil {
|
||||
return jsonErrorRes(403, "Failed binding attempt for passkey", err)
|
||||
}
|
||||
|
||||
if _, err = db.CreateFromCrendential(user.ID, passkeyName, waCredential); err != nil {
|
||||
return jsonErrorRes(500, "Cannot create WebAuthn credential on database", err)
|
||||
}
|
||||
|
||||
delete(sess.Values, "webauthn_registration_session")
|
||||
saveSession(sess, ctx)
|
||||
|
||||
addFlash(ctx, tr(ctx, "flash.auth.passkey-registred", passkeyName), "success")
|
||||
return json(ctx, []string{"OK"})
|
||||
}
|
||||
|
||||
func beginWebAuthnLogin(ctx echo.Context) error {
|
||||
credsCreation, jsonWaSession, err := webauthn.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
|
||||
}
|
||||
|
||||
sess := getSession(ctx)
|
||||
sess.Values["webauthn_login_session"] = jsonWaSession
|
||||
sess.Options.MaxAge = 5 * 60 // 5 minutes
|
||||
saveSession(sess, ctx)
|
||||
|
||||
return json(ctx, credsCreation)
|
||||
}
|
||||
|
||||
func finishWebAuthnLogin(ctx echo.Context) error {
|
||||
sess := getSession(ctx)
|
||||
sessionData, ok := sess.Values["webauthn_login_session"].([]byte)
|
||||
if !ok {
|
||||
return jsonErrorRes(401, "Cannot get WebAuthn login session", nil)
|
||||
}
|
||||
|
||||
userID, err := webauthn.FinishDiscoverableLogin(sessionData, ctx.Request())
|
||||
if err != nil {
|
||||
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
|
||||
}
|
||||
|
||||
sess.Values["user"] = userID
|
||||
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
|
||||
|
||||
delete(sess.Values, "webauthn_login_session")
|
||||
saveSession(sess, ctx)
|
||||
|
||||
return json(ctx, []string{"OK"})
|
||||
}
|
||||
|
||||
func beginWebAuthnAssertion(ctx echo.Context) error {
|
||||
sess := getSession(ctx)
|
||||
|
||||
ogUser, err := db.GetUserById(sess.Values["mfaID"].(uint))
|
||||
if err != nil {
|
||||
return jsonErrorRes(500, "Cannot get user", err)
|
||||
}
|
||||
|
||||
credsCreation, jsonWaSession, err := webauthn.BeginLogin(ogUser)
|
||||
if err != nil {
|
||||
return jsonErrorRes(401, "Cannot begin WebAuthn login", err)
|
||||
}
|
||||
|
||||
sess.Values["webauthn_assertion_session"] = jsonWaSession
|
||||
sess.Options.MaxAge = 5 * 60 // 5 minutes
|
||||
saveSession(sess, ctx)
|
||||
|
||||
return json(ctx, credsCreation)
|
||||
}
|
||||
|
||||
func finishWebAuthnAssertion(ctx echo.Context) error {
|
||||
sess := getSession(ctx)
|
||||
sessionData, ok := sess.Values["webauthn_assertion_session"].([]byte)
|
||||
if !ok {
|
||||
return jsonErrorRes(401, "Cannot get WebAuthn assertion session", nil)
|
||||
}
|
||||
|
||||
userId := sess.Values["mfaID"].(uint)
|
||||
|
||||
ogUser, err := db.GetUserById(userId)
|
||||
if err != nil {
|
||||
return jsonErrorRes(500, "Cannot get user", err)
|
||||
}
|
||||
|
||||
if err = webauthn.FinishLogin(ogUser, sessionData, ctx.Request()); err != nil {
|
||||
return jsonErrorRes(403, "Failed authentication attempt for passkey", err)
|
||||
}
|
||||
|
||||
sess.Values["user"] = userId
|
||||
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
|
||||
|
||||
delete(sess.Values, "webauthn_assertion_session")
|
||||
delete(sess.Values, "mfaID")
|
||||
saveSession(sess, ctx)
|
||||
|
||||
return json(ctx, []string{"OK"})
|
||||
}
|
||||
|
||||
func beginTotp(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
|
||||
if _, hasTotp, err := user.HasMFA(); err != nil {
|
||||
return errorRes(500, "Cannot check for user MFA", err)
|
||||
} else if hasTotp {
|
||||
addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error")
|
||||
return redirect(ctx, "/settings")
|
||||
}
|
||||
|
||||
ogUrl, err := url.Parse(getData(ctx, "baseHttpUrl").(string))
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot parse base URL", err)
|
||||
}
|
||||
|
||||
sess := getSession(ctx)
|
||||
generatedSecret, _ := sess.Values["generatedSecret"].([]byte)
|
||||
|
||||
totpSecret, qrcode, err, generatedSecret := totp.GenerateQRCode(getUserLogged(ctx).Username, ogUrl.Hostname(), generatedSecret)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot generate TOTP QR code", err)
|
||||
}
|
||||
sess.Values["totpSecret"] = totpSecret
|
||||
sess.Values["generatedSecret"] = generatedSecret
|
||||
saveSession(sess, ctx)
|
||||
|
||||
setData(ctx, "totpSecret", totpSecret)
|
||||
setData(ctx, "totpQrcode", qrcode)
|
||||
|
||||
return html(ctx, "totp.html")
|
||||
|
||||
}
|
||||
|
||||
func finishTotp(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
|
||||
if _, hasTotp, err := user.HasMFA(); err != nil {
|
||||
return errorRes(500, "Cannot check for user MFA", err)
|
||||
} else if hasTotp {
|
||||
addFlash(ctx, tr(ctx, "auth.totp.already-enabled"), "error")
|
||||
return redirect(ctx, "/settings")
|
||||
}
|
||||
|
||||
dto := &db.TOTPDTO{}
|
||||
if err := ctx.Bind(dto); err != nil {
|
||||
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
|
||||
}
|
||||
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
addFlash(ctx, "Invalid secret", "error")
|
||||
return redirect(ctx, "/settings/totp/generate")
|
||||
}
|
||||
|
||||
sess := getSession(ctx)
|
||||
secret, ok := sess.Values["totpSecret"].(string)
|
||||
if !ok {
|
||||
return errorRes(500, "Cannot get TOTP secret from session", nil)
|
||||
}
|
||||
|
||||
if !totp.Validate(dto.Code, secret) {
|
||||
addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
|
||||
|
||||
return redirect(ctx, "/settings/totp/generate")
|
||||
}
|
||||
|
||||
userTotp := &db.TOTP{
|
||||
UserID: getUserLogged(ctx).ID,
|
||||
}
|
||||
if err := userTotp.StoreSecret(secret); err != nil {
|
||||
return errorRes(500, "Cannot store TOTP secret", err)
|
||||
}
|
||||
|
||||
if err := userTotp.Create(); err != nil {
|
||||
return errorRes(500, "Cannot create TOTP", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "TOTP successfully enabled", "success")
|
||||
codes, err := userTotp.GenerateRecoveryCodes()
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot generate recovery codes", err)
|
||||
}
|
||||
|
||||
delete(sess.Values, "totpSecret")
|
||||
delete(sess.Values, "generatedSecret")
|
||||
saveSession(sess, ctx)
|
||||
|
||||
setData(ctx, "recoveryCodes", codes)
|
||||
return html(ctx, "totp.html")
|
||||
}
|
||||
|
||||
func assertTotp(ctx echo.Context) error {
|
||||
var err error
|
||||
dto := &db.TOTPDTO{}
|
||||
if err := ctx.Bind(dto); err != nil {
|
||||
return errorRes(400, tr(ctx, "error.cannot-bind-data"), err)
|
||||
}
|
||||
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
|
||||
return redirect(ctx, "/mfa")
|
||||
}
|
||||
|
||||
sess := getSession(ctx)
|
||||
userId := sess.Values["mfaID"].(uint)
|
||||
var userTotp *db.TOTP
|
||||
if userTotp, err = db.GetTOTPByUserID(userId); err != nil {
|
||||
return errorRes(500, "Cannot get TOTP by UID", err)
|
||||
}
|
||||
|
||||
redirectUrl := "/"
|
||||
|
||||
var validCode, validRecoveryCode bool
|
||||
if validCode, err = userTotp.ValidateCode(dto.Code); err != nil {
|
||||
return errorRes(500, "Cannot validate TOTP code", err)
|
||||
}
|
||||
if !validCode {
|
||||
validRecoveryCode, err = userTotp.ValidateRecoveryCode(dto.Code)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot validate TOTP code", err)
|
||||
}
|
||||
|
||||
if !validRecoveryCode {
|
||||
addFlash(ctx, tr(ctx, "auth.totp.invalid-code"), "error")
|
||||
return redirect(ctx, "/mfa")
|
||||
}
|
||||
|
||||
addFlash(ctx, tr(ctx, "auth.totp.code-used", dto.Code), "warning")
|
||||
redirectUrl = "/settings"
|
||||
}
|
||||
|
||||
sess.Values["user"] = userId
|
||||
sess.Options.MaxAge = 60 * 60 * 24 * 365 // 1 year
|
||||
delete(sess.Values, "mfaID")
|
||||
saveSession(sess, ctx)
|
||||
|
||||
return redirect(ctx, redirectUrl)
|
||||
}
|
||||
|
||||
func disableTotp(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
userTotp, err := db.GetTOTPByUserID(user.ID)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get TOTP by UID", err)
|
||||
}
|
||||
|
||||
if err = userTotp.Delete(); err != nil {
|
||||
return errorRes(500, "Cannot delete TOTP", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, tr(ctx, "auth.totp.disabled"), "success")
|
||||
return redirect(ctx, "/settings")
|
||||
}
|
||||
|
||||
func regenerateTotpRecoveryCodes(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
userTotp, err := db.GetTOTPByUserID(user.ID)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get TOTP by UID", err)
|
||||
}
|
||||
|
||||
codes, err := userTotp.GenerateRecoveryCodes()
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot generate recovery codes", err)
|
||||
}
|
||||
|
||||
setData(ctx, "recoveryCodes", codes)
|
||||
return html(ctx, "totp.html")
|
||||
}
|
||||
|
||||
func logout(ctx echo.Context) error {
|
||||
deleteSession(ctx)
|
||||
deleteCsrfCookie(ctx)
|
||||
@@ -425,7 +786,7 @@ func getAvatarUrlFromProvider(provider string, identifier string) string {
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(body, &result)
|
||||
err = gojson.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Cannot unmarshal Gitea response body")
|
||||
return ""
|
||||
|
||||
@@ -2,7 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
gojson "encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
htmlpkg "html"
|
||||
@@ -65,6 +65,9 @@ var (
|
||||
"isCsv": func(i string) bool {
|
||||
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 {
|
||||
if strings.ToLower(filepath.Ext(file.Filename)) != ".csv" {
|
||||
return nil
|
||||
@@ -161,13 +164,11 @@ type Server struct {
|
||||
dev bool
|
||||
}
|
||||
|
||||
func NewServer(isDev bool, sessionsPath string) *Server {
|
||||
func NewServer(isDev bool, sessionsPath string, ignoreCsrf bool) *Server {
|
||||
dev = isDev
|
||||
flashStore = sessions.NewCookieStore([]byte("opengist"))
|
||||
userStore = sessions.NewFilesystemStore(sessionsPath,
|
||||
utils.ReadKey(path.Join(sessionsPath, "session-auth.key")),
|
||||
utils.ReadKey(path.Join(sessionsPath, "session-encrypt.key")),
|
||||
)
|
||||
encryptKey, _ := utils.GenerateSecretKey(filepath.Join(sessionsPath, "session-encrypt.key"))
|
||||
userStore = sessions.NewFilesystemStore(sessionsPath, config.SecretKey, encryptKey)
|
||||
userStore.MaxLength(10 * 1024)
|
||||
gothic.Store = userStore
|
||||
|
||||
@@ -215,10 +216,18 @@ func NewServer(isDev bool, sessionsPath string) *Server {
|
||||
}
|
||||
|
||||
e.HTTPErrorHandler = func(er error, ctx echo.Context) {
|
||||
if err, ok := er.(*echo.HTTPError); ok {
|
||||
setData(ctx, "error", err)
|
||||
if errHtml := htmlWithCode(ctx, err.Code, "error.html"); errHtml != nil {
|
||||
log.Fatal().Err(errHtml).Send()
|
||||
var httpErr *echo.HTTPError
|
||||
if errors.As(er, &httpErr) {
|
||||
acceptJson := strings.Contains(ctx.Request().Header.Get("Accept"), "application/json")
|
||||
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 {
|
||||
log.Fatal().Err(er).Send()
|
||||
@@ -236,9 +245,9 @@ func NewServer(isDev bool, sessionsPath string) *Server {
|
||||
// Web based routes
|
||||
g1 := e.Group("")
|
||||
{
|
||||
if !dev {
|
||||
if !ignoreCsrf {
|
||||
g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
TokenLookup: "form:_csrf",
|
||||
TokenLookup: "form:_csrf,header:X-CSRF-Token",
|
||||
CookiePath: "/",
|
||||
CookieHTTPOnly: true,
|
||||
CookieSameSite: http.SameSiteStrictMode,
|
||||
@@ -248,7 +257,7 @@ func NewServer(isDev bool, sessionsPath string) *Server {
|
||||
|
||||
g1.GET("/", create, logged)
|
||||
g1.POST("/", processCreate, logged)
|
||||
g1.GET("/preview", preview, logged)
|
||||
g1.POST("/preview", preview, logged)
|
||||
|
||||
g1.GET("/healthcheck", healthcheck)
|
||||
g1.GET("/metrics", metrics)
|
||||
@@ -260,14 +269,29 @@ func NewServer(isDev bool, sessionsPath string) *Server {
|
||||
g1.GET("/logout", logout)
|
||||
g1.GET("/oauth/:provider", oauth)
|
||||
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.POST("/settings/email", emailProcess, logged)
|
||||
g1.DELETE("/settings/account", accountDeleteProcess, logged)
|
||||
g1.POST("/settings/ssh-keys", sshKeysProcess, 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/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.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 {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
@@ -563,7 +598,7 @@ func parseManifestEntries() {
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,20 @@ func userSettings(ctx echo.Context) error {
|
||||
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, "sshKeys", keys)
|
||||
setData(ctx, "passkeys", passkeys)
|
||||
setData(ctx, "hasTotp", hasTotp)
|
||||
setData(ctx, "hasPassword", user.Password != "")
|
||||
setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
|
||||
setData(ctx, "htmlTitle", trH(ctx, "settings"))
|
||||
@@ -127,6 +139,26 @@ func sshKeysDelete(ctx echo.Context) error {
|
||||
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 {
|
||||
user := getUserLogged(ctx)
|
||||
|
||||
|
||||
@@ -12,12 +12,10 @@ import (
|
||||
)
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
setup(t)
|
||||
s, err := newTestServer()
|
||||
require.NoError(t, err, "Failed to create test server")
|
||||
s := setup(t)
|
||||
defer teardown(t, s)
|
||||
|
||||
err = s.request("GET", "/", nil, 302)
|
||||
err := s.request("GET", "/", nil, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.request("GET", "/register", nil, 200)
|
||||
@@ -55,12 +53,10 @@ func TestRegister(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
setup(t)
|
||||
s, err := newTestServer()
|
||||
require.NoError(t, err, "Failed to create test server")
|
||||
s := setup(t)
|
||||
defer teardown(t, s)
|
||||
|
||||
err = s.request("GET", "/login", nil, 200)
|
||||
err := s.request("GET", "/login", nil, 200)
|
||||
require.NoError(t, err)
|
||||
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
@@ -101,15 +97,13 @@ type settingSet struct {
|
||||
}
|
||||
|
||||
func TestAnonymous(t *testing.T) {
|
||||
setup(t)
|
||||
s, err := newTestServer()
|
||||
require.NoError(t, err, "Failed to create test server")
|
||||
s := setup(t)
|
||||
defer teardown(t, s)
|
||||
|
||||
user := db.UserDTO{Username: "thomas", Password: "azeaze"}
|
||||
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)
|
||||
|
||||
gist1 := db.GistDTO{
|
||||
@@ -154,9 +148,7 @@ func TestAnonymous(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGitOperations(t *testing.T) {
|
||||
setup(t)
|
||||
s, err := newTestServer()
|
||||
require.NoError(t, err, "Failed to create test server")
|
||||
s := setup(t)
|
||||
defer teardown(t, s)
|
||||
|
||||
admin := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
@@ -178,7 +170,7 @@ func TestGitOperations(t *testing.T) {
|
||||
"yeah",
|
||||
},
|
||||
}
|
||||
err = s.request("POST", "/", gist1, 302)
|
||||
err := s.request("POST", "/", gist1, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist2 := db.GistDTO{
|
||||
|
||||
@@ -9,12 +9,10 @@ import (
|
||||
)
|
||||
|
||||
func TestGists(t *testing.T) {
|
||||
setup(t)
|
||||
s, err := newTestServer()
|
||||
require.NoError(t, err, "Failed to create test server")
|
||||
s := setup(t)
|
||||
defer teardown(t, s)
|
||||
|
||||
err = s.request("GET", "/", nil, 302)
|
||||
err := s.request("GET", "/", nil, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
@@ -106,9 +104,7 @@ func TestGists(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVisibility(t *testing.T) {
|
||||
setup(t)
|
||||
s, err := newTestServer()
|
||||
require.NoError(t, err, "Failed to create test server")
|
||||
s := setup(t)
|
||||
defer teardown(t, s)
|
||||
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
@@ -123,7 +119,7 @@ func TestVisibility(t *testing.T) {
|
||||
Name: []string{""},
|
||||
Content: []string{"yeah"},
|
||||
}
|
||||
err = s.request("POST", "/", gist1, 302)
|
||||
err := s.request("POST", "/", gist1, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist1db, err := db.GetGistByID("1")
|
||||
@@ -150,9 +146,7 @@ func TestVisibility(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLikeFork(t *testing.T) {
|
||||
setup(t)
|
||||
s, err := newTestServer()
|
||||
require.NoError(t, err, "Failed to create test server")
|
||||
s := setup(t)
|
||||
defer teardown(t, s)
|
||||
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
@@ -167,7 +161,7 @@ func TestLikeFork(t *testing.T) {
|
||||
Name: []string{""},
|
||||
Content: []string{"yeah"},
|
||||
}
|
||||
err = s.request("POST", "/", gist1, 302)
|
||||
err := s.request("POST", "/", gist1, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.sessionCookie = ""
|
||||
@@ -211,9 +205,7 @@ func TestLikeFork(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCustomUrl(t *testing.T) {
|
||||
setup(t)
|
||||
s, err := newTestServer()
|
||||
require.NoError(t, err, "Failed to create test server")
|
||||
s := setup(t)
|
||||
defer teardown(t, s)
|
||||
|
||||
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
@@ -229,7 +221,7 @@ func TestCustomUrl(t *testing.T) {
|
||||
Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"},
|
||||
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)
|
||||
|
||||
gist1db, err := db.GetGistByID("1")
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web"
|
||||
)
|
||||
|
||||
var databaseType string
|
||||
|
||||
type testServer struct {
|
||||
server *web.Server
|
||||
sessionCookie string
|
||||
@@ -31,7 +33,7 @@ type testServer struct {
|
||||
|
||||
func newTestServer() (*testServer, error) {
|
||||
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()
|
||||
@@ -131,7 +133,18 @@ func structToURLValues(s interface{}) url.Values {
|
||||
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")
|
||||
|
||||
err := config.InitConfig("", io.Discard)
|
||||
@@ -140,6 +153,8 @@ func setup(t *testing.T) {
|
||||
err = os.MkdirAll(filepath.Join(config.GetHomeDir()), 0755)
|
||||
require.NoError(t, err, "Could not create Opengist home directory")
|
||||
|
||||
config.SetupSecretKey()
|
||||
|
||||
git.ReposDirectory = path.Join("tests")
|
||||
|
||||
config.C.IndexEnabled = false
|
||||
@@ -155,23 +170,32 @@ func setup(t *testing.T) {
|
||||
err = os.MkdirAll(filepath.Join(homePath, "tmp", "repos"), 0755)
|
||||
require.NoError(t, err, "Could not create tmp repos directory")
|
||||
|
||||
err = db.Setup("file::memory:", true)
|
||||
err = db.Setup(databaseDsn, true)
|
||||
require.NoError(t, err, "Could not initialize database")
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Could not initialize database")
|
||||
}
|
||||
|
||||
err = memdb.Setup()
|
||||
require.NoError(t, err, "Could not initialize in memory database")
|
||||
|
||||
// err = index.Open(filepath.Join(homePath, "testsindex", "opengist.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) {
|
||||
s.stop()
|
||||
|
||||
err := db.Close()
|
||||
require.NoError(t, err, "Could not close database")
|
||||
//err := db.Close()
|
||||
//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")
|
||||
|
||||
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"))
|
||||
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"))
|
||||
// require.NoError(t, err, "Could not remove repos directory")
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ func htmlWithCode(ctx echo.Context, code int, template string) error {
|
||||
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 {
|
||||
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}
|
||||
}
|
||||
|
||||
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 {
|
||||
user := getData(ctx, "userLogged")
|
||||
if user != nil {
|
||||
@@ -80,6 +97,7 @@ func setErrorFlashes(ctx echo.Context) {
|
||||
|
||||
setData(ctx, "flashErrors", sess.Flashes("error"))
|
||||
setData(ctx, "flashSuccess", sess.Flashes("success"))
|
||||
setData(ctx, "flashWarnings", sess.Flashes("warning"))
|
||||
|
||||
_ = sess.Save(ctx.Request(), ctx.Response())
|
||||
}
|
||||
@@ -102,14 +120,15 @@ func saveSession(sess *sessions.Session, ctx echo.Context) {
|
||||
func deleteSession(ctx echo.Context) {
|
||||
sess := getSession(ctx)
|
||||
sess.Options.MaxAge = -1
|
||||
sess.Values["user"] = nil
|
||||
saveSession(sess, ctx)
|
||||
}
|
||||
|
||||
func setCsrfHtmlForm(ctx echo.Context) {
|
||||
var csrf string
|
||||
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) {
|
||||
|
||||
1842
package-lock.json
generated
1842
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import {EditorView, gutter, keymap, lineNumbers} from "@codemirror/view";
|
||||
import {Compartment, EditorState, Facet, Line, SelectionRange} from "@codemirror/state";
|
||||
import {indentLess} from "@codemirror/commands";
|
||||
import {defaultKeymap, indentLess} from "@codemirror/commands";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
EditorView.theme({}, {dark: true});
|
||||
@@ -27,7 +27,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
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)),
|
||||
wrapMode.of([]),
|
||||
indentType.of(txtFacet.of("space")),
|
||||
@@ -68,11 +71,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
cmeditor!.classList.remove("hidden-important");
|
||||
return;
|
||||
} else {
|
||||
fetch(`${baseUrl}/preview?` + new URLSearchParams({
|
||||
content: editor.state.doc.toString()
|
||||
}), {
|
||||
method: 'GET',
|
||||
const formData = new FormData();
|
||||
formData.append('content', editor.state.doc.toString());
|
||||
let csrf = document.querySelector<HTMLInputElement>('form#create input[name="_csrf"]').value
|
||||
fetch(`${baseUrl}/preview`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf
|
||||
}
|
||||
}).then(r => r.text()).then(r => {
|
||||
let divpreview = dom.querySelector("div.preview") as HTMLElement;
|
||||
divpreview!.innerHTML = r;
|
||||
|
||||
4
public/embed.scss
vendored
4
public/embed.scss
vendored
@@ -107,6 +107,10 @@ dl.dl-config dd {
|
||||
@apply overflow-auto whitespace-pre;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
@apply bg-transparent dark:bg-transparent;
|
||||
}
|
||||
|
||||
.chroma.preview.markdown pre code {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
4
public/style.css
vendored
4
public/style.css
vendored
@@ -167,6 +167,10 @@ dl.dl-config dd {
|
||||
@apply overflow-auto whitespace-pre !important;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
@apply bg-transparent dark:bg-transparent !important;
|
||||
}
|
||||
|
||||
.chroma.preview.markdown pre code {
|
||||
@apply p-4 !important;
|
||||
}
|
||||
|
||||
2
public/tailwind-embed.config.js
vendored
2
public/tailwind-embed.config.js
vendored
@@ -7,6 +7,8 @@ module.exports = {
|
||||
],
|
||||
theme: {
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
current: 'currentColor',
|
||||
white: colors.white,
|
||||
black: colors.black,
|
||||
gray: {
|
||||
|
||||
1
public/tailwind.config.js
vendored
1
public/tailwind.config.js
vendored
@@ -11,6 +11,7 @@ module.exports = {
|
||||
current: 'currentColor',
|
||||
white: colors.white,
|
||||
black: colors.black,
|
||||
yellow: colors.yellow,
|
||||
gray: {
|
||||
50: "#EEEFF1",
|
||||
100: "#DEDFE3",
|
||||
|
||||
3
public/vite.config.js
vendored
3
public/vite.config.js
vendored
@@ -14,7 +14,8 @@ export default defineConfig({
|
||||
'./public/editor.ts',
|
||||
'./public/admin.ts',
|
||||
'./public/gist.ts',
|
||||
'./public/embed.ts'
|
||||
'./public/embed.ts',
|
||||
'./public/webauthn.ts'
|
||||
]
|
||||
},
|
||||
assetsInlineLimit: 0,
|
||||
|
||||
182
public/webauthn.ts
Normal file
182
public/webauthn.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
14
templates/base/base_header.html
vendored
14
templates/base/base_header.html
vendored
@@ -302,6 +302,20 @@
|
||||
</div>
|
||||
</div>
|
||||
{{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>
|
||||
|
||||
{{ end }}
|
||||
|
||||
4
templates/base/gist_header.html
vendored
4
templates/base/gist_header.html
vendored
@@ -75,9 +75,9 @@
|
||||
{{ .locale.Tr "gist.header.edit" }}
|
||||
</a>
|
||||
</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 }}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
6
templates/pages/admin_config.html
vendored
6
templates/pages/admin_config.html
vendored
@@ -3,7 +3,7 @@
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<div class="relative col-span-3">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
@@ -17,11 +17,11 @@
|
||||
<dt>Log output</dt><dd>{{ .c.LogOutput }}</dd>
|
||||
<dt>External URL</dt><dd>{{ .c.ExternalUrl }}</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 Dirname</dt><dd>{{ .c.IndexDirname }}</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="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
|
||||
4
templates/pages/admin_gists.html
vendored
4
templates/pages/admin_gists.html
vendored
@@ -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"><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">
|
||||
<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 }}
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
4
templates/pages/admin_invitations.html
vendored
4
templates/pages/admin_invitations.html
vendored
@@ -53,9 +53,9 @@
|
||||
<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="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 }}
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
4
templates/pages/admin_users.html
vendored
4
templates/pages/admin_users.html
vendored
@@ -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"><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">
|
||||
<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 }}
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
159
templates/pages/auth_form.html
vendored
159
templates/pages/auth_form.html
vendored
@@ -11,83 +11,110 @@
|
||||
{{ if .disableSignup }}
|
||||
<p class="italic">{{ .locale.Tr "auth.signup-disabled" }}</p>
|
||||
{{ else }}
|
||||
<div class="sm:col-span-6">
|
||||
<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">
|
||||
<div class="grid sm:grid-cols-2">
|
||||
<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">
|
||||
|
||||
{{ 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 }}
|
||||
<div class="relative my-4">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-gray-200 dark:border-gray-700"></div>
|
||||
<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>
|
||||
<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 }}
|
||||
<div>
|
||||
{{ if .githubOauth }}
|
||||
<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">
|
||||
{{ .locale.Tr "auth.oauth" "GitHub"}}
|
||||
</a>
|
||||
{{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }}
|
||||
{{ if not .disableForm }}
|
||||
<div class="relative my-4">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-gray-200 dark:border-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
{{ end }}
|
||||
{{ if .gitlabOauth }}
|
||||
<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">
|
||||
{{ .locale.Tr "auth.oauth" .c.GitlabName}}
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if .giteaOauth }}
|
||||
<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">
|
||||
{{ .locale.Tr "auth.oauth" .c.GiteaName }}
|
||||
</a>
|
||||
{{ 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>
|
||||
{{ if .githubOauth }}
|
||||
<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">
|
||||
{{ .locale.Tr "auth.oauth" "GitHub"}}
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if .gitlabOauth }}
|
||||
<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">
|
||||
{{ .locale.Tr "auth.oauth" .c.GitlabName}}
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if .giteaOauth }}
|
||||
<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">
|
||||
{{ .locale.Tr "auth.oauth" .c.GiteaName }}
|
||||
</a>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
{{ end }}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
|
||||
|
||||
{{ template "footer" .}}
|
||||
|
||||
4
templates/pages/edit.html
vendored
4
templates/pages/edit.html
vendored
@@ -28,9 +28,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</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 }}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
2
templates/pages/gist.html
vendored
2
templates/pages/gist.html
vendored
@@ -68,6 +68,8 @@
|
||||
</table>
|
||||
{{ else if isMarkdown $file.Filename }}
|
||||
<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 }}
|
||||
<div class="code">
|
||||
{{ $fileslug := slug $file.Filename }}
|
||||
|
||||
2
templates/pages/gist_embed.html
vendored
2
templates/pages/gist_embed.html
vendored
@@ -32,6 +32,8 @@
|
||||
</table>
|
||||
{{ else if isMarkdown $file.Filename }}
|
||||
<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 }}
|
||||
<div class="code dark:bg-gray-900">
|
||||
{{ $fileslug := slug $file.Filename }}
|
||||
|
||||
60
templates/pages/mfa.html
vendored
Normal file
60
templates/pages/mfa.html
vendored
Normal 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" .}}
|
||||
104
templates/pages/settings.html
vendored
104
templates/pages/settings.html
vendored
@@ -96,7 +96,7 @@
|
||||
|
||||
{{ if .githubOauth }}
|
||||
{{ 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.')">
|
||||
{{ .locale.Tr "settings.unlink-github-account" }}
|
||||
</a>
|
||||
@@ -109,7 +109,7 @@
|
||||
|
||||
{{ if .gitlabOauth }}
|
||||
{{ 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.')">
|
||||
{{ .locale.Tr "settings.unlink-gitlab-account" }}
|
||||
</a>
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
{{ if .giteaOauth }}
|
||||
{{ 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.')">
|
||||
{{ .locale.Tr "settings.unlink-gitea-account" }}
|
||||
</a>
|
||||
@@ -134,7 +134,7 @@
|
||||
{{ end }}
|
||||
{{ if .oidcOauth }}
|
||||
{{ 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.')">
|
||||
Unlink OpenID account
|
||||
</a>
|
||||
@@ -148,6 +148,90 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ 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="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">
|
||||
@@ -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>
|
||||
{{ end }}
|
||||
</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">
|
||||
{{ $.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>
|
||||
</div>
|
||||
</li>
|
||||
@@ -215,9 +299,9 @@
|
||||
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
|
||||
{{ .locale.Tr "settings.delete-account" }}
|
||||
</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">
|
||||
<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 }}
|
||||
</form>
|
||||
</div>
|
||||
@@ -225,4 +309,8 @@
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
|
||||
|
||||
|
||||
{{ template "footer" .}}
|
||||
|
||||
54
templates/pages/totp.html
vendored
Normal file
54
templates/pages/totp.html
vendored
Normal 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" .}}
|
||||
Reference in New Issue
Block a user