Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a596aed0 | ||
|
|
a468f0ecfa | ||
|
|
5ef5518795 | ||
|
|
92c5569538 | ||
|
|
132e4faed2 | ||
|
|
c7b947580d | ||
|
|
4106956f6d | ||
|
|
c02bf97b63 | ||
|
|
53ce41e0e4 | ||
|
|
594d876ba8 | ||
|
|
905276f24b | ||
|
|
2976173658 | ||
|
|
b048203216 | ||
|
|
a7a25c4100 | ||
|
|
bb1991f3ca | ||
|
|
979b302e4c | ||
|
|
b18cdb9188 | ||
|
|
867aa6e57b | ||
|
|
3c0115d829 | ||
|
|
d796895b75 | ||
|
|
5542497622 | ||
|
|
546f1968e0 | ||
|
|
75e71fd042 | ||
|
|
897dc43790 | ||
|
|
72e02700ec | ||
|
|
dc43fccc04 | ||
|
|
0e9b778b45 | ||
|
|
3c940cd81f | ||
|
|
de144d09d3 |
1
.github/workflows/helm.yml
vendored
1
.github/workflows/helm.yml
vendored
@@ -26,6 +26,7 @@ jobs:
|
||||
helm package ./opengist
|
||||
|
||||
# First time, create the index
|
||||
wget -q https://helm.opengist.io/index.yaml
|
||||
if [ ! -f index.yaml ]; then
|
||||
helm repo index --url https://helm.opengist.io .
|
||||
else
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
gist.db
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
/**/.DS_Store
|
||||
public/assets/*
|
||||
@@ -10,4 +11,5 @@ opengist
|
||||
build/
|
||||
docs/.vitepress/dist/
|
||||
docs/.vitepress/cache/
|
||||
helm/opengist/charts/
|
||||
helm/opengist/charts/
|
||||
vendor/
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -1,5 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## [1.11.1](https://github.com/thomiceli/opengist/compare/v1.11.0...v1.11.1) - 2025-09-30
|
||||
See here how to [update](https://opengist.io/docs/update) Opengist.
|
||||
|
||||
### Added
|
||||
- More translation strings (#511)
|
||||
|
||||
### Fixed
|
||||
- CSV errors for rendering (#514)
|
||||
|
||||
### Other
|
||||
- Reset default log level to warn
|
||||
|
||||
## [1.11.0](https://github.com/thomiceli/opengist/compare/v1.10.0...v1.11.0) - 2025-09-21
|
||||
See here how to [update](https://opengist.io/docs/update) Opengist.
|
||||
|
||||
### Added
|
||||
- LDAP authentication (#470)
|
||||
- Listen to Unix websocket (#484)
|
||||
- Binary files support (#503)
|
||||
- Support for rendering .ipynb Jupyter/IPython notebooks (#491)
|
||||
- File upload on gist creation/edition (#507)
|
||||
- Read psql sslmode from db uri (#462)
|
||||
- OIDC group claim name to OpenID request (#490)
|
||||
- Reworked user settings page (#467)
|
||||
- Style preference tab for user (#467)
|
||||
- Init gist with regular urls via git CLI (http) (#501)
|
||||
|
||||
### Fixed
|
||||
- Gitlab avatar (#461)
|
||||
- Correct German spelling, use consistent wording (#468)
|
||||
- Filename unescape (#474)
|
||||
- Fix Markdown preview links (#475)
|
||||
- Replace Unicode characters with HTML entity codes in embed template (#480)
|
||||
- Redirect to $baseUrl after auth with passkey instead of / (#482)
|
||||
- Human date on iOS devices (#510)
|
||||
|
||||
### Docs
|
||||
- Add Proxmox VE Helper-Script (#473)
|
||||
|
||||
### Other
|
||||
- Use Helm deployment.env[] values (#471)
|
||||
- Update Helm Postgres version
|
||||
- Use database for Gist init queue (#498)
|
||||
- Update go dep chroma (#493)
|
||||
|
||||
## [1.10.0](https://github.com/thomiceli/opengist/compare/v1.9.1...v1.10.0) - 2025-04-07
|
||||
See here how to [update](https://opengist.io/docs/update) Opengist.
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -50,7 +50,7 @@ watch_backend:
|
||||
OG_DEV=1 npx nodemon --watch '**/*' -e html,yml,go,js --signal SIGTERM --exec 'go run -ldflags "-X $(VERSION_PKG)=$(GIT_TAG)" . --config config.yml'
|
||||
|
||||
watch:
|
||||
@sh ./scripts/watch.sh
|
||||
@bash ./scripts/watch.sh
|
||||
|
||||
clean:
|
||||
@echo "Cleaning up build artifacts..."
|
||||
|
||||
10
README.md
10
README.md
@@ -28,7 +28,7 @@ It is similar to [GitHub Gist](https://gist.github.com/), but open-source and co
|
||||
* Download raw files or as a ZIP archive
|
||||
* OAuth2 login with GitHub, GitLab, Gitea, and OpenID Connect
|
||||
* Restrict or unrestrict snippets visibility to anonymous users
|
||||
* Docker support
|
||||
* Docker support / Helm Chart
|
||||
* [More...](/docs/introduction.md#features)
|
||||
|
||||
## Quick start
|
||||
@@ -38,7 +38,7 @@ It is similar to [GitHub Gist](https://gist.github.com/), but open-source and co
|
||||
Docker [images](https://github.com/thomiceli/opengist/pkgs/container/opengist) are available for each release :
|
||||
|
||||
```shell
|
||||
docker pull ghcr.io/thomiceli/opengist:1.10
|
||||
docker pull ghcr.io/thomiceli/opengist:1.11
|
||||
```
|
||||
|
||||
It can be used in a `docker-compose.yml` file :
|
||||
@@ -50,7 +50,7 @@ It can be used in a `docker-compose.yml` file :
|
||||
```yml
|
||||
services:
|
||||
opengist:
|
||||
image: ghcr.io/thomiceli/opengist:1.10
|
||||
image: ghcr.io/thomiceli/opengist:1.11
|
||||
container_name: opengist
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -77,9 +77,9 @@ Download the archive for your system from the release page [here](https://github
|
||||
|
||||
```shell
|
||||
# example for linux amd64
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.10.0/opengist1.10.0-linux-amd64.tar.gz
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.11.1/opengist1.11.1-linux-amd64.tar.gz
|
||||
|
||||
tar xzvf opengist1.10.0-linux-amd64.tar.gz
|
||||
tar xzvf opengist1.11.1-linux-amd64.tar.gz
|
||||
cd opengist
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
16
config.yml
16
config.yml
@@ -43,6 +43,7 @@ sqlite.journal-mode: WAL
|
||||
|
||||
# HTTP server configuration
|
||||
# Host to bind to. Default: 0.0.0.0
|
||||
# Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock)
|
||||
http.host: 0.0.0.0
|
||||
|
||||
# Port to bind to. Default: 6157
|
||||
@@ -51,6 +52,9 @@ http.port: 6157
|
||||
# Enable or disable git operations (clone, pull, push) via HTTP (either `true` or `false`). Default: true
|
||||
http.git-enabled: true
|
||||
|
||||
# File permissions for Unix socket (octal format). Default: 0666
|
||||
unix-socket-permissions: 0666
|
||||
|
||||
# Enable or disable the metrics endpoint (either `true` or `false`). Default: false
|
||||
metrics.enabled: false
|
||||
|
||||
@@ -111,6 +115,18 @@ oidc.group-claim-name:
|
||||
# The name of the group that should receive admin rights
|
||||
oidc.admin-group:
|
||||
|
||||
# LDAP authentication configuration
|
||||
# URL of the LDAP instance e.g: ldap://ldap.example.com:389 ; if not set, LDAP authentication is disabled
|
||||
ldap.url:
|
||||
# Bind DN to authenticate against the LDAP e.g: cn=read-only-admin,dc=example,dc=com
|
||||
ldap.bind-dn:
|
||||
# The password for the Bind DN.
|
||||
ldap.bind-credentials:
|
||||
# The Base DN to start search from e.g: ou=People,dc=example,dc=com
|
||||
ldap.search-base:
|
||||
# The filter to search against (the format string %s will be replaced with the username) e.g: (uid=%s)
|
||||
ldap.search-filter:
|
||||
|
||||
# Instance name
|
||||
# Set your own custom name to be displayed instead of 'Opengist'
|
||||
custom.name:
|
||||
|
||||
@@ -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.10</span>
|
||||
<span class="pr-1">Released 1.11</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>
|
||||
|
||||
@@ -4,43 +4,49 @@ aside: false
|
||||
|
||||
# Configuration Cheat Sheet
|
||||
|
||||
| YAML Config Key | Environment Variable | Default value | Description |
|
||||
|-----------------------|-------------------------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| log-level | OG_LOG_LEVEL | `warn` | Set the log level to one of the following: `debug`, `info`, `warn`, `error`, `fatal`. |
|
||||
| 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. |
|
||||
| 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 | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
|
||||
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
|
||||
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
|
||||
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
|
||||
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
|
||||
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. |
|
||||
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
|
||||
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
|
||||
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics endpoint at `/metrics` (`true` or `false`) |
|
||||
| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) |
|
||||
| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. |
|
||||
| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. |
|
||||
| ssh.external-domain | OG_SSH_EXTERNAL_DOMAIN | none | Public domain for the Git SSH connection, if it has to be different from the HTTP one. If not set, uses the URL from the request. |
|
||||
| ssh.keygen-executable | OG_SSH_KEYGEN_EXECUTABLE | `ssh-keygen` | Path to the SSH key generation executable. |
|
||||
| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. |
|
||||
| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. |
|
||||
| gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. |
|
||||
| gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. |
|
||||
| gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. |
|
||||
| gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. |
|
||||
| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. |
|
||||
| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. |
|
||||
| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. |
|
||||
| gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. |
|
||||
| oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider |
|
||||
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
|
||||
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
|
||||
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
|
||||
| custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title |
|
||||
| custom.logo | OG_CUSTOM_LOGO | none | Path to an image, relative to $opengist-home/custom. |
|
||||
| custom.favicon | OG_CUSTOM_FAVICON | none | Path to an image, relative to $opengist-home/custom. |
|
||||
| custom.static-links | OG_CUSTOM_STATIC_LINK_#_(PATH,NAME) | none | Path and name to custom links, more info [here](custom-links.md). |
|
||||
| YAML Config Key | Environment Variable | Default value | Description |
|
||||
|-------------------------|-------------------------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| log-level | OG_LOG_LEVEL | `warn` | Set the log level to one of the following: `debug`, `info`, `warn`, `error`, `fatal`. |
|
||||
| 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. |
|
||||
| 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 | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). |
|
||||
| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. |
|
||||
| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. |
|
||||
| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) |
|
||||
| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) |
|
||||
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) |
|
||||
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
|
||||
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
|
||||
| unix-socket-permissions | OG_UNIX_SOCKET_PERMISSIONS | `0666` | File permissions for Unix socket (octal format). |
|
||||
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics endpoint at `/metrics` (`true` or `false`) |
|
||||
| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) |
|
||||
| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. |
|
||||
| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. |
|
||||
| ssh.external-domain | OG_SSH_EXTERNAL_DOMAIN | none | Public domain for the Git SSH connection, if it has to be different from the HTTP one. If not set, uses the URL from the request. |
|
||||
| ssh.keygen-executable | OG_SSH_KEYGEN_EXECUTABLE | `ssh-keygen` | Path to the SSH key generation executable. |
|
||||
| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. |
|
||||
| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. |
|
||||
| gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. |
|
||||
| gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. |
|
||||
| gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. |
|
||||
| gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. |
|
||||
| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. |
|
||||
| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. |
|
||||
| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. |
|
||||
| gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. |
|
||||
| oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider |
|
||||
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
|
||||
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
|
||||
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
|
||||
| ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled |
|
||||
| ldap.bind-dn | OG_LDAP_BIND_DN | none | Bind DN to authenticate against the LDAP. e.g: cn=read-only-admin,dc=example,dc=com |
|
||||
| ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. |
|
||||
| ldap.search-base | OG_LDAP_SEARCH_BASE | none | The Base DN to start search from. e.g: ou=People,dc=example,dc=com |
|
||||
| ldap.search-filter | OG_LDAP_SEARCH_FILTER | none | The filter to search against (the format string %s will be replaced with the username). e.g: (uid=%s) |
|
||||
| custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title |
|
||||
| custom.logo | OG_CUSTOM_LOGO | none | Path to an image, relative to $opengist-home/custom. |
|
||||
| custom.favicon | OG_CUSTOM_FAVICON | none | Path to an image, relative to $opengist-home/custom. |
|
||||
| custom.static-links | OG_CUSTOM_STATIC_LINK_#_(PATH,NAME) | none | Path and name to custom links, more info [here](custom-links.md). |
|
||||
|
||||
@@ -4,3 +4,4 @@ The following is a list of resources made by happy users of Opengist. Feel free
|
||||
|
||||
- [Aetherinox/opengist-debian](https://github.com/Aetherinox/opengist-debian) - A Debian package for Opengist
|
||||
- [How to Install Opengist on Your Synology NAS](https://mariushosting.com/how-to-install-opengist-on-your-synology-nas/) - A guide to install Opengist on a Synology NAS
|
||||
- [Proxmox VE Helper-Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=opengist) - A script to install Opengist on Proxmox VE
|
||||
|
||||
@@ -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.10.0/opengist1.10.0-linux-amd64.tar.gz
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.11.1/opengist1.11.1-linux-amd64.tar.gz
|
||||
|
||||
tar xzvf opengist1.10.0-linux-amd64.tar.gz
|
||||
tar xzvf opengist1.11.1-linux-amd64.tar.gz
|
||||
cd opengist
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
@@ -10,7 +10,7 @@ Requirements:
|
||||
git clone https://github.com/thomiceli/opengist
|
||||
cd opengist
|
||||
|
||||
git checkout v1.10.0 # optional, to checkout the latest release
|
||||
git checkout v1.11.1 # optional, to checkout the latest release
|
||||
|
||||
make
|
||||
./opengist
|
||||
|
||||
@@ -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.10.0/opengist1.10.0-linux-amd64.tar.gz
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.11.1/opengist1.11.1-linux-amd64.tar.gz
|
||||
|
||||
tar xzvf opengist1.10.0-linux-amd64.tar.gz
|
||||
tar xzvf opengist1.11.1-linux-amd64.tar.gz
|
||||
cd opengist
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Init Gists via Git
|
||||
|
||||
Opengist allows you to create new snippets via Git over HTTP.
|
||||
Opengist allows you to create new snippets via Git over HTTP. You can create gists with either auto-generated URLs or custom URLs of your choice.
|
||||
|
||||
Simply init a new Git repository where your file(s) is/are located:
|
||||
|
||||
@@ -10,19 +10,41 @@ git add .
|
||||
git commit -m "My cool snippet"
|
||||
```
|
||||
|
||||
Then add this Opengist special remote URL and push your changes:
|
||||
### Option A: Regular URL
|
||||
|
||||
Create a gist with a custom URL using the format `http://opengist.url/username/custom-url`, where `username` is your authenticated username and `custom-url` is your desired gist identifier.
|
||||
|
||||
The gist must not exist yet if you want to create it, otherwise you will just push to the existing gist.
|
||||
|
||||
```shell
|
||||
git remote add origin http://localhost:6157/init
|
||||
git remote add origin http://opengist.url/thomas/my-custom-gist
|
||||
|
||||
git push -u origin master
|
||||
```
|
||||
|
||||
Log in with your Opengist account credentials, and your snippet will be created at the specified URL:
|
||||
**Requirements for custom URLs:**
|
||||
- The username must match your authenticated username
|
||||
- URL format: `http://opengist.url/username/custom-url`
|
||||
- The custom URL becomes your gist's identifier and title
|
||||
- `.git` suffix is automatically removed if present
|
||||
|
||||
### Option B: Init endpoint
|
||||
|
||||
Use the special `http://opengist.url/init` endpoint to create a gist with an automatically generated URL:
|
||||
|
||||
```shell
|
||||
Username for 'http://localhost:6157': thomas
|
||||
Password for 'http://thomas@localhost:6157':
|
||||
git remote add origin http://opengist.url/init
|
||||
|
||||
git push -u origin master
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
When you push, you'll be prompted to authenticate:
|
||||
|
||||
```shell
|
||||
Username for 'http://opengist.url': thomas
|
||||
Password for 'http://thomas@opengist.url': [your-password]
|
||||
Enumerating objects: 3, done.
|
||||
Counting objects: 100% (3/3), done.
|
||||
Delta compression using up to 8 threads
|
||||
@@ -30,12 +52,12 @@ Compressing objects: 100% (2/2), done.
|
||||
Writing objects: 100% (3/3), 416 bytes | 416.00 KiB/s, done.
|
||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
|
||||
remote:
|
||||
remote: Your new repository has been created here: http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066
|
||||
remote: Your new repository has been created here: http://opengist.url/thomas/6051e930f140429f9a2f3bb1fa101066
|
||||
remote:
|
||||
remote: If you want to keep working with your gist, you could set the remote URL via:
|
||||
remote: git remote set-url origin http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066
|
||||
remote: git remote set-url origin http://opengist.url/thomas/6051e930f140429f9a2f3bb1fa101066
|
||||
remote:
|
||||
To http://localhost:6157/init
|
||||
To http://opengist.url/init
|
||||
* [new branch] master -> master
|
||||
```
|
||||
|
||||
|
||||
7
go.mod
7
go.mod
@@ -4,10 +4,12 @@ go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/Kunde21/markdownfmt/v3 v3.1.0
|
||||
github.com/alecthomas/chroma/v2 v2.16.0
|
||||
github.com/alecthomas/chroma/v2 v2.20.0
|
||||
github.com/blevesearch/bleve/v2 v2.5.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/gabriel-vasile/mimetype v1.4.8
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.8
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
github.com/go-webauthn/webauthn v0.12.3
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -37,6 +39,7 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -64,8 +67,8 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
|
||||
81
go.sum
81
go.sum
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0=
|
||||
github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
|
||||
@@ -7,11 +9,13 @@ github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA=
|
||||
github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -87,8 +91,12 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -137,10 +145,15 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -151,6 +164,18 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -232,9 +257,11 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
@@ -250,6 +277,7 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
@@ -262,37 +290,82 @@ go.abhg.dev/goldmark/mermaid v0.5.0 h1:mDkykpSPJ+5wCQ8bSXgzJ2KQskjXkI5Ndxz7JYDHW
|
||||
go.abhg.dev/goldmark/mermaid v0.5.0/go.mod h1:OCyk2o85TX2drWHH+HRy6bih2yZlUwbbv/R1MMh1YLs=
|
||||
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
|
||||
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
version: 16.5.6
|
||||
version: 16.7.27
|
||||
- name: meilisearch
|
||||
repository: https://meilisearch.github.io/meilisearch-kubernetes
|
||||
version: 0.12.0
|
||||
digest: sha256:31084e570aa16e3a26317aeb6d0d5dec62540c314ee4f703374e6e7827399fa6
|
||||
generated: "2025-03-27T11:34:51.840778733+01:00"
|
||||
version: 0.17.1
|
||||
digest: sha256:ad702e35f258fed1f804d3e48b071767499f5730e099a8c461610950e5182368
|
||||
generated: "2025-09-21T04:49:08.679554149+02:00"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: opengist
|
||||
description: Opengist Helm chart for Kubernetes
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: 1.10.0
|
||||
version: 0.4.0
|
||||
appVersion: 1.11.1
|
||||
home: https://opengist.io
|
||||
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/opengist.svg
|
||||
sources:
|
||||
@@ -11,9 +11,9 @@ sources:
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
version: 16.5.6
|
||||
version: 16.7.27
|
||||
condition: postgresql.enabled
|
||||
- name: meilisearch
|
||||
repository: https://meilisearch.github.io/meilisearch-kubernetes
|
||||
version: 0.12.0
|
||||
version: 0.17.1
|
||||
condition: meilisearch.enabled
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Opengist Helm Chart
|
||||
|
||||
 
|
||||
 
|
||||
|
||||
Opengist Helm chart for Kubernetes.
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ spec:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.deployment.terminationGracePeriodSeconds }}
|
||||
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
@@ -49,6 +52,10 @@ spec:
|
||||
mountPath: /init/config
|
||||
- name: config-volume
|
||||
mountPath: /config-volume
|
||||
{{- if .Values.deployment.env }}
|
||||
env:
|
||||
{{- toYaml .Values.deployment.env | nindent 12 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
|
||||
@@ -17,7 +17,7 @@ configExistingSecret: ""
|
||||
image:
|
||||
repository: ghcr.io/thomiceli/opengist
|
||||
pullPolicy: Always
|
||||
tag: "1.10.0"
|
||||
tag: "1.11.1"
|
||||
digest: ""
|
||||
imagePullSecrets: []
|
||||
# - name: "image-pull-secret"
|
||||
|
||||
64
internal/auth/ldap/ldap.go
Normal file
64
internal/auth/ldap/ldap.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
)
|
||||
|
||||
func Enabled() bool {
|
||||
return config.C.LDAPUrl != ""
|
||||
}
|
||||
|
||||
// Authenticate attempts to authenticate a user against the configured LDAP instance.
|
||||
func Authenticate(username, password string) (bool, error) {
|
||||
l, err := ldap.DialURL(config.C.LDAPUrl)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to connect to URI: %v", config.C.LDAPUrl)
|
||||
}
|
||||
defer func(l *ldap.Conn) {
|
||||
_ = l.Close()
|
||||
}(l)
|
||||
|
||||
// First bind with a read only user
|
||||
err = l.Bind(config.C.LDAPBindDn, config.C.LDAPBindCredentials)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
searchFilter := fmt.Sprintf(config.C.LDAPSearchFilter, username)
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.C.LDAPSearchBase,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
searchFilter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
sr, err := l.Search(searchRequest)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(sr.Entries) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Bind as the user to verify their password
|
||||
err = l.Bind(sr.Entries[0].DN, password)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Rebind as the read only user for any further queries
|
||||
err = l.Bind(config.C.LDAPBindDn, config.C.LDAPBindCredentials)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -2,13 +2,17 @@ package oauth
|
||||
|
||||
import (
|
||||
gocontext "context"
|
||||
gojson "encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/gothic"
|
||||
"github.com/markbates/goth/providers/gitlab"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type GitLabProvider struct {
|
||||
@@ -77,7 +81,34 @@ func (p *GitLabCallbackProvider) GetProviderUserSSHKeys() ([]string, error) {
|
||||
|
||||
func (p *GitLabCallbackProvider) UpdateUserDB(user *db.User) {
|
||||
user.GitlabID = p.User.UserID
|
||||
user.AvatarURL = urlJoin(config.C.GitlabUrl, "/uploads/-/system/user/avatar/", p.User.UserID, "/avatar.png") + "?width=400"
|
||||
|
||||
resp, err := http.Get(urlJoin(config.C.GitlabUrl, "/api/v4/avatar?size=400&email=", p.User.Email))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Cannot get user avatar from GitLab")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Cannot read Gitlab response body")
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = gojson.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Cannot unmarshal Gitlab response body")
|
||||
return
|
||||
}
|
||||
|
||||
field, ok := result["avatar_url"]
|
||||
if !ok {
|
||||
log.Error().Msg("Field 'avatar_url' not found in Gitlab JSON response")
|
||||
return
|
||||
}
|
||||
|
||||
user.AvatarURL = field.(string)
|
||||
}
|
||||
|
||||
func NewGitLabCallbackProvider(user *goth.User) CallbackProvider {
|
||||
|
||||
@@ -25,6 +25,7 @@ func (p *OIDCProvider) RegisterProvider() error {
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
config.C.OIDCGroupClaimName,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package auth
|
||||
package password
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -6,8 +6,9 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
type argon2ID struct {
|
||||
@@ -1,11 +1,9 @@
|
||||
package password
|
||||
|
||||
import "github.com/thomiceli/opengist/internal/auth"
|
||||
|
||||
func HashPassword(code string) (string, error) {
|
||||
return auth.Argon2id.Hash(code)
|
||||
return Argon2id.Hash(code)
|
||||
}
|
||||
|
||||
func VerifyPassword(code, hashedCode string) (bool, error) {
|
||||
return auth.Argon2id.Verify(code, hashedCode)
|
||||
return Argon2id.Verify(code, hashedCode)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package auth
|
||||
package totp
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
83
internal/auth/try_login.go
Normal file
83
internal/auth/try_login.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/auth/ldap"
|
||||
passwordpkg "github.com/thomiceli/opengist/internal/auth/password"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e AuthError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func TryAuthentication(username, password string) (*db.User, error) {
|
||||
user, err := db.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Error().Err(err).Msgf("Cannot get user by username %s", username)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if user.Password != "" {
|
||||
return tryDbLogin(user, password)
|
||||
} else {
|
||||
if ldap.Enabled() {
|
||||
return tryLdapLogin(username, password)
|
||||
}
|
||||
return nil, AuthError{"no authentication method available"}
|
||||
}
|
||||
}
|
||||
|
||||
func tryDbLogin(user *db.User, password string) (*db.User, error) {
|
||||
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Password verification failed")
|
||||
return nil, err
|
||||
}
|
||||
return nil, AuthError{"invalid password"}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func tryLdapLogin(username, password string) (user *db.User, err error) {
|
||||
ok, err := ldap.Authenticate(username, password)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("LDAP authentication failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return nil, AuthError{"invalid LDAP credentials"}
|
||||
}
|
||||
|
||||
if user, err = db.GetUserByUsername(username); err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Error().Err(err).Msgf("Cannot get user by username %s", username)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
user = &db.User{
|
||||
Username: username,
|
||||
}
|
||||
if err = user.Create(); err != nil {
|
||||
log.Warn().Err(err).Msg("Cannot create user after LDAP authentication")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -36,11 +36,12 @@ var CmdStart = cli.Command{
|
||||
|
||||
Initialize(ctx)
|
||||
|
||||
go server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false).Start()
|
||||
server := server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false)
|
||||
go server.Start()
|
||||
go ssh.Start()
|
||||
|
||||
<-stopCtx.Done()
|
||||
shutdown()
|
||||
shutdown(server)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -130,7 +131,7 @@ func Initialize(ctx *cli.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
func shutdown(server *server.Server) {
|
||||
log.Info().Msg("Shutting down database...")
|
||||
if err := db.Close(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to close database")
|
||||
@@ -141,6 +142,8 @@ func shutdown() {
|
||||
index.Close()
|
||||
}
|
||||
|
||||
server.Stop()
|
||||
|
||||
log.Info().Msg("Shutdown complete")
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ type config struct {
|
||||
HttpPort string `yaml:"http.port" env:"OG_HTTP_PORT"`
|
||||
HttpGit bool `yaml:"http.git-enabled" env:"OG_HTTP_GIT_ENABLED"`
|
||||
|
||||
UnixSocketPermissions string `yaml:"unix-socket-permissions" env:"OG_UNIX_SOCKET_PERMISSIONS"`
|
||||
|
||||
SshGit bool `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"`
|
||||
SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"`
|
||||
SshPort string `yaml:"ssh.port" env:"OG_SSH_PORT"`
|
||||
@@ -79,6 +81,12 @@ type config struct {
|
||||
|
||||
MetricsEnabled bool `yaml:"metrics.enabled" env:"OG_METRICS_ENABLED"`
|
||||
|
||||
LDAPUrl string `yaml:"ldap.url" env:"OG_LDAP_URL"`
|
||||
LDAPBindDn string `yaml:"ldap.bind-dn" env:"OG_LDAP_BIND_DN"`
|
||||
LDAPBindCredentials string `yaml:"ldap.bind-credentials" env:"OG_LDAP_BIND_CREDENTIALS"`
|
||||
LDAPSearchBase string `yaml:"ldap.search-base" env:"OG_LDAP_SEARCH_BASE"`
|
||||
LDAPSearchFilter string `yaml:"ldap.search-filter" env:"OG_LDAP_SEARCH_FILTER"`
|
||||
|
||||
CustomName string `yaml:"custom.name" env:"OG_CUSTOM_NAME"`
|
||||
CustomLogo string `yaml:"custom.logo" env:"OG_CUSTOM_LOGO"`
|
||||
CustomFavicon string `yaml:"custom.favicon" env:"OG_CUSTOM_FAVICON"`
|
||||
@@ -107,6 +115,8 @@ func configWithDefaults() (*config, error) {
|
||||
c.HttpPort = "6157"
|
||||
c.HttpGit = true
|
||||
|
||||
c.UnixSocketPermissions = "0666"
|
||||
|
||||
c.SshGit = true
|
||||
c.SshHost = "0.0.0.0"
|
||||
c.SshPort = "2222"
|
||||
|
||||
@@ -3,16 +3,17 @@ 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"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"gorm.io/gorm"
|
||||
@@ -39,6 +40,7 @@ type databaseInfo struct {
|
||||
User string
|
||||
Password string
|
||||
Database string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
var DatabaseInfo *databaseInfo
|
||||
@@ -46,6 +48,8 @@ var DatabaseInfo *databaseInfo
|
||||
func parseDBURI(uri string) (*databaseInfo, error) {
|
||||
info := &databaseInfo{}
|
||||
|
||||
info.SSLMode = "disable"
|
||||
|
||||
if uri == ":memory:" {
|
||||
info.Type = SQLite
|
||||
info.Database = uri
|
||||
@@ -85,6 +89,13 @@ func parseDBURI(uri string) (*databaseInfo, error) {
|
||||
info.Password, _ = u.User.Password()
|
||||
}
|
||||
|
||||
if u.RawQuery != "" {
|
||||
q, _ := url.ParseQuery(u.RawQuery)
|
||||
if sslmode := q.Get("sslmode"); sslmode != "" && info.Type == PostgreSQL {
|
||||
info.SSLMode = sslmode
|
||||
}
|
||||
}
|
||||
|
||||
switch info.Type {
|
||||
case PostgreSQL, MySQL:
|
||||
info.Database = strings.TrimPrefix(u.Path, "/")
|
||||
@@ -144,7 +155,7 @@ func Setup(dbUri string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}); err != nil {
|
||||
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -222,7 +233,7 @@ func setupSQLite(dbInfo databaseInfo) error {
|
||||
|
||||
func setupPostgres(dbInfo databaseInfo) error {
|
||||
var err error
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", dbInfo.Host, dbInfo.Port, dbInfo.User, dbInfo.Password, dbInfo.Database)
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", dbInfo.Host, dbInfo.Port, dbInfo.User, dbInfo.Password, dbInfo.Database, dbInfo.SSLMode)
|
||||
|
||||
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
@@ -258,5 +269,5 @@ func DeprecationDBFilename() {
|
||||
}
|
||||
|
||||
func TruncateDatabase() error {
|
||||
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{})
|
||||
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -420,12 +418,20 @@ func (gist *Gist) Files(revision string, truncate bool) ([]*git.File, error) {
|
||||
|
||||
var files []*git.File
|
||||
for _, fileCat := range filesCat {
|
||||
var shortContent string
|
||||
if len(fileCat.Content) > 512 {
|
||||
shortContent = fileCat.Content[:512]
|
||||
} else {
|
||||
shortContent = fileCat.Content
|
||||
}
|
||||
|
||||
files = append(files, &git.File{
|
||||
Filename: fileCat.Name,
|
||||
Size: fileCat.Size,
|
||||
HumanSize: humanize.IBytes(fileCat.Size),
|
||||
Content: fileCat.Content,
|
||||
Truncated: fileCat.Truncated,
|
||||
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(fileCat.Name)),
|
||||
})
|
||||
}
|
||||
return files, err
|
||||
@@ -446,12 +452,20 @@ func (gist *Gist) File(revision string, filename string, truncate bool) (*git.Fi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var shortContent string
|
||||
if len(content) > 512 {
|
||||
shortContent = content[:512]
|
||||
} else {
|
||||
shortContent = content
|
||||
}
|
||||
|
||||
return &git.File{
|
||||
Filename: filename,
|
||||
Size: size,
|
||||
HumanSize: humanize.IBytes(size),
|
||||
Content: content,
|
||||
Truncated: truncated,
|
||||
MimeType: git.DetectMimeType([]byte(shortContent), filepath.Ext(filename)),
|
||||
}, err
|
||||
}
|
||||
|
||||
@@ -473,8 +487,14 @@ func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error {
|
||||
}
|
||||
|
||||
for _, file := range *files {
|
||||
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
|
||||
return err
|
||||
if file.SourcePath != "" { // if it's an uploaded file
|
||||
if err := git.MoveFileToRepository(gist.Uuid, file.Filename, file.SourcePath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else { // else it's a text editor file
|
||||
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,19 +552,28 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
|
||||
gist.Preview = ""
|
||||
gist.PreviewFilename = ""
|
||||
} else {
|
||||
file, err := gist.File("HEAD", filesStr[0], true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fileStr := range filesStr {
|
||||
file, err := gist.File("HEAD", fileStr, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if file == nil {
|
||||
continue
|
||||
}
|
||||
gist.Preview = ""
|
||||
gist.PreviewFilename = file.Filename
|
||||
|
||||
split := strings.Split(file.Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = file.Content
|
||||
}
|
||||
if !file.MimeType.CanBeEdited() {
|
||||
continue
|
||||
}
|
||||
|
||||
gist.PreviewFilename = file.Filename
|
||||
split := strings.Split(file.Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = file.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if withTimestampUpdate {
|
||||
@@ -613,30 +642,6 @@ func (gist *Gist) TopicsSlice() []string {
|
||||
return topics
|
||||
}
|
||||
|
||||
func (gist *Gist) SerialiseInitRepository() error {
|
||||
var gobBuffer bytes.Buffer
|
||||
encoder := gob.NewEncoder(&gobBuffer)
|
||||
if err := encoder.Encode(gist); err != nil {
|
||||
return fmt.Errorf("gob encoding error: %v", err)
|
||||
}
|
||||
|
||||
return git.SerialiseInitRepository(gist.User.Username, gobBuffer.Bytes())
|
||||
}
|
||||
|
||||
func DeserialiseInitRepository(user string) (*Gist, error) {
|
||||
data, err := git.DeserialiseInitRepository(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var gist Gist
|
||||
decoder := gob.NewDecoder(bytes.NewReader(data))
|
||||
if err := decoder.Decode(&gist); err != nil {
|
||||
return nil, fmt.Errorf("gob decoding error: %v", err)
|
||||
}
|
||||
return &gist, nil
|
||||
}
|
||||
|
||||
func (gist *Gist) UpdateLanguages() {
|
||||
languages, err := gist.GetLanguagesFromFiles()
|
||||
if err != nil {
|
||||
@@ -686,10 +691,15 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
|
||||
|
||||
fileDTOs := make([]FileDTO, 0, len(files))
|
||||
for _, file := range files {
|
||||
fileDTOs = append(fileDTOs, FileDTO{
|
||||
f := FileDTO{
|
||||
Filename: file.Filename,
|
||||
Content: file.Content,
|
||||
})
|
||||
}
|
||||
if file.MimeType.CanBeEdited() {
|
||||
f.Content = file.Content
|
||||
} else {
|
||||
f.Binary = true
|
||||
}
|
||||
fileDTOs = append(fileDTOs, f)
|
||||
}
|
||||
|
||||
return &GistDTO{
|
||||
@@ -726,8 +736,10 @@ type VisibilityDTO struct {
|
||||
}
|
||||
|
||||
type FileDTO struct {
|
||||
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
|
||||
Content string `validate:"required"`
|
||||
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
|
||||
Content string
|
||||
Binary bool
|
||||
SourcePath string // Path to uploaded file, used instead of Content when present
|
||||
}
|
||||
|
||||
func (dto *GistDTO) ToGist() *Gist {
|
||||
|
||||
34
internal/db/gist_init_queue.go
Normal file
34
internal/db/gist_init_queue.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
type GistInitQueue struct {
|
||||
GistID uint `gorm:"primaryKey"`
|
||||
Gist Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:GistID"`
|
||||
UserID uint `gorm:"primaryKey"`
|
||||
User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
}
|
||||
|
||||
func GetInitGistInQueueForUser(userID uint) (*Gist, error) {
|
||||
queue := new(GistInitQueue)
|
||||
err := db.Preload("Gist").Preload("Gist.User").
|
||||
Where("user_id = ?", userID).
|
||||
Order("gist_id asc").
|
||||
First(&queue).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = db.Delete(&queue).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &queue.Gist, nil
|
||||
}
|
||||
|
||||
func AddInitGistToQueue(gistID uint, userID uint) error {
|
||||
queue := &GistInitQueue{
|
||||
GistID: gistID,
|
||||
UserID: userID,
|
||||
}
|
||||
return db.Create(&queue).Error
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/thomiceli/opengist/internal/auth"
|
||||
"slices"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/auth/password"
|
||||
ogtotp "github.com/thomiceli/opengist/internal/auth/totp"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type TOTP struct {
|
||||
@@ -31,7 +31,7 @@ func GetTOTPByUserID(userID uint) (*TOTP, error) {
|
||||
|
||||
func (totp *TOTP) StoreSecret(secret string) error {
|
||||
secretBytes := []byte(secret)
|
||||
encrypted, err := auth.AESEncrypt(config.SecretKey, secretBytes)
|
||||
encrypted, err := ogtotp.AESEncrypt(config.SecretKey, secretBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func (totp *TOTP) ValidateCode(code string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
secretBytes, err := auth.AESDecrypt(config.SecretKey, ciphertext)
|
||||
secretBytes, err := ogtotp.AESDecrypt(config.SecretKey, ciphertext)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"uniqueIndex,size:191"`
|
||||
Password string
|
||||
IsAdmin bool
|
||||
CreatedAt int64
|
||||
Email string
|
||||
MD5Hash string // for gravatar, if no Email is specified, the value is random
|
||||
AvatarURL string
|
||||
GithubID string
|
||||
GitlabID string
|
||||
GiteaID string
|
||||
OIDCID string `gorm:"column:oidc_id"`
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"uniqueIndex,size:191"`
|
||||
Password string
|
||||
IsAdmin bool
|
||||
CreatedAt int64
|
||||
Email string
|
||||
MD5Hash string // for gravatar, if no Email is specified, the value is random
|
||||
AvatarURL string
|
||||
GithubID string
|
||||
GitlabID string
|
||||
GiteaID string
|
||||
OIDCID string `gorm:"column:oidc_id"`
|
||||
StylePreferences string
|
||||
|
||||
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
@@ -234,6 +236,15 @@ func (user *User) HasMFA() (bool, bool, error) {
|
||||
return webauthn, totp, err
|
||||
}
|
||||
|
||||
func (user *User) GetStyle() *UserStyleDTO {
|
||||
style := new(UserStyleDTO)
|
||||
err := json.Unmarshal([]byte(user.StylePreferences), style)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// -- DTO -- //
|
||||
|
||||
type UserDTO struct {
|
||||
@@ -251,3 +262,18 @@ func (dto *UserDTO) ToUser() *User {
|
||||
type UserUsernameDTO struct {
|
||||
Username string `form:"username" validate:"required,max=24,alphanumdash,notreserved"`
|
||||
}
|
||||
|
||||
type UserStyleDTO struct {
|
||||
SoftWrap bool `form:"softwrap" json:"soft_wrap"`
|
||||
RemovedLineColor string `form:"removedlinecolor" json:"removed_line_color" validate:"min=0,max=7"`
|
||||
AddedLineColor string `form:"addedlinecolor" json:"added_line_color" validate:"min=0,max=7"`
|
||||
GitLineColor string `form:"gitlinecolor" json:"git_line_color" validate:"min=0,max=7"`
|
||||
}
|
||||
|
||||
func (dto *UserStyleDTO) ToJson() string {
|
||||
data, err := json.Marshal(dto)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
@@ -203,6 +202,11 @@ func CatFileBatch(user string, gist string, revision string, truncate bool) ([]*
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Don't truncate Jupyter notebooks
|
||||
if strings.HasSuffix(file.Name, ".ipynb") {
|
||||
truncate = false
|
||||
}
|
||||
|
||||
sizeToRead := size
|
||||
if truncate && sizeToRead > truncateLimit {
|
||||
sizeToRead = truncateLimit
|
||||
@@ -381,6 +385,17 @@ func SetFileContent(gistTmpId string, filename string, content string) error {
|
||||
return os.WriteFile(filepath.Join(repositoryPath, filename), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func MoveFileToRepository(gistTmpId string, filename string, sourcePath string) error {
|
||||
repositoryPath := TmpRepositoryPath(gistTmpId)
|
||||
destPath := filepath.Join(repositoryPath, filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(sourcePath, destPath)
|
||||
}
|
||||
|
||||
func AddAll(gistTmpId string) error {
|
||||
tmpPath := TmpRepositoryPath(gistTmpId)
|
||||
|
||||
@@ -565,50 +580,6 @@ func DeleteUserDirectory(user string) error {
|
||||
return os.RemoveAll(filepath.Join(config.GetHomeDir(), ReposDirectory, user))
|
||||
}
|
||||
|
||||
func SerialiseInitRepository(user string, serialized []byte) error {
|
||||
userRepositoryPath := UserRepositoriesPath(user)
|
||||
initPath := filepath.Join(userRepositoryPath, "_init")
|
||||
|
||||
f, err := os.OpenFile(initPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
encodedData := base64.StdEncoding.EncodeToString(serialized)
|
||||
_, err = f.Write(append([]byte(encodedData), '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func DeserialiseInitRepository(user string) ([]byte, error) {
|
||||
initPath := filepath.Join(UserRepositoriesPath(user), "_init")
|
||||
|
||||
content, err := os.ReadFile(initPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idx := bytes.Index(content, []byte{'\n'})
|
||||
if idx == -1 {
|
||||
return base64.StdEncoding.DecodeString(string(content))
|
||||
}
|
||||
|
||||
firstLine := content[:idx]
|
||||
remaining := content[idx+1:]
|
||||
|
||||
if len(remaining) == 0 {
|
||||
if err := os.Remove(initPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to remove file: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.WriteFile(initPath, remaining, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to write remaining content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return base64.StdEncoding.DecodeString(string(firstLine))
|
||||
}
|
||||
|
||||
func createDotGitHookFile(repositoryPath string, hook string, content string) error {
|
||||
preReceiveDst, err := os.OpenFile(filepath.Join(repositoryPath, "hooks", hook), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0744)
|
||||
if err != nil {
|
||||
@@ -672,7 +643,7 @@ func convertUTF8ToOctal(name string) string {
|
||||
}
|
||||
|
||||
func convertURLToOctal(name string) string {
|
||||
decoded, err := url.QueryUnescape(name)
|
||||
decoded, err := url.PathUnescape(name)
|
||||
if err != nil {
|
||||
return name
|
||||
}
|
||||
|
||||
91
internal/git/mime.go
Normal file
91
internal/git/mime.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
type MimeType struct {
|
||||
ContentType string
|
||||
extension string
|
||||
}
|
||||
|
||||
func (mt MimeType) IsText() bool {
|
||||
return strings.Contains(mt.ContentType, "text/")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsCSV() bool {
|
||||
return strings.Contains(mt.ContentType, "text/csv") &&
|
||||
(strings.HasSuffix(mt.extension, ".csv"))
|
||||
}
|
||||
|
||||
func (mt MimeType) IsImage() bool {
|
||||
return strings.Contains(mt.ContentType, "image/")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsSVG() bool {
|
||||
return strings.Contains(mt.ContentType, "image/svg+xml")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsPDF() bool {
|
||||
return strings.Contains(mt.ContentType, "application/pdf")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsAudio() bool {
|
||||
return strings.Contains(mt.ContentType, "audio/")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsVideo() bool {
|
||||
return strings.Contains(mt.ContentType, "video/")
|
||||
}
|
||||
|
||||
func (mt MimeType) CanBeHighlighted() bool {
|
||||
return mt.IsText() && !mt.IsCSV()
|
||||
}
|
||||
|
||||
func (mt MimeType) CanBeEmbedded() bool {
|
||||
return mt.IsImage() || mt.IsPDF() || mt.IsAudio() || mt.IsVideo()
|
||||
}
|
||||
|
||||
func (mt MimeType) CanBeRendered() bool {
|
||||
return mt.IsText() || mt.IsImage() || mt.IsSVG() || mt.IsPDF() || mt.IsAudio() || mt.IsVideo()
|
||||
}
|
||||
|
||||
func (mt MimeType) CanBeEdited() bool {
|
||||
return mt.IsText() || mt.IsSVG()
|
||||
}
|
||||
|
||||
func (mt MimeType) RenderType() string {
|
||||
t := strings.Split(mt.ContentType, "/")
|
||||
str := ""
|
||||
if len(t) == 2 {
|
||||
str = fmt.Sprintf("(%s)", strings.ToUpper(t[1]))
|
||||
}
|
||||
|
||||
// More user friendly description
|
||||
if mt.IsImage() || mt.IsSVG() {
|
||||
return fmt.Sprintf("Image %s", str)
|
||||
}
|
||||
if mt.IsAudio() {
|
||||
return fmt.Sprintf("Audio %s", str)
|
||||
}
|
||||
if mt.IsVideo() {
|
||||
return fmt.Sprintf("Video %s", str)
|
||||
}
|
||||
if mt.IsPDF() {
|
||||
return "PDF"
|
||||
}
|
||||
if mt.IsCSV() {
|
||||
return "CSV"
|
||||
}
|
||||
if mt.IsText() {
|
||||
return "Text"
|
||||
}
|
||||
return "Binary"
|
||||
}
|
||||
|
||||
func DetectMimeType(data []byte, extension string) MimeType {
|
||||
return MimeType{mimetype.Detect(data).String(), extension}
|
||||
}
|
||||
@@ -3,27 +3,23 @@ package git
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Filename string `json:"filename"`
|
||||
Size uint64 `json:"size"`
|
||||
HumanSize string `json:"human_size"`
|
||||
OldFilename string `json:"-"`
|
||||
Content string `json:"content"`
|
||||
Truncated bool `json:"truncated"`
|
||||
IsCreated bool `json:"-"`
|
||||
IsDeleted bool `json:"-"`
|
||||
}
|
||||
|
||||
type CsvFile struct {
|
||||
File
|
||||
Header []string
|
||||
Rows [][]string
|
||||
Filename string `json:"filename"`
|
||||
Size uint64 `json:"size"`
|
||||
HumanSize string `json:"human_size"`
|
||||
OldFilename string `json:"-"`
|
||||
Content string `json:"content"`
|
||||
Truncated bool `json:"truncated"`
|
||||
IsCreated bool `json:"-"`
|
||||
IsDeleted bool `json:"-"`
|
||||
IsBinary bool `json:"-"`
|
||||
MimeType MimeType `json:"-"`
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
@@ -62,6 +58,8 @@ func truncateCommandOutput(out io.Reader, maxBytes int64) (string, bool, error)
|
||||
return string(buf), truncated, nil
|
||||
}
|
||||
|
||||
var reLogBinaryNames = regexp.MustCompile(`Binary files (.+) and (.+) differ`)
|
||||
|
||||
// inspired from https://github.com/go-gitea/gitea/blob/main/services/gitdiff/gitdiff.go
|
||||
func parseLog(out io.Reader, maxFiles int, maxBytes int) ([]*Commit, error) {
|
||||
var commits []*Commit
|
||||
@@ -206,6 +204,20 @@ loopLog:
|
||||
currentFile.IsCreated = true
|
||||
case strings.HasPrefix(line, "deleted file"):
|
||||
currentFile.IsDeleted = true
|
||||
case strings.HasPrefix(line, "Binary files"):
|
||||
currentFile.IsBinary = true
|
||||
names := reLogBinaryNames.FindStringSubmatch(line)
|
||||
if names[1][2:] != names[2][2:] {
|
||||
if currentFile.IsCreated {
|
||||
currentFile.Filename = convertOctalToUTF8(names[2])[2:]
|
||||
}
|
||||
if currentFile.IsDeleted {
|
||||
currentFile.Filename = convertOctalToUTF8(names[1])[2:]
|
||||
}
|
||||
} else {
|
||||
currentFile.OldFilename = convertOctalToUTF8(names[1])[2:]
|
||||
currentFile.Filename = convertOctalToUTF8(names[2])[2:]
|
||||
}
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
name := convertOctalToUTF8(line[4 : len(line)-1])
|
||||
if parseRename && currentFile.IsDeleted {
|
||||
@@ -344,27 +356,3 @@ func skipToNextCommit(input *bufio.Reader) (line string, err error) {
|
||||
}
|
||||
return line, err
|
||||
}
|
||||
|
||||
func ParseCsv(file *File) (*CsvFile, error) {
|
||||
|
||||
reader := csv.NewReader(strings.NewReader(file.Content))
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
header := records[0]
|
||||
numColumns := len(header)
|
||||
|
||||
for i := 1; i < len(records); i++ {
|
||||
if len(records[i]) != numColumns {
|
||||
return nil, fmt.Errorf("CSV file has invalid row at index %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
return &CsvFile{
|
||||
File: *file,
|
||||
Header: header,
|
||||
Rows: records[1:],
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ gist.header.embed: 'Einbetten'
|
||||
gist.header.embed-help: 'Bette diese Gist in deine Webseite ein.'
|
||||
gist.header.download-zip: 'ZIP Herunterladen'
|
||||
|
||||
gist.raw: 'Orginalformat'
|
||||
gist.raw: 'Originalformat'
|
||||
gist.file-truncated: 'Diese Datei wurde abgeschnitten.'
|
||||
gist.watch-full-file: 'Die gesamte Datei anzeigen.'
|
||||
gist.file-not-valid: 'Diese Datei ist keine korrekte CSV Datei.'
|
||||
@@ -37,7 +37,7 @@ gist.new.indent-mode-space: 'Leerzeichen'
|
||||
gist.new.indent-mode-tab: 'Tab'
|
||||
gist.new.indent-size: 'Einrückungs Größe'
|
||||
gist.new.wrap-mode: 'Textumbruch Modus'
|
||||
gist.new.wrap-mode-no: 'kein Textumruch'
|
||||
gist.new.wrap-mode-no: 'kein Textumbruch'
|
||||
gist.new.wrap-mode-soft: 'weicher Zeilenumbruch'
|
||||
gist.new.add-file: 'Datei hinzufügen'
|
||||
gist.new.create-public-button: 'Öffentliche Gist erstellen'
|
||||
@@ -53,7 +53,7 @@ gist.edit.delete: 'Löschen'
|
||||
gist.edit.cancel: 'Abbrechen'
|
||||
gist.edit.save: 'Speichern'
|
||||
|
||||
gist.list.joined: 'Gemeinsam'
|
||||
gist.list.joined: 'Beigetreten'
|
||||
gist.list.all: 'Alle Gists'
|
||||
gist.list.search-results: 'Suchergebnisse'
|
||||
gist.list.sort: 'Sortieren'
|
||||
@@ -61,17 +61,17 @@ gist.list.sort-by-created: 'erstellt'
|
||||
gist.list.sort-by-updated: 'bearbeitet'
|
||||
gist.list.order-by-asc: 'Älteste'
|
||||
gist.list.order-by-desc: 'Neueste'
|
||||
gist.list.select-tab: 'Tab Auswählen'
|
||||
gist.list.select-tab: 'Tab auswählen'
|
||||
gist.list.liked: 'Favorisiert'
|
||||
gist.list.likes: 'Favoriten'
|
||||
gist.list.forked: 'Forked'
|
||||
gist.list.forked-from: 'Forked von'
|
||||
gist.list.forked: 'Geforkt'
|
||||
gist.list.forked-from: 'Geforkt von'
|
||||
gist.list.forks: 'Forks'
|
||||
gist.list.files: 'Dateien'
|
||||
gist.list.last-active: 'Zuletzt aktiv'
|
||||
gist.list.no-gists: 'Keine Gists'
|
||||
gist.list.all-liked-by: 'Alle Gists favorisiert von %s'
|
||||
gist.list.all-forked-by: 'Alle Gists geforked von %s'
|
||||
gist.list.all-forked-by: 'Alle Gists geforkt von %s'
|
||||
gist.list.all-from: 'Alle Gists von %s'
|
||||
|
||||
gist.search.found: 'Gists gefunden'
|
||||
@@ -89,7 +89,7 @@ gist.forks.for: 'Fork für %s'
|
||||
|
||||
gist.likes: 'Favoriten'
|
||||
gist.likes.no: 'Keine Favorisierungen'
|
||||
gist.likes.for: 'Favortitisiert für %s'
|
||||
gist.likes.for: 'Favorisiert für %s'
|
||||
|
||||
gist.revisions: 'Revisionen'
|
||||
gist.revision.revised: 'hat die Gist bearbeitet'
|
||||
@@ -112,7 +112,7 @@ settings.link-accounts: 'Accounts verlinken'
|
||||
settings.link-github-account: 'GitHub-Account verlinken'
|
||||
settings.link-gitlab-account: 'GitLab-Account verlinken'
|
||||
settings.link-gitea-account: 'Gitea-Account verlinken'
|
||||
settings.unlink-github-account: 'Github-Account Verlinkung aufheben'
|
||||
settings.unlink-github-account: 'GitHub-Account Verlinkung aufheben'
|
||||
settings.unlink-gitlab-account: 'GitLab-Account Verlinkung aufheben'
|
||||
settings.unlink-gitea-account: 'Gitea-Account Verlinkung aufheben'
|
||||
settings.delete-account: 'Account löschen'
|
||||
|
||||
@@ -23,9 +23,12 @@ gist.header.download-zip: Download ZIP
|
||||
|
||||
gist.raw: Raw
|
||||
gist.file-truncated: This file has been truncated.
|
||||
gist.file-raw: This file can't be rendered.
|
||||
gist.file-binary-edit: This file is binary.
|
||||
gist.watch-full-file: View the full file.
|
||||
gist.file-not-valid: This file is not a valid CSV file.
|
||||
gist.no-content: No files found
|
||||
gist.preview-non-available: Preview not available
|
||||
|
||||
gist.new.new_gist: New gist
|
||||
gist.new.title: Title
|
||||
@@ -46,6 +49,8 @@ gist.new.create-private-button: Create private gist
|
||||
gist.new.preview: Preview
|
||||
gist.new.create-a-new-gist: Create a new gist
|
||||
gist.new.topics: Topics (separate with spaces)
|
||||
gist.new.drop-files: Drop files here or click to upload
|
||||
gist.new.any-file-type: Upload any file type
|
||||
|
||||
gist.edit.editing: Editing
|
||||
gist.edit.edit-gist: Edit %s
|
||||
@@ -115,6 +120,7 @@ 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.binary-file-changes: Binary file changes are not shown
|
||||
gist.revision.no-changes: No changes
|
||||
gist.revision.no-revisions: No revisions to show
|
||||
gist.revision-of: Revision of %s
|
||||
@@ -148,6 +154,17 @@ settings.create-password-help: Create your password to login to Opengist via HTT
|
||||
settings.change-password: Change password
|
||||
settings.change-password-help: Change your password to login to Opengist via HTTP
|
||||
settings.password-label-title: Password
|
||||
settings.header.account: Account
|
||||
settings.header.mfa: MFA
|
||||
settings.header.ssh: SSH
|
||||
settings.header.style: Style
|
||||
settings.style.gist-code: Gist code
|
||||
settings.style.no-soft-wrap: No Soft Wrap
|
||||
settings.style.soft-wrap: Soft Wrap
|
||||
settings.style.removed-lines-color: Removed lines color
|
||||
settings.style.added-lines-color: Added lines color
|
||||
settings.style.git-lines-color: Git lines color
|
||||
settings.style.save-style: Save style
|
||||
|
||||
auth.signup-disabled: Administrator has disabled signing up
|
||||
auth.login: Login
|
||||
@@ -204,6 +221,8 @@ 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
|
||||
error.no-file-uploaded: No file uploaded
|
||||
error.cannot-open-file: Cannot open uploaded file
|
||||
|
||||
header.menu.all: All
|
||||
header.menu.new: New
|
||||
|
||||
@@ -266,3 +266,67 @@ validation.invalid: Geçersiz %s
|
||||
|
||||
html.title.admin-panel: Yönetici paneli
|
||||
settings.ssh-key-exists: SSH anahtarı zaten mevcut
|
||||
gist.search.help.topic: Verilen konuyla ilgili gist'ler
|
||||
gist.search.placeholder.unlisted: Listelenmemiş
|
||||
settings.header.style: Stil
|
||||
auth.mfa.passkey: Parola Anahtarı
|
||||
auth.mfa.waiting-for-passkey-input: Tarayıcı etkileşiminden gelecek girdi bekleniyor...
|
||||
settings.header.account: Hesap
|
||||
settings.style.no-soft-wrap: Yumuşak Satır Kaydırma Yok
|
||||
auth.totp: Zamana Dayalı Tek Kullanımlık Parola (TOTP)
|
||||
flash.admin.sync-gist-languages: Gist dilleri senkronize ediliyor...
|
||||
auth.mfa.passkeys-help: Hesabınıza giriş yapmak ve çok faktörlü kimlik doğrulama yöntemi olarak kullanmak için bir geçiş anahtarı ekleyin.
|
||||
validation.invalid-gist-topics: Geçersiz gist konuları, harf veya rakamla başlamalı, 50 karakterden uzun olmamalı ve tire içerebilir.
|
||||
auth.totp.enter-recovery-key: veya cihazınızı kaybettiyseniz kurtarma anahtarını kullanın
|
||||
auth.totp.save-recovery-codes: Kurtarma kodlarınızı güvenli bir yerde saklayın. Bu kodları, kimlik doğrulayıcı uygulamanıza erişimi kaybetmeniz durumunda hesabınıza yeniden erişmek için kullanabilirsiniz.
|
||||
error.not-in-mfa-session: Kullanıcı çok faktörlü kimlik doğrulama oturumunda değil
|
||||
admin.invitations.delete_confirm: Bu daveti silmek istiyor musunuz?
|
||||
auth.totp.help: TOTP, paylaşılan bir gizli anahtarı kullanarak tek kullanımlık bir parola üreten, iki faktörlü kimlik doğrulama yöntemidir.
|
||||
auth.totp.use: TOTP kullan
|
||||
auth.totp.regenerate-recovery-codes: Kurtarma kodlarını yeniden oluştur
|
||||
auth.totp.already-enabled: TOTP zaten etkinleştirilmiş
|
||||
auth.totp.invalid-secret: Geçersiz TOTP gizli anahtarı
|
||||
auth.totp.invalid-code: Geçersiz TOTP kodu
|
||||
auth.totp.code-used: '%s kurtarma kodu kullanıldı, artık geçersiz. Şu anda çok faktörlü kimlik doğrulamayı devre dışı bırakmak veya kodlarınızı yeniden oluşturmak isteyebilirsiniz.'
|
||||
flash.auth.passkey-registred: '%s geçiş anahtarı kaydedildi'
|
||||
gist.new.topics: Konular (boşluklarla ayır)
|
||||
gist.list.topic-results-topic: Tüm %s konusuyla eşleşen gist'ler
|
||||
gist.list.topic-results: Konuyla eşleşen tüm gist'ler
|
||||
gist.search.placeholder.title: Başlık
|
||||
gist.search.placeholder.visibility: Görünürlük
|
||||
gist.search.placeholder.public: Halka açık
|
||||
gist.search.placeholder.private: Özel
|
||||
gist.search.placeholder.language: Lisan
|
||||
gist.search.placeholder.all: Tümü
|
||||
gist.search.placeholder.topics: Başlıklar
|
||||
gist.search.placeholder.search: Ara
|
||||
gist.delete.confirm: Bu Gist'i silmek istediğinizden emin misiniz?
|
||||
flash.auth.passkey-deleted: Geçiş anahtarı silindi
|
||||
settings.header.mfa: ÇFKD
|
||||
settings.header.ssh: SSH
|
||||
settings.style.gist-code: Gist kodu
|
||||
settings.style.soft-wrap: Yumuşak Satır Kaydırma
|
||||
settings.style.removed-lines-color: Silinen satırların rengi
|
||||
settings.style.added-lines-color: Eklenen satırların rengi
|
||||
settings.style.git-lines-color: Git satırların rengi
|
||||
settings.style.save-style: Stili kaydet
|
||||
auth.mfa: Çok Faktörlü Kimlik Doğrulama
|
||||
auth.mfa.passkeys: Parola Anahtarları
|
||||
auth.mfa.use-passkey: Parola Anahtarı kullan
|
||||
auth.mfa.bind-passkey: Parola Anahtarı bağla
|
||||
auth.mfa.login-with-passkey: Parola Anahtarı ile Giriş yap
|
||||
auth.mfa.use-passkey-to-finish: Kimlik doğrulamayı tamamlamak için bir geçiş anahtarı kullanın
|
||||
auth.mfa.passkey-name: İsim
|
||||
auth.mfa.delete-passkey: Sil
|
||||
auth.mfa.passkey-added-at: Eklendi
|
||||
auth.mfa.passkey-never-used: Hiç kullanılmadı
|
||||
auth.mfa.passkey-last-used: Son kullanım
|
||||
auth.mfa.delete-passkey-confirm: Geçiş Anahtarının silinmesini onaylayın
|
||||
auth.totp.disabled: TOTP başarıyla devre dışı bırakıldı
|
||||
auth.totp.disable: TOTP devre dışı bırak
|
||||
auth.totp.enter-code: Kimlik Doğrulayıcı uygulamasındaki kodu girin
|
||||
auth.totp.code: Kod
|
||||
auth.totp.submit: Kaydet
|
||||
auth.totp.proceed: Onayla
|
||||
auth.totp.scan-qr-code: İki faktörlü kimlik doğrulamayı etkinleştirmek için aşağıdaki QR kodunu kimlik doğrulayıcı uygulamanızla tarayın veya aşağıdaki metni girin, ardından oluşturulan kodla onaylayın.
|
||||
admin.actions.sync-gist-languages: Tüm gist dillerini senkronize et
|
||||
|
||||
44
internal/render/csv.go
Normal file
44
internal/render/csv.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
)
|
||||
|
||||
type CSVFile struct {
|
||||
*git.File
|
||||
Type string `json:"type"`
|
||||
Header []string `json:"-"`
|
||||
Rows [][]string `json:"-"`
|
||||
}
|
||||
|
||||
func (r CSVFile) InternalType() string {
|
||||
return "CSVFile"
|
||||
}
|
||||
|
||||
func renderCsvFile(file *git.File) (*CSVFile, error) {
|
||||
reader := csv.NewReader(strings.NewReader(file.Content))
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
header := records[0]
|
||||
numColumns := len(header)
|
||||
|
||||
for i := 1; i < len(records); i++ {
|
||||
if len(records[i]) != numColumns {
|
||||
return nil, fmt.Errorf("CSV file has invalid row at index %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
return &CSVFile{
|
||||
File: file,
|
||||
Type: "CSV",
|
||||
Header: header,
|
||||
Rows: records[1:],
|
||||
}, nil
|
||||
}
|
||||
@@ -5,47 +5,44 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"path"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type RenderedFile struct {
|
||||
type HighlightedFile struct {
|
||||
*git.File
|
||||
Type string `json:"type"`
|
||||
Lines []string `json:"-"`
|
||||
HTML string `json:"-"`
|
||||
}
|
||||
|
||||
func (r HighlightedFile) InternalType() string {
|
||||
return "HighlightedFile"
|
||||
}
|
||||
|
||||
type RenderedGist struct {
|
||||
*db.Gist
|
||||
Lines []string
|
||||
HTML string
|
||||
}
|
||||
|
||||
func HighlightFile(file *git.File) (RenderedFile, error) {
|
||||
func highlightFile(file *git.File) (HighlightedFile, error) {
|
||||
rendered := HighlightedFile{
|
||||
File: file,
|
||||
}
|
||||
if !file.MimeType.IsText() {
|
||||
return rendered, nil
|
||||
}
|
||||
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
|
||||
@@ -74,38 +71,6 @@ func HighlightFile(file *git.File) (RenderedFile, error) {
|
||||
return rendered, err
|
||||
}
|
||||
|
||||
func HighlightFiles(files []*git.File) []RenderedFile {
|
||||
const numWorkers = 10
|
||||
jobs := make(chan int, numWorkers)
|
||||
renderedFiles := make([]RenderedFile, len(files))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
worker := func() {
|
||||
for idx := range jobs {
|
||||
rendered, err := HighlightFile(files[idx])
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error rendering gist preview for " + files[idx].Filename)
|
||||
}
|
||||
renderedFiles[idx] = rendered
|
||||
}
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
wg.Add(1)
|
||||
go worker()
|
||||
}
|
||||
|
||||
for i := range files {
|
||||
jobs <- i
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return renderedFiles
|
||||
}
|
||||
|
||||
func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
|
||||
rendered := RenderedGist{
|
||||
Gist: gist,
|
||||
@@ -146,18 +111,12 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
|
||||
return rendered, err
|
||||
}
|
||||
|
||||
func RenderSvgFile(file *git.File) RenderedFile {
|
||||
rendered := RenderedFile{
|
||||
func renderSvgFile(file *git.File) HighlightedFile {
|
||||
return HighlightedFile{
|
||||
File: file,
|
||||
HTML: `<img src="data:image/svg+xml;base64,` + base64.StdEncoding.EncodeToString([]byte(file.Content)) + `" />`,
|
||||
Type: "SVG",
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,8 @@ package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
|
||||
"github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
@@ -18,17 +20,19 @@ func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
|
||||
var buf bytes.Buffer
|
||||
err := newMarkdown().Convert([]byte(gist.Preview), &buf)
|
||||
|
||||
// remove links in Markdown Preview, quick fix for now
|
||||
re := regexp.MustCompile(`<a\b[^>]*>(.*?)</a>`)
|
||||
return RenderedGist{
|
||||
Gist: gist,
|
||||
HTML: buf.String(),
|
||||
HTML: re.ReplaceAllString(buf.String(), `$1`),
|
||||
}, err
|
||||
}
|
||||
|
||||
func MarkdownFile(file *git.File) (RenderedFile, error) {
|
||||
func renderMarkdownFile(file *git.File) (HighlightedFile, error) {
|
||||
var buf bytes.Buffer
|
||||
err := newMarkdownWithSvgExtension().Convert([]byte(file.Content), &buf)
|
||||
|
||||
return RenderedFile{
|
||||
return HighlightedFile{
|
||||
File: file,
|
||||
HTML: buf.String(),
|
||||
Type: "Markdown",
|
||||
|
||||
88
internal/render/render.go
Normal file
88
internal/render/render.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
)
|
||||
|
||||
type RenderedFile interface {
|
||||
InternalType() string
|
||||
}
|
||||
|
||||
type NonHighlightedFile struct {
|
||||
*git.File
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (r NonHighlightedFile) InternalType() string {
|
||||
return "NonHighlightedFile"
|
||||
}
|
||||
|
||||
func RenderFiles(files []*git.File) []RenderedFile {
|
||||
const numWorkers = 10
|
||||
jobs := make(chan int, numWorkers)
|
||||
renderedFiles := make([]RenderedFile, len(files))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
worker := func() {
|
||||
for idx := range jobs {
|
||||
renderedFiles[idx] = processFile(files[idx])
|
||||
}
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
wg.Add(1)
|
||||
go worker()
|
||||
}
|
||||
|
||||
for i := range files {
|
||||
jobs <- i
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return renderedFiles
|
||||
}
|
||||
|
||||
func processFile(file *git.File) RenderedFile {
|
||||
mt := file.MimeType
|
||||
if mt.IsCSV() {
|
||||
rendered, err := renderCsvFile(file)
|
||||
if err != nil {
|
||||
rendered, err := highlightFile(file)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error rendering gist preview for " + file.Filename)
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
return rendered
|
||||
} else if mt.IsText() && filepath.Ext(file.Filename) == ".md" {
|
||||
rendered, err := renderMarkdownFile(file)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error rendering markdown file for " + file.Filename)
|
||||
}
|
||||
return rendered
|
||||
} else if mt.IsSVG() {
|
||||
rendered := renderSvgFile(file)
|
||||
return rendered
|
||||
} else if mt.CanBeEmbedded() {
|
||||
rendered := NonHighlightedFile{File: file, Type: mt.RenderType()}
|
||||
file.Content = ""
|
||||
return rendered
|
||||
} else if mt.CanBeRendered() {
|
||||
rendered, err := highlightFile(file)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error rendering gist preview for " + file.Filename)
|
||||
}
|
||||
return rendered
|
||||
} else {
|
||||
rendered := NonHighlightedFile{File: file, Type: mt.RenderType()}
|
||||
file.Content = ""
|
||||
return rendered
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,16 @@ package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/i18n"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type dataKey string
|
||||
@@ -57,7 +58,7 @@ func (ctx *Context) DataMap() echo.Map {
|
||||
}
|
||||
|
||||
func (ctx *Context) ErrorRes(code int, message string, err error) error {
|
||||
if code >= 500 {
|
||||
if code >= 500 && err != nil {
|
||||
var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
|
||||
skipLogger.Error().Err(err).Msg(message)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/auth"
|
||||
passwordpkg "github.com/thomiceli/opengist/internal/auth/password"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/i18n"
|
||||
@@ -114,6 +116,7 @@ func ProcessLogin(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(403, ctx.Tr("error.login-disabled-form"), nil)
|
||||
}
|
||||
|
||||
var user *db.User
|
||||
var err error
|
||||
sess := ctx.GetSession()
|
||||
|
||||
@@ -121,26 +124,16 @@ func ProcessLogin(ctx *context.Context) error {
|
||||
if err = ctx.Bind(dto); err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
|
||||
}
|
||||
password := dto.Password
|
||||
|
||||
var user *db.User
|
||||
|
||||
if user, err = db.GetUserByUsername(dto.Username); err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ctx.ErrorRes(500, "Cannot get user", err)
|
||||
user, err = auth.TryAuthentication(dto.Username, dto.Password)
|
||||
if err != nil {
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
|
||||
return ctx.RedirectTo("/login")
|
||||
}
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
|
||||
return ctx.RedirectTo("/login")
|
||||
}
|
||||
|
||||
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot check for password", err)
|
||||
}
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
|
||||
return ctx.RedirectTo("/login")
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
|
||||
// handle MFA
|
||||
|
||||
@@ -14,7 +14,7 @@ func BeginTotp(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
|
||||
} else if hasTotp {
|
||||
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/mfa")
|
||||
}
|
||||
|
||||
ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string))
|
||||
@@ -47,7 +47,7 @@ func FinishTotp(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
|
||||
} else if hasTotp {
|
||||
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/mfa")
|
||||
}
|
||||
|
||||
dto := &db.TOTPDTO{}
|
||||
@@ -134,7 +134,7 @@ func AssertTotp(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning")
|
||||
redirectUrl = "/settings"
|
||||
redirectUrl = "/settings/mfa"
|
||||
}
|
||||
|
||||
sess.Values["user"] = userId
|
||||
@@ -157,7 +157,7 @@ func DisableTotp(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/mfa")
|
||||
}
|
||||
|
||||
func RegenerateTotpRecoveryCodes(ctx *context.Context) error {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package gist
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"github.com/thomiceli/opengist/internal/i18n"
|
||||
"github.com/thomiceli/opengist/internal/validator"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Create(ctx *context.Context) error {
|
||||
@@ -43,25 +48,78 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
|
||||
dto.Files = make([]db.FileDTO, 0)
|
||||
fileCounter := 0
|
||||
for i := 0; i < len(ctx.Request().PostForm["content"]); i++ {
|
||||
name := ctx.Request().PostForm["name"][i]
|
||||
content := ctx.Request().PostForm["content"][i]
|
||||
|
||||
names := ctx.Request().PostForm["name"]
|
||||
contents := ctx.Request().PostForm["content"]
|
||||
|
||||
// Process files from text editors
|
||||
for i, content := range contents {
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
name := names[i]
|
||||
if name == "" {
|
||||
fileCounter += 1
|
||||
name = "gistfile" + strconv.Itoa(fileCounter) + ".txt"
|
||||
}
|
||||
|
||||
escapedValue, err := url.QueryUnescape(content)
|
||||
escapedValue, err := url.PathUnescape(content)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.invalid-character-unescaped"), err)
|
||||
}
|
||||
|
||||
dto.Files = append(dto.Files, db.FileDTO{
|
||||
Filename: strings.Trim(name, " "),
|
||||
Filename: strings.TrimSpace(name),
|
||||
Content: escapedValue,
|
||||
})
|
||||
}
|
||||
|
||||
// Process uploaded files from UUID arrays
|
||||
fileUUIDs := ctx.Request().PostForm["uploadedfile_uuid"]
|
||||
fileFilenames := ctx.Request().PostForm["uploadedfile_filename"]
|
||||
if len(fileUUIDs) == len(fileFilenames) {
|
||||
for i, fileUUID := range fileUUIDs {
|
||||
filePath := filepath.Join(filepath.Join(config.GetHomeDir(), "uploads"), fileUUID)
|
||||
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dto.Files = append(dto.Files, db.FileDTO{
|
||||
Filename: fileFilenames[i],
|
||||
SourcePath: filePath,
|
||||
Content: "", // Empty since we're using SourcePath
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Process binary file operations (edit mode)
|
||||
binaryOldNames := ctx.Request().PostForm["binary_old_name"]
|
||||
binaryNewNames := ctx.Request().PostForm["binary_new_name"]
|
||||
if len(binaryOldNames) == len(binaryNewNames) {
|
||||
for i, oldName := range binaryOldNames {
|
||||
newName := binaryNewNames[i]
|
||||
|
||||
if newName == "" { // deletion
|
||||
continue
|
||||
}
|
||||
|
||||
if !isCreate {
|
||||
gistOld := ctx.GetData("gist").(*db.Gist)
|
||||
|
||||
fileContent, _, err := git.GetFileContent(gistOld.User.Username, gistOld.Uuid, "HEAD", oldName, false)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dto.Files = append(dto.Files, db.FileDTO{
|
||||
Filename: newName,
|
||||
Content: fileContent,
|
||||
Binary: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.SetData("dto", dto)
|
||||
|
||||
err = ctx.Validate(dto)
|
||||
@@ -100,24 +158,13 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
if gist.Title == "" {
|
||||
if ctx.Request().PostForm["name"][0] == "" {
|
||||
if dto.Files[0].Filename == "" {
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
} else {
|
||||
gist.Title = ctx.Request().PostForm["name"][0]
|
||||
gist.Title = dto.Files[0].Filename
|
||||
}
|
||||
}
|
||||
|
||||
if len(dto.Files) > 0 {
|
||||
split := strings.Split(dto.Files[0].Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = dto.Files[0].Content
|
||||
}
|
||||
|
||||
gist.PreviewFilename = dto.Files[0].Filename
|
||||
}
|
||||
|
||||
if err = gist.InitRepository(); err != nil {
|
||||
return ctx.ErrorRes(500, "Error creating the repository", err)
|
||||
}
|
||||
@@ -138,6 +185,9 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
|
||||
gist.AddInIndex()
|
||||
gist.UpdateLanguages()
|
||||
if err = gist.UpdatePreviewAndCount(true); err != nil {
|
||||
return ctx.ErrorRes(500, "Error updating preview and count", err)
|
||||
}
|
||||
|
||||
return ctx.RedirectTo("/" + user.Username + "/" + gist.Identifier())
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
)
|
||||
|
||||
func RawFile(ctx *context.Context) error {
|
||||
@@ -20,10 +19,8 @@ func RawFile(ctx *context.Context) error {
|
||||
if file == nil {
|
||||
return ctx.NotFound("File not found")
|
||||
}
|
||||
contentType := handlers.GetContentTypeFromFilename(file.Filename)
|
||||
ContentDisposition := handlers.GetContentDisposition(file.Filename)
|
||||
ctx.Response().Header().Set("Content-Type", contentType)
|
||||
ctx.Response().Header().Set("Content-Disposition", ContentDisposition)
|
||||
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
|
||||
ctx.Response().Header().Set("Content-Disposition", "inline; filename=\""+file.Filename+"\"")
|
||||
return ctx.PlainText(200, file.Content)
|
||||
}
|
||||
|
||||
@@ -38,7 +35,7 @@ func DownloadFile(ctx *context.Context) error {
|
||||
return ctx.NotFound("File not found")
|
||||
}
|
||||
|
||||
ctx.Response().Header().Set("Content-Type", "text/plain")
|
||||
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
|
||||
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+file.Filename)
|
||||
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
|
||||
_, err = ctx.Response().Write([]byte(file.Content))
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"bytes"
|
||||
gojson "encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"github.com/thomiceli/opengist/internal/render"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GistIndex(ctx *context.Context) error {
|
||||
@@ -34,7 +35,7 @@ func GistIndex(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Error fetching files", err)
|
||||
}
|
||||
|
||||
renderedFiles := render.HighlightFiles(files)
|
||||
renderedFiles := render.RenderFiles(files)
|
||||
|
||||
ctx.SetData("page", "code")
|
||||
ctx.SetData("commit", revision)
|
||||
@@ -51,7 +52,7 @@ func GistJson(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Error fetching files", err)
|
||||
}
|
||||
|
||||
renderedFiles := render.HighlightFiles(files)
|
||||
renderedFiles := render.RenderFiles(files)
|
||||
ctx.SetData("files", renderedFiles)
|
||||
|
||||
topics, err := gist.GetTopics()
|
||||
@@ -106,7 +107,7 @@ func GistJs(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Error fetching files", err)
|
||||
}
|
||||
|
||||
renderedFiles := render.HighlightFiles(files)
|
||||
renderedFiles := render.RenderFiles(files)
|
||||
ctx.SetData("files", renderedFiles)
|
||||
|
||||
htmlbuf := bytes.Buffer{}
|
||||
|
||||
77
internal/web/handlers/gist/upload.go
Normal file
77
internal/web/handlers/gist/upload.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package gist
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
func Upload(ctx *context.Context) error {
|
||||
err := ctx.Request().ParseMultipartForm(32 << 20) // 32 MB max
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), err)
|
||||
}
|
||||
|
||||
fileHeader, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.no-file-uploaded"), err)
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.cannot-open-file"), err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileUUID, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error generating UUID", err)
|
||||
}
|
||||
|
||||
uploadsDir := filepath.Join(config.GetHomeDir(), "uploads")
|
||||
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
|
||||
return ctx.ErrorRes(500, "Error creating uploads directory", err)
|
||||
}
|
||||
|
||||
filename := fileUUID.String()
|
||||
filePath := filepath.Join(uploadsDir, filename)
|
||||
|
||||
destFile, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error creating file", err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, file); err != nil {
|
||||
return ctx.ErrorRes(500, "Error saving file", err)
|
||||
}
|
||||
|
||||
return ctx.JSON(200, map[string]string{
|
||||
"uuid": filename,
|
||||
"filename": fileHeader.Filename,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteUpload(ctx *context.Context) error {
|
||||
uuid := ctx.Param("uuid")
|
||||
if uuid == "" {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), nil)
|
||||
}
|
||||
|
||||
uploadsDir := filepath.Join(config.GetHomeDir(), "uploads")
|
||||
filePath := filepath.Join(uploadsDir, uuid)
|
||||
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return ctx.ErrorRes(500, "Error deleting file", err)
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.JSON(200, map[string]string{
|
||||
"status": "deleted",
|
||||
})
|
||||
}
|
||||
@@ -6,9 +6,6 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/thomiceli/opengist/internal/auth/password"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -23,7 +20,8 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/auth"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"gorm.io/gorm"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
)
|
||||
|
||||
var routes = []struct {
|
||||
@@ -45,138 +43,211 @@ var routes = []struct {
|
||||
}
|
||||
|
||||
func GitHttp(ctx *context.Context) error {
|
||||
route := findMatchingRoute(ctx)
|
||||
if route == nil {
|
||||
return ctx.NotFound("Gist not found") // regular 404 for non-git routes
|
||||
}
|
||||
gist := ctx.GetData("gist").(*db.Gist)
|
||||
gistExists := gist.ID != 0
|
||||
|
||||
isInitRoute := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs")
|
||||
isInitRouteReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack")
|
||||
isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$")
|
||||
isPull := ctx.QueryParam("service") == "git-upload-pack" ||
|
||||
strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") && !isInfoRefs
|
||||
isPush := ctx.QueryParam("service") == "git-receive-pack" ||
|
||||
strings.HasSuffix(ctx.Request().URL.Path, "git-receive-pack") && !isInfoRefs
|
||||
|
||||
repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid)
|
||||
ctx.SetData("repositoryPath", repositoryPath)
|
||||
|
||||
allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, true)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Cannot check if unauthenticated access is allowed")
|
||||
}
|
||||
|
||||
// No need to authenticate if the user wants
|
||||
// to clone/pull ; a non-private gist ; that exists ; where unauthenticated access is allowed in the instance
|
||||
if isPull && gist.Private != db.PrivateVisibility && gistExists && allow {
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
// Else we need to authenticate the user, that include other cases:
|
||||
// - user wants to push the gist
|
||||
// - user wants to clone/pull a private gist
|
||||
// - user wants to clone/pull a non-private gist but unauthenticated access is not allowed
|
||||
// - gist is not found ; has no right to clone/pull (obfuscation)
|
||||
// - admin setting to require login is set to true
|
||||
authUsername, authPassword, err := parseAuthHeader(ctx)
|
||||
if err != nil {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
// if the user wants to create a gist via the /init route
|
||||
if isInitRoute || isInitRouteReceive {
|
||||
var user *db.User
|
||||
|
||||
// check if the user has a valid account on opengist to push a gist
|
||||
user, err = auth.TryAuthentication(authUsername, authPassword)
|
||||
if err != nil {
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.PlainText(401, "Invalid credentials")
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
|
||||
if isInitRoute {
|
||||
gist, err = createGist(user, "")
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot create gist", err)
|
||||
}
|
||||
|
||||
err = db.AddInitGistToQueue(gist.ID, user.ID)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot add inited gist to the queue", err)
|
||||
}
|
||||
ctx.SetData("gist", gist)
|
||||
return route.handler(ctx)
|
||||
} else {
|
||||
gist, err = db.GetInitGistInQueueForUser(user.ID)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot retrieve inited gist from the queue", err)
|
||||
}
|
||||
|
||||
ctx.SetData("gist", gist)
|
||||
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
|
||||
return route.handler(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// if clone/pull
|
||||
// check if the gist exists and if the credentials are valid
|
||||
if isPull {
|
||||
log.Debug().Msg("Detected git pull operation")
|
||||
if !gistExists {
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Pulling unknown gist")
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
|
||||
var userToCheckPermissions string
|
||||
// if the user is trying to clone/pull a non-private gist while unauthenticated access is not allowed,
|
||||
// check if the user has a valid account
|
||||
if gist.Private != db.PrivateVisibility {
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Pulling non-private gist with authenticated access")
|
||||
userToCheckPermissions = authUsername
|
||||
} else { // else just check the password against the gist owner
|
||||
log.Debug().Str("authUsername", authUsername).Str("gistOwner", gist.User.Username).Msg("Pulling private gist")
|
||||
userToCheckPermissions = gist.User.Username
|
||||
}
|
||||
|
||||
if _, err = auth.TryAuthentication(userToCheckPermissions, authPassword); err != nil {
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Pulling gist")
|
||||
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
if isPush {
|
||||
log.Debug().Msg("Detected git push operation")
|
||||
// if gist exists, check if the credentials are valid and if the user is the gist owner
|
||||
if gistExists {
|
||||
log.Debug().Str("authUsername", authUsername).Str("gistOwner", gist.User.Username).Msg("Pushing to existing gist")
|
||||
if _, err = auth.TryAuthentication(gist.User.Username, authPassword); err != nil {
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Pushing gist")
|
||||
|
||||
return route.handler(ctx)
|
||||
} else { // if the gist does not exist, check if the user has a valid account on opengist to push a gist and create it
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Creating new gist by pushing")
|
||||
var user *db.User
|
||||
if user, err = auth.TryAuthentication(authUsername, authPassword); err != nil {
|
||||
var authErr auth.AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
|
||||
urlPath := ctx.Request().URL.Path
|
||||
pathParts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
if pathParts[0] == authUsername && len(pathParts) == 4 {
|
||||
log.Debug().Str("authUsername", authUsername).Msg("Valid URL format for push operation")
|
||||
gist, err = createGist(user, pathParts[1])
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot create gist", err)
|
||||
}
|
||||
log.Debug().Str("authUsername", authUsername).Str("url", urlPath).Msg("Gist created")
|
||||
ctx.SetData("gist", gist)
|
||||
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
|
||||
} else {
|
||||
log.Debug().Str("authUsername", authUsername).Any("path", pathParts).Msg("Invalid URL format for push operation")
|
||||
return ctx.PlainText(401, "Invalid URL format for push operation")
|
||||
}
|
||||
return route.handler(ctx)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
func findMatchingRoute(ctx *context.Context) *struct {
|
||||
gitUrl string
|
||||
method string
|
||||
handler func(ctx *context.Context) error
|
||||
} {
|
||||
for _, route := range routes {
|
||||
matched, _ := regexp.MatchString(route.gitUrl, ctx.Request().URL.Path)
|
||||
if ctx.Request().Method == route.method && matched {
|
||||
if !strings.HasPrefix(ctx.Request().Header.Get("User-Agent"), "git/") {
|
||||
continue
|
||||
}
|
||||
|
||||
gist := ctx.GetData("gist").(*db.Gist)
|
||||
|
||||
isInit := strings.HasPrefix(ctx.Request().URL.Path, "/init/info/refs")
|
||||
isInitReceive := strings.HasPrefix(ctx.Request().URL.Path, "/init/git-receive-pack")
|
||||
isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$")
|
||||
isPull := ctx.QueryParam("service") == "git-upload-pack" ||
|
||||
strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") ||
|
||||
ctx.Request().Method == "GET" && !isInfoRefs
|
||||
|
||||
repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid)
|
||||
if _, err := os.Stat(repositoryPath); os.IsNotExist(err) {
|
||||
if err != nil {
|
||||
log.Info().Err(err).Msg("Repository directory does not exist")
|
||||
return ctx.ErrorRes(404, "Repository directory does not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetData("repositoryPath", repositoryPath)
|
||||
|
||||
allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, true)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Cannot check if unauthenticated access is allowed")
|
||||
}
|
||||
|
||||
// Shows basic auth if :
|
||||
// - user wants to push the gist
|
||||
// - user wants to clone/pull a private gist
|
||||
// - gist is not found (obfuscation)
|
||||
// - admin setting to require login is set to true
|
||||
if isPull && gist.Private != db.PrivateVisibility && gist.ID != 0 && allow {
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
authHeader := ctx.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
authFields := strings.Fields(authHeader)
|
||||
if len(authFields) != 2 || authFields[0] != "Basic" {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
authUsername, authPassword, err := basicAuthDecode(authFields[1])
|
||||
if err != nil {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
if !isInit && !isInitReceive {
|
||||
if gist.ID == 0 {
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
|
||||
var userToCheckPermissions *db.User
|
||||
if gist.Private != db.PrivateVisibility && isPull {
|
||||
userToCheckPermissions, _ = db.GetUserByUsername(authUsername)
|
||||
} else {
|
||||
userToCheckPermissions = &gist.User
|
||||
}
|
||||
|
||||
if ok, err := password.VerifyPassword(authPassword, userToCheckPermissions.Password); !ok {
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot verify password", err)
|
||||
}
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.PlainText(404, "Check your credentials or make sure you have access to the Gist")
|
||||
}
|
||||
} else {
|
||||
var user *db.User
|
||||
if user, err = db.GetUserByUsername(authUsername); err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ctx.ErrorRes(500, "Cannot get user", err)
|
||||
}
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.ErrorRes(401, "Invalid credentials", nil)
|
||||
}
|
||||
|
||||
if ok, err := password.VerifyPassword(authPassword, user.Password); !ok {
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot check for password", err)
|
||||
}
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
return ctx.ErrorRes(401, "Invalid credentials", nil)
|
||||
}
|
||||
|
||||
if isInit {
|
||||
gist = new(db.Gist)
|
||||
gist.UserID = user.ID
|
||||
gist.User = *user
|
||||
uuidGist, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error creating an UUID", err)
|
||||
}
|
||||
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
|
||||
if err = gist.InitRepository(); err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot init repository in the file system", err)
|
||||
}
|
||||
|
||||
if err = gist.Create(); err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot init repository in database", err)
|
||||
}
|
||||
|
||||
err = gist.SerialiseInitRepository()
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot serialise the repository", err)
|
||||
}
|
||||
|
||||
ctx.SetData("gist", gist)
|
||||
} else {
|
||||
gist, err = db.DeserialiseInitRepository(user.Username)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot deserialise the repository", err)
|
||||
}
|
||||
|
||||
ctx.SetData("gist", gist)
|
||||
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
|
||||
}
|
||||
}
|
||||
|
||||
return route.handler(ctx)
|
||||
return &route
|
||||
}
|
||||
}
|
||||
return ctx.NotFound("Gist not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func createGist(user *db.User, url string) (*db.Gist, error) {
|
||||
gist := new(db.Gist)
|
||||
gist.UserID = user.ID
|
||||
gist.User = *user
|
||||
uuidGist, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
|
||||
if url != "" {
|
||||
gist.URL = strings.TrimSuffix(url, ".git")
|
||||
gist.Title = strings.TrimSuffix(url, ".git")
|
||||
}
|
||||
|
||||
if err := gist.InitRepository(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := gist.Create(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gist, nil
|
||||
}
|
||||
|
||||
func uploadPack(ctx *context.Context) error {
|
||||
@@ -302,6 +373,26 @@ func basicAuth(ctx *context.Context) error {
|
||||
return ctx.PlainText(401, "Requires authentication")
|
||||
}
|
||||
|
||||
func parseAuthHeader(ctx *context.Context) (string, string, error) {
|
||||
authHeader := ctx.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", "", errors.New("no auth header")
|
||||
}
|
||||
|
||||
authFields := strings.Fields(authHeader)
|
||||
if len(authFields) != 2 || authFields[0] != "Basic" {
|
||||
return "", "", errors.New("invalid auth header")
|
||||
}
|
||||
|
||||
authUsername, authPassword, err := basicAuthDecode(authFields[1])
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Cannot decode basic auth header")
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return authUsername, authPassword, nil
|
||||
}
|
||||
|
||||
func basicAuthDecode(encoded string) (string, string, error) {
|
||||
s, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,13 +5,19 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
func UserSettings(ctx *context.Context) error {
|
||||
func UserAccount(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
|
||||
keys, err := db.GetSSHKeysByUserID(user.ID)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot get SSH keys", err)
|
||||
}
|
||||
ctx.SetData("email", user.Email)
|
||||
ctx.SetData("hasPassword", user.Password != "")
|
||||
ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
|
||||
ctx.SetData("settingsHeaderPage", "account")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("settings"))
|
||||
return ctx.Html("settings_account.html")
|
||||
}
|
||||
|
||||
func UserMFA(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
|
||||
passkeys, err := db.GetAllCredentialsForUser(user.ID)
|
||||
if err != nil {
|
||||
@@ -23,12 +29,48 @@ func UserSettings(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Cannot get MFA status", err)
|
||||
}
|
||||
|
||||
ctx.SetData("email", user.Email)
|
||||
ctx.SetData("sshKeys", keys)
|
||||
ctx.SetData("passkeys", passkeys)
|
||||
ctx.SetData("hasTotp", hasTotp)
|
||||
ctx.SetData("hasPassword", user.Password != "")
|
||||
ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
|
||||
ctx.SetData("settingsHeaderPage", "mfa")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("settings"))
|
||||
return ctx.Html("settings.html")
|
||||
return ctx.Html("settings_mfa.html")
|
||||
}
|
||||
|
||||
func UserSSHKeys(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
|
||||
keys, err := db.GetSSHKeysByUserID(user.ID)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot get SSH keys", err)
|
||||
}
|
||||
|
||||
ctx.SetData("sshKeys", keys)
|
||||
ctx.SetData("settingsHeaderPage", "ssh")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("settings"))
|
||||
return ctx.Html("settings_ssh.html")
|
||||
}
|
||||
|
||||
func UserStyle(ctx *context.Context) error {
|
||||
ctx.SetData("settingsHeaderPage", "style")
|
||||
ctx.SetData("htmlTitle", ctx.TrH("settings"))
|
||||
return ctx.Html("settings_style.html")
|
||||
}
|
||||
|
||||
func ProcessUserStyle(ctx *context.Context) error {
|
||||
styleDto := new(db.UserStyleDTO)
|
||||
if err := ctx.Bind(styleDto); err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
|
||||
}
|
||||
|
||||
if err := ctx.Validate(styleDto); err != nil {
|
||||
return ctx.ErrorRes(400, "Invalid data", err)
|
||||
}
|
||||
user := ctx.User
|
||||
user.StylePreferences = styleDto.ToJson()
|
||||
if err := user.Update(); err != nil {
|
||||
return ctx.ErrorRes(500, "Cannot update user styles", err)
|
||||
}
|
||||
|
||||
ctx.AddFlash("Updated style", "success")
|
||||
return ctx.RedirectTo("/settings/style")
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func SshKeysProcess(ctx *context.Context) error {
|
||||
|
||||
if err := ctx.Validate(dto); err != nil {
|
||||
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
}
|
||||
key := dto.ToSSHKey()
|
||||
|
||||
@@ -29,7 +29,7 @@ func SshKeysProcess(ctx *context.Context) error {
|
||||
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
|
||||
if err != nil {
|
||||
ctx.AddFlash(ctx.Tr("flash.user.invalid-ssh-key"), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
}
|
||||
key.Content = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey)))
|
||||
|
||||
@@ -38,7 +38,7 @@ func SshKeysProcess(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Cannot check if SSH key exists", err)
|
||||
}
|
||||
ctx.AddFlash(ctx.Tr("settings.ssh-key-exists"), "error")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
}
|
||||
|
||||
if err := key.Create(); err != nil {
|
||||
@@ -46,20 +46,20 @@ func SshKeysProcess(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
}
|
||||
|
||||
func SshKeysDelete(ctx *context.Context) error {
|
||||
user := ctx.User
|
||||
keyId, err := strconv.Atoi(ctx.Param("id"))
|
||||
if err != nil {
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
}
|
||||
|
||||
key, err := db.GetSSHKeyByID(uint(keyId))
|
||||
|
||||
if err != nil || key.UserID != user.ID {
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
}
|
||||
|
||||
if err := key.Delete(); err != nil {
|
||||
@@ -67,5 +67,5 @@ func SshKeysDelete(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success")
|
||||
return ctx.RedirectTo("/settings")
|
||||
return ctx.RedirectTo("/settings/ssh")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -141,22 +140,3 @@ func ParseSearchQueryStr(query string) (string, map[string]string) {
|
||||
content := strings.TrimSpace(contentBuilder.String())
|
||||
return content, metadata
|
||||
}
|
||||
|
||||
func GetContentTypeFromFilename(filename string) (ret string) {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
|
||||
switch ext {
|
||||
case ".css":
|
||||
ret = "text/css"
|
||||
default:
|
||||
ret = "text/plain"
|
||||
}
|
||||
|
||||
// add charset=utf-8, if not, unicode charset will be broken
|
||||
ret += "; charset=utf-8"
|
||||
return
|
||||
}
|
||||
|
||||
func GetContentDisposition(filename string) string {
|
||||
return "inline; filename=\"" + filename + "\""
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ package server
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo-contrib/echoprometheus"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
@@ -15,12 +22,6 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) useCustomContext() {
|
||||
@@ -54,7 +55,7 @@ func (s *Server) registerMiddlewares() {
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
//s.echo.Use(middleware.Recover())
|
||||
s.echo.Use(middleware.Recover())
|
||||
s.echo.Use(middleware.Secure())
|
||||
s.echo.Use(Middleware(sessionInit).toEcho())
|
||||
|
||||
@@ -275,6 +276,7 @@ func sessionInit(next Handler) Handler {
|
||||
if user != nil {
|
||||
ctx.User = user
|
||||
ctx.SetData("userLogged", user)
|
||||
ctx.SetData("currentStyle", user.GetStyle())
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
@@ -4,16 +4,6 @@ import (
|
||||
gojson "encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"github.com/thomiceli/opengist/internal/index"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
"github.com/thomiceli/opengist/public"
|
||||
"github.com/thomiceli/opengist/templates"
|
||||
htmlpkg "html"
|
||||
"html/template"
|
||||
"io"
|
||||
@@ -24,6 +14,17 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/index"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
"github.com/thomiceli/opengist/public"
|
||||
"github.com/thomiceli/opengist/templates"
|
||||
)
|
||||
|
||||
type Template struct {
|
||||
@@ -58,23 +59,8 @@ func (s *Server) setFuncMap() {
|
||||
"isMarkdown": func(i string) bool {
|
||||
return strings.ToLower(filepath.Ext(i)) == ".md"
|
||||
},
|
||||
"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
|
||||
}
|
||||
|
||||
csvFile, err := git.ParseCsv(file)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return csvFile
|
||||
"isJupyter": func(i string) bool {
|
||||
return strings.ToLower(filepath.Ext(i)) == ".ipynb"
|
||||
},
|
||||
"httpStatusText": http.StatusText,
|
||||
"loadedTime": func(startTime time.Time) string {
|
||||
@@ -186,6 +172,20 @@ func (s *Server) setFuncMap() {
|
||||
}
|
||||
return str
|
||||
},
|
||||
"hexToRgb": func(hex string) string {
|
||||
h, _ := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 32)
|
||||
return fmt.Sprintf("%d, %d, %d,", (h>>16)&0xFF, (h>>8)&0xFF, h&0xFF)
|
||||
},
|
||||
"humanTimeDiff": func(t int64) string {
|
||||
return humanize.Time(time.Unix(t, 0))
|
||||
},
|
||||
"humanTimeDiffStr": func(timestamp string) string {
|
||||
t, _ := strconv.ParseInt(timestamp, 10, 64)
|
||||
return humanize.Time(time.Unix(t, 0))
|
||||
},
|
||||
"humanDate": func(t int64) string {
|
||||
return time.Unix(t, 0).Format("02/01/2006 15:04")
|
||||
},
|
||||
}
|
||||
|
||||
t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html"))
|
||||
|
||||
@@ -29,6 +29,8 @@ func (s *Server) registerRoutes() {
|
||||
r.GET("/", gist.Create, logged)
|
||||
r.POST("/", gist.ProcessCreate, logged)
|
||||
r.POST("/preview", gist.Preview, logged)
|
||||
r.POST("/upload", gist.Upload, logged)
|
||||
r.DELETE("/upload/:uuid", gist.DeleteUpload, logged)
|
||||
|
||||
r.GET("/healthcheck", health.Healthcheck)
|
||||
|
||||
@@ -56,7 +58,11 @@ func (s *Server) registerRoutes() {
|
||||
sA := r.SubGroup("/settings")
|
||||
{
|
||||
sA.Use(logged)
|
||||
sA.GET("", settings.UserSettings)
|
||||
sA.GET("", settings.UserAccount)
|
||||
sA.GET("/mfa", settings.UserMFA)
|
||||
sA.GET("/ssh", settings.UserSSHKeys)
|
||||
sA.GET("/style", settings.UserStyle)
|
||||
sA.POST("/style", settings.ProcessUserStyle)
|
||||
sA.POST("/email", settings.EmailProcess)
|
||||
sA.DELETE("/account", settings.AccountDeleteProcess)
|
||||
sA.POST("/ssh-keys", settings.SshKeysProcess)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/thomiceli/opengist/internal/validator"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -45,7 +51,19 @@ func NewServer(isDev bool, sessionsPath string, ignoreCsrf bool) *Server {
|
||||
return s
|
||||
}
|
||||
|
||||
func isSocketPath(host string) bool {
|
||||
return strings.Contains(host, "/") || strings.Contains(host, "\\")
|
||||
}
|
||||
|
||||
func (s *Server) Start() {
|
||||
if isSocketPath(config.C.HttpHost) {
|
||||
s.startUnixSocket()
|
||||
} else {
|
||||
s.startHTTP()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) startHTTP() {
|
||||
addr := config.C.HttpHost + ":" + config.C.HttpPort
|
||||
|
||||
log.Info().Msg("Starting HTTP server on http://" + addr)
|
||||
@@ -54,12 +72,106 @@ func (s *Server) Start() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) startUnixSocket() {
|
||||
socketPath := config.C.HttpHost
|
||||
if socketPath == "" {
|
||||
socketPath = "/tmp/opengist.sock"
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(socketPath); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
log.Warn().Err(err).Str("dir", dir).Msg("Failed to create socket directory")
|
||||
}
|
||||
}
|
||||
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn().Err(err).Str("socket", socketPath).Msg("Failed to remove existing socket file")
|
||||
}
|
||||
|
||||
pidPath := strings.TrimSuffix(socketPath, filepath.Ext(socketPath)) + ".pid"
|
||||
if err := s.createPidFile(pidPath); err != nil {
|
||||
log.Warn().Err(err).Str("pid-file", pidPath).Msg("Failed to create PID file")
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to start Unix socket server")
|
||||
}
|
||||
s.echo.Listener = listener
|
||||
|
||||
if config.C.UnixSocketPermissions != "" {
|
||||
if perm, err := strconv.ParseUint(config.C.UnixSocketPermissions, 8, 32); err == nil {
|
||||
if err := os.Chmod(socketPath, os.FileMode(perm)); err != nil {
|
||||
log.Warn().Err(err).Str("socket", socketPath).Str("permissions", config.C.UnixSocketPermissions).Msg("Failed to set socket permissions")
|
||||
}
|
||||
} else {
|
||||
log.Warn().Err(err).Str("permissions", config.C.UnixSocketPermissions).Msg("Invalid socket permissions format")
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Str("socket", socketPath).Msg("Starting Unix socket server")
|
||||
log.Info().Str("pid-file", pidPath).Msg("PID file created")
|
||||
server := new(http.Server)
|
||||
if err := s.echo.StartServer(server); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal().Err(err).Msg("Failed to start Unix socket server")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
if isSocketPath(config.C.HttpHost) {
|
||||
s.stopUnixSocket()
|
||||
} else {
|
||||
s.stopHTTP()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) stopHTTP() {
|
||||
log.Info().Msg("Stopping HTTP server...")
|
||||
if err := s.echo.Close(); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to stop HTTP server")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) stopUnixSocket() {
|
||||
log.Info().Msg("Stopping Unix socket server...")
|
||||
|
||||
var socketPath string
|
||||
if s.echo.Listener != nil {
|
||||
if unixListener, ok := s.echo.Listener.(*net.UnixListener); ok {
|
||||
socketPath = unixListener.Addr().String()
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.echo.Close(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to stop Unix socket server")
|
||||
}
|
||||
|
||||
if socketPath != "" {
|
||||
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
|
||||
log.Error().Err(err).Str("socket", socketPath).Msg("Failed to remove socket file")
|
||||
} else {
|
||||
log.Info().Str("socket", socketPath).Msg("Socket file removed")
|
||||
}
|
||||
|
||||
pidPath := strings.TrimSuffix(socketPath, filepath.Ext(socketPath)) + ".pid"
|
||||
if err := os.Remove(pidPath); err != nil && !os.IsNotExist(err) {
|
||||
log.Error().Err(err).Str("pid-file", pidPath).Msg("Failed to remove PID file")
|
||||
} else {
|
||||
log.Info().Str("pid-file", pidPath).Msg("PID file removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) createPidFile(pidPath string) error {
|
||||
pid := os.Getpid()
|
||||
pidContent := fmt.Sprintf("%d\n", pid)
|
||||
|
||||
if err := os.WriteFile(pidPath, []byte(pidContent), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.echo.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -203,49 +203,28 @@ func TestGitOperations(t *testing.T) {
|
||||
err = s.Request("POST", "/", gist3, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gitOperations := func(credentials, owner, url, filename string, expectErrorClone, expectErrorCheck, expectErrorPush bool) {
|
||||
log.Debug().Msgf("Testing %s %s %t %t %t", credentials, url, expectErrorClone, expectErrorCheck, expectErrorPush)
|
||||
err := clientGitClone(credentials, owner, url)
|
||||
if expectErrorClone {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
err = clientCheckRepo(url, filename)
|
||||
if expectErrorCheck {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
err = clientGitPush(url)
|
||||
if expectErrorPush {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
credentials string
|
||||
user string
|
||||
url string
|
||||
pushOptions string
|
||||
expectErrorClone bool
|
||||
expectErrorCheck bool
|
||||
expectErrorPush bool
|
||||
}{
|
||||
{":", "kaguya", "kaguya-pub-gist", false, false, true},
|
||||
{":", "kaguya", "kaguya-unl-gist", false, false, true},
|
||||
{":", "kaguya", "kaguya-priv-gist", true, true, true},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", false, false, false},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", false, false, false},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", false, false, false},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", false, false, true},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", false, false, true},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", true, true, true},
|
||||
{":", "kaguya", "kaguya-pub-gist", "", false, false, true},
|
||||
{":", "kaguya", "kaguya-unl-gist", "", false, false, true},
|
||||
{":", "kaguya", "kaguya-priv-gist", "", true, true, true},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
}
|
||||
|
||||
login(t, s, admin)
|
||||
@@ -256,23 +235,24 @@ func TestGitOperations(t *testing.T) {
|
||||
credentials string
|
||||
user string
|
||||
url string
|
||||
pushOptions string
|
||||
expectErrorClone bool
|
||||
expectErrorCheck bool
|
||||
expectErrorPush bool
|
||||
}{
|
||||
{":", "kaguya", "kaguya-pub-gist", true, true, true},
|
||||
{":", "kaguya", "kaguya-unl-gist", true, true, true},
|
||||
{":", "kaguya", "kaguya-priv-gist", true, true, true},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", false, false, false},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", false, false, false},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", false, false, false},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", false, false, true},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", false, false, true},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", true, true, true},
|
||||
{":", "kaguya", "kaguya-pub-gist", "", true, true, true},
|
||||
{":", "kaguya", "kaguya-unl-gist", "", true, true, true},
|
||||
{":", "kaguya", "kaguya-priv-gist", "", true, true, true},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false},
|
||||
{"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true},
|
||||
{"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true},
|
||||
}
|
||||
|
||||
for _, test := range testsRequireLogin {
|
||||
gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
}
|
||||
|
||||
login(t, s, admin)
|
||||
@@ -280,31 +260,155 @@ func TestGitOperations(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitInit(t *testing.T) {
|
||||
s := Setup(t)
|
||||
defer Teardown(t, s)
|
||||
|
||||
admin := db.UserDTO{Username: "thomas", Password: "thomas"}
|
||||
register(t, s, admin)
|
||||
s.sessionCookie = ""
|
||||
register(t, s, db.UserDTO{Username: "fujiwara", Password: "fujiwara"})
|
||||
s.sessionCookie = ""
|
||||
register(t, s, db.UserDTO{Username: "kaguya", Password: "kaguya"})
|
||||
|
||||
testsNewWithPush := []struct {
|
||||
credentials string
|
||||
user string
|
||||
url string
|
||||
pushOptions string
|
||||
expectErrorClone bool
|
||||
expectErrorCheck bool
|
||||
expectErrorPush bool
|
||||
}{
|
||||
{":", "kaguya", "gist1", "", true, true, true},
|
||||
{"kaguya:wrongpass", "kaguya", "gist2", "", true, true, true},
|
||||
{"fujiwara:fujiwara", "kaguya", "gist3", "", true, true, true},
|
||||
{"kaguya:kaguya", "kaguya", "gist4", "", false, false, false},
|
||||
{"kaguya:kaguya", "kaguya", "gist5/g", "", true, true, true},
|
||||
}
|
||||
|
||||
for _, test := range testsNewWithPush {
|
||||
gitInitPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorPush)
|
||||
}
|
||||
|
||||
gist1db, err := db.GetGistByID("1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "kaguya", gist1db.User.Username)
|
||||
|
||||
for _, test := range testsNewWithPush {
|
||||
gitCloneCheckPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
}
|
||||
|
||||
count, err := db.CountAll(db.Gist{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), count)
|
||||
|
||||
testsNewWithInit := []struct {
|
||||
credentials string
|
||||
url string
|
||||
pushOptions string
|
||||
expectErrorPush bool
|
||||
}{
|
||||
{":", "init", "", true},
|
||||
{"fujiwara:wrongpass", "init", "", true},
|
||||
{"kaguya:kaguya", "init", "", false},
|
||||
{"fujiwara:fujiwara", "init", "", false},
|
||||
}
|
||||
|
||||
for _, test := range testsNewWithInit {
|
||||
gitInitPush(t, test.credentials, "kaguya", test.url, "newfile.txt", test.pushOptions, test.expectErrorPush)
|
||||
}
|
||||
|
||||
count, err = db.CountAll(db.Gist{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), count)
|
||||
|
||||
gist2db, err := db.GetGistByID("2")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "kaguya", gist2db.User.Username)
|
||||
|
||||
gist3db, err := db.GetGistByID("3")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "fujiwara", gist3db.User.Username)
|
||||
}
|
||||
|
||||
func clientGitClone(creds string, user string, url string) error {
|
||||
return exec.Command("git", "clone", "http://"+creds+"@localhost:6157/"+user+"/"+url, filepath.Join(config.GetHomeDir(), "tmp", url)).Run()
|
||||
}
|
||||
|
||||
func clientGitPush(url string) error {
|
||||
f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, "newfile.txt"))
|
||||
func clientGitPush(url string, pushOptions string, file string) error {
|
||||
f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, file))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
_, _ = f.WriteString("new file")
|
||||
_ = f.Close()
|
||||
|
||||
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", "newfile.txt").Run()
|
||||
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", file).Run()
|
||||
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "commit", "-m", "new file").Run()
|
||||
err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", "origin", "master").Run()
|
||||
|
||||
if pushOptions != "" {
|
||||
err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", pushOptions, "origin").Run()
|
||||
} else {
|
||||
err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", "origin").Run()
|
||||
}
|
||||
_ = os.RemoveAll(filepath.Join(config.GetHomeDir(), "tmp", url))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func clientGitInit(path string) error {
|
||||
return exec.Command("git", "init", "--initial-branch=master", filepath.Join(config.GetHomeDir(), "tmp", path)).Run()
|
||||
}
|
||||
|
||||
func clientGitSetRemote(path string, remoteName string, remoteUrl string) error {
|
||||
return exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", path), "remote", "add", remoteName, remoteUrl).Run()
|
||||
}
|
||||
|
||||
func clientCheckRepo(url string, file string) error {
|
||||
_, err := os.ReadFile(filepath.Join(config.GetHomeDir(), "tmp", url, file))
|
||||
return err
|
||||
}
|
||||
|
||||
func gitCloneCheckPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorClone, expectErrorCheck, expectErrorPush bool) {
|
||||
log.Debug().Msgf("Testing %s %s %t %t %t", credentials, url, expectErrorClone, expectErrorCheck, expectErrorPush)
|
||||
err := clientGitClone(credentials, owner, url)
|
||||
if expectErrorClone {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
err = clientCheckRepo(url, filename)
|
||||
if expectErrorCheck {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
err = clientGitPush(url, pushOptions, filename)
|
||||
if expectErrorPush {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func gitInitPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorPush bool) {
|
||||
log.Debug().Msgf("Testing %s %s %t", credentials, url, expectErrorPush)
|
||||
err := clientGitInit(url)
|
||||
require.NoError(t, err)
|
||||
if url == "init" {
|
||||
err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/init/")
|
||||
} else {
|
||||
err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/"+owner+"/"+url)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
err = clientGitPush(url, pushOptions, filename)
|
||||
if expectErrorPush {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestGists(t *testing.T) {
|
||||
Content: []string{"", "yeah\ncool", "yeah\ncool gist actually"},
|
||||
Topics: "",
|
||||
}
|
||||
err = s.Request("POST", "/", gist2, 400)
|
||||
err = s.Request("POST", "/", gist2, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist3 := db.GistDTO{
|
||||
@@ -82,7 +82,7 @@ func TestGists(t *testing.T) {
|
||||
err = s.Request("POST", "/", gist3, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist3db, err := db.GetGistByID("2")
|
||||
gist3db, err := db.GetGistByID("3")
|
||||
require.NoError(t, err)
|
||||
|
||||
gist3files, err := git.GetFilesOfRepository(gist3db.User.Username, gist3db.Uuid, "HEAD")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -153,8 +154,16 @@ func Setup(t *testing.T) *TestServer {
|
||||
|
||||
config.C.Index = ""
|
||||
config.C.LogLevel = "error"
|
||||
config.C.GitDefaultBranch = "master"
|
||||
config.InitLog()
|
||||
|
||||
err = exec.Command("git", "config", "--global", "--type", "bool", "push.autoSetupRemote", "true").Run()
|
||||
require.NoError(t, err)
|
||||
err = exec.Command("git", "config", "--global", "user.email", "test@opengist.io").Run()
|
||||
require.NoError(t, err)
|
||||
err = exec.Command("git", "config", "--global", "user.name", "test").Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
homePath := config.GetHomeDir()
|
||||
log.Info().Msg("Data directory: " + homePath)
|
||||
|
||||
|
||||
94
package-lock.json
generated
94
package-lock.json
generated
@@ -17,10 +17,12 @@
|
||||
"autoprefixer": "^10.4.14",
|
||||
"codemirror": "^6.0.1",
|
||||
"cssnano": "^5.1.15",
|
||||
"dayjs": "^1.11.9",
|
||||
"github-markdown-css": "^5.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jdenticon": "^3.3.0",
|
||||
"katex": "^0.16.22",
|
||||
"nodemon": "^2.0.22",
|
||||
"pdfobject": "^2.3.1",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-cssnext": "^3.1.1",
|
||||
@@ -28,6 +30,7 @@
|
||||
"postcss-loader": "^7.1.0",
|
||||
"postcss-selector-namespace": "^3.0.1",
|
||||
"sass": "^1.62.1",
|
||||
"showdown": "^2.1.0",
|
||||
"sugarss": "^4.0.1",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vite": "^4.5.3"
|
||||
@@ -1868,13 +1871,6 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.11",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
|
||||
"integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
@@ -2428,6 +2424,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
|
||||
@@ -2705,6 +2711,33 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.22",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
|
||||
"integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/katex/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
@@ -2797,14 +2830,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
|
||||
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "14 || >=16.14"
|
||||
}
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/math-expression-evaluator": {
|
||||
"version": "1.4.0",
|
||||
@@ -3154,6 +3184,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfobject": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pdfobject/-/pdfobject-2.3.1.tgz",
|
||||
"integrity": "sha512-vluuGiSDmMGpOvWFGiUY4trNB8aGKLDVxIXuuGHjX0kK3bMxCANUVtLivctE7uejLBScWCnbVarKatFVvdwXaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
|
||||
@@ -5325,6 +5362,33 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/showdown": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",
|
||||
"integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^9.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"showdown": "bin/showdown.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://www.paypal.me/tiviesantos"
|
||||
}
|
||||
},
|
||||
"node_modules/showdown/node_modules/commander": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
"autoprefixer": "^10.4.14",
|
||||
"codemirror": "^6.0.1",
|
||||
"cssnano": "^5.1.15",
|
||||
"dayjs": "^1.11.9",
|
||||
"github-markdown-css": "^5.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jdenticon": "^3.3.0",
|
||||
"katex": "^0.16.22",
|
||||
"nodemon": "^2.0.22",
|
||||
"pdfobject": "^2.3.1",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-cssnext": "^3.1.1",
|
||||
@@ -32,6 +34,7 @@
|
||||
"postcss-loader": "^7.1.0",
|
||||
"postcss-selector-namespace": "^3.0.1",
|
||||
"sass": "^1.62.1",
|
||||
"showdown": "^2.1.0",
|
||||
"sugarss": "^4.0.1",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vite": "^4.5.3"
|
||||
|
||||
246
public/editor.ts
246
public/editor.ts
@@ -117,7 +117,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
let deleteBtns = dom.querySelector<HTMLButtonElement>("button.delete-file");
|
||||
if (deleteBtns !== null) {
|
||||
deleteBtns.onclick = () => {
|
||||
editorsjs.splice(editorsjs.indexOf(editor), 1);
|
||||
// For both text and binary files, just remove from DOM
|
||||
if (!dom.hasAttribute('data-binary-original-name')) {
|
||||
// Only remove from editors array for text files
|
||||
editorsjs.splice(editorsjs.indexOf(editor), 1);
|
||||
}
|
||||
dom.remove();
|
||||
checkForFirstDeleteButton();
|
||||
};
|
||||
@@ -196,21 +200,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
let arr = Array.from(allEditorsdom);
|
||||
arr.forEach((el: HTMLElement) => {
|
||||
// in case we edit the gist contents
|
||||
let currEditor = newEditor(el, el.querySelector<HTMLInputElement>(".form-filecontent")!.value);
|
||||
editorsjs.push(currEditor);
|
||||
let formFileContent =el.querySelector<HTMLInputElement>(".form-filecontent")
|
||||
if (formFileContent !== null) {
|
||||
let currEditor = newEditor(el, el.querySelector<HTMLInputElement>(".form-filecontent")!.value);
|
||||
editorsjs.push(currEditor);
|
||||
} else if (el.hasAttribute('data-binary-original-name')) {
|
||||
// For binary files, just set up the delete button
|
||||
let deleteBtn = el.querySelector<HTMLButtonElement>("button.delete-file");
|
||||
if (deleteBtn) {
|
||||
deleteBtn.onclick = () => {
|
||||
el.remove();
|
||||
checkForFirstDeleteButton();
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
checkForFirstDeleteButton();
|
||||
|
||||
document.getElementById("add-file")!.onclick = () => {
|
||||
let newEditorDom = firstEditordom.cloneNode(true) as HTMLElement;
|
||||
|
||||
// reset the filename of the new cloned element
|
||||
newEditorDom.querySelector<HTMLInputElement>('input[name="name"]')!.value = "";
|
||||
|
||||
// removing the previous codemirror editor
|
||||
let newEditorDomCM = newEditorDom.querySelector(".cm-editor");
|
||||
newEditorDomCM!.remove();
|
||||
const template = document.getElementById("editor-template")!;
|
||||
const newEditorDom = template.firstElementChild!.cloneNode(true) as HTMLElement;
|
||||
|
||||
// creating the new codemirror editor and append it in the editor div
|
||||
editorsjs.push(newEditor(newEditorDom));
|
||||
@@ -220,9 +230,56 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.querySelector<HTMLFormElement>("form#create")!.onsubmit = () => {
|
||||
let j = 0;
|
||||
document.querySelectorAll<HTMLInputElement>(".form-filecontent").forEach((e) => {
|
||||
e.value = encodeURIComponent(editorsjs[j++].state.doc.toString());
|
||||
document.querySelectorAll<HTMLInputElement>(".form-filecontent").forEach((el) => {
|
||||
if (j < editorsjs.length) {
|
||||
el.value = encodeURIComponent(editorsjs[j++].state.doc.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const fileInput = document.getElementById("file-upload") as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.remove();
|
||||
}
|
||||
|
||||
const form = document.querySelector<HTMLFormElement>("form#create")!;
|
||||
|
||||
uploadedFileUUIDs.forEach((fileData) => {
|
||||
const uuidInput = document.createElement('input');
|
||||
uuidInput.type = 'hidden';
|
||||
uuidInput.name = 'uploadedfile_uuid';
|
||||
uuidInput.value = fileData.uuid;
|
||||
form.appendChild(uuidInput);
|
||||
|
||||
const filenameInput = document.createElement('input');
|
||||
filenameInput.type = 'hidden';
|
||||
filenameInput.name = 'uploadedfile_filename';
|
||||
filenameInput.value = fileData.filename;
|
||||
form.appendChild(filenameInput);
|
||||
});
|
||||
|
||||
const binaryFiles = document.querySelectorAll('[data-binary-original-name]');
|
||||
binaryFiles.forEach((fileDiv) => {
|
||||
const originalName = fileDiv.getAttribute('data-binary-original-name');
|
||||
const fileNameInput = fileDiv.querySelector('.form-filename') as HTMLInputElement;
|
||||
|
||||
if (fileNameInput) {
|
||||
fileNameInput.removeAttribute('name');
|
||||
}
|
||||
|
||||
const oldNameInput = document.createElement('input');
|
||||
oldNameInput.type = 'hidden';
|
||||
oldNameInput.name = 'binary_old_name';
|
||||
oldNameInput.value = originalName || '';
|
||||
form.appendChild(oldNameInput);
|
||||
|
||||
const newNameInput = document.createElement('input');
|
||||
newNameInput.type = 'hidden';
|
||||
newNameInput.name = 'binary_new_name';
|
||||
newNameInput.value = fileNameInput?.value || '';
|
||||
form.appendChild(newNameInput);
|
||||
});
|
||||
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
|
||||
document.getElementById('gist-metadata-btn')!.onclick = (el) => {
|
||||
@@ -239,16 +296,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
function checkForFirstDeleteButton() {
|
||||
let deleteBtn = editorsParentdom.querySelector<HTMLButtonElement>("button.delete-file")!;
|
||||
if (editorsjs.length === 1) {
|
||||
deleteBtn.classList.add("hidden");
|
||||
deleteBtn.previousElementSibling.classList.remove("rounded-l-md");
|
||||
deleteBtn.previousElementSibling.classList.add("rounded-md");
|
||||
} else {
|
||||
deleteBtn.classList.remove("hidden");
|
||||
deleteBtn.previousElementSibling.classList.add("rounded-l-md");
|
||||
deleteBtn.previousElementSibling.classList.remove("rounded-md");
|
||||
}
|
||||
// Count total files (both text and binary)
|
||||
const totalFiles = editorsParentdom.querySelectorAll('.editor').length;
|
||||
|
||||
// Hide/show all delete buttons based on total file count
|
||||
const deleteButtons = editorsParentdom.querySelectorAll<HTMLButtonElement>("button.delete-file");
|
||||
deleteButtons.forEach(deleteBtn => {
|
||||
if (totalFiles <= 1) {
|
||||
deleteBtn.classList.add("hidden");
|
||||
deleteBtn.previousElementSibling?.classList.remove("rounded-l-md");
|
||||
deleteBtn.previousElementSibling?.classList.add("rounded-md");
|
||||
} else {
|
||||
deleteBtn.classList.remove("hidden");
|
||||
deleteBtn.previousElementSibling?.classList.add("rounded-l-md");
|
||||
deleteBtn.previousElementSibling?.classList.remove("rounded-md");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteButton(editorDom: HTMLElement) {
|
||||
@@ -259,7 +322,140 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
checkForFirstDeleteButton();
|
||||
}
|
||||
|
||||
document.onsubmit = () => {
|
||||
window.onbeforeunload = null;
|
||||
// File upload functionality
|
||||
let uploadedFileUUIDs: {uuid: string, filename: string}[] = [];
|
||||
const fileUploadInput = document.getElementById("file-upload") as HTMLInputElement;
|
||||
const uploadedFilesContainer = document.getElementById("uploaded-files")!;
|
||||
const fileUploadZone = document.getElementById("file-upload-zone")!.querySelector('.border-dashed') as HTMLElement;
|
||||
|
||||
// Handle file selection
|
||||
const handleFiles = (files: FileList) => {
|
||||
Array.from(files).forEach(file => {
|
||||
if (!uploadedFileUUIDs.find(f => f.filename === file.name)) {
|
||||
uploadFile(file);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Upload file to server
|
||||
const uploadFile = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
const csrf = document.querySelector<HTMLInputElement>('form#create input[name="_csrf"]')?.value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
uploadedFileUUIDs.push({uuid: result.uuid, filename: result.filename});
|
||||
addFileToUI(result.filename, result.uuid, file.size);
|
||||
} else {
|
||||
console.error('Upload failed:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add file to UI
|
||||
const addFileToUI = (filename: string, uuid: string, fileSize: number) => {
|
||||
const fileElement = document.createElement('div');
|
||||
fileElement.className = 'flex items-stretch bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden';
|
||||
fileElement.dataset.uuid = uuid;
|
||||
|
||||
fileElement.innerHTML = `
|
||||
<div class="flex items-center space-x-3 px-3 py-1 flex-1">
|
||||
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">${filename}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">${formatFileSize(fileSize)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="remove-file flex items-center justify-center px-4 border-l-1 dark:border-l-1 text-rose-600 dark:text-rose-400 border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 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:outline-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Remove file handler
|
||||
fileElement.querySelector('.remove-file')!.addEventListener('click', async () => {
|
||||
// Remove from server
|
||||
try {
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
const csrf = document.querySelector<HTMLInputElement>('form#create input[name="_csrf"]')?.value;
|
||||
|
||||
await fetch(`${baseUrl}/upload/${uuid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf || ''
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
}
|
||||
|
||||
// Remove from UI and local array
|
||||
uploadedFileUUIDs = uploadedFileUUIDs.filter(f => f.uuid !== uuid);
|
||||
fileElement.remove();
|
||||
});
|
||||
|
||||
uploadedFilesContainer.appendChild(fileElement);
|
||||
};
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// File input change handler
|
||||
fileUploadInput.addEventListener('change', (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) {
|
||||
handleFiles(files);
|
||||
// Clear the input value immediately so it doesn't get submitted with the form
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop handlers
|
||||
fileUploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadZone.classList.add('border-primary-400', 'dark:border-primary-500');
|
||||
});
|
||||
|
||||
fileUploadZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadZone.classList.remove('border-primary-400', 'dark:border-primary-500');
|
||||
});
|
||||
|
||||
fileUploadZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadZone.classList.remove('border-primary-400', 'dark:border-primary-500');
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files) {
|
||||
handleFiles(files);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -4,5 +4,5 @@ package public
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed manifest.json assets/*.js assets/*.css assets/*.svg assets/*.png
|
||||
//go:embed manifest.json assets/*.js assets/*.css assets/*.svg assets/*.png assets/*.ttf assets/*.woff assets/*.woff2
|
||||
var Files embed.FS
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import './ipynb';
|
||||
import PDFObject from 'pdfobject';
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.table-code').forEach((el) => {
|
||||
el.addEventListener('click', event => {
|
||||
if (event.target && (event.target as HTMLElement).matches('.line-num')) {
|
||||
@@ -75,5 +78,6 @@ if (document.getElementById('gist').dataset.own) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
document.querySelectorAll(".pdf").forEach((el) => {
|
||||
PDFObject.embed(el.dataset.src || "", el);
|
||||
})
|
||||
15
public/ipynb.css
vendored
Normal file
15
public/ipynb.css
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
.jupyter.notebook {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.jupyter.notebook pre {
|
||||
font-size: 0.8em !important;
|
||||
}
|
||||
|
||||
.jupyter.notebook .jupyter-cell {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.jupyter.notebook .jupyter-cell.code-cell {
|
||||
filter: drop-shadow(0 0 0.1rem rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
127
public/ipynb.ts
Normal file
127
public/ipynb.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import hljs from 'highlight.js';
|
||||
import latex from './latex';
|
||||
import showdown from 'showdown';
|
||||
|
||||
class IPynb {
|
||||
private element: HTMLElement;
|
||||
private cells: HTMLElement[] = [];
|
||||
private language: string = 'python';
|
||||
private notebook: any;
|
||||
|
||||
constructor(element: HTMLElement) {
|
||||
this.element = element;
|
||||
let notebookContent = element.innerText;
|
||||
|
||||
try {
|
||||
this.notebook = JSON.parse(notebookContent);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse Jupyter notebook content:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.notebook) {
|
||||
console.error('Failed to parse Jupyter notebook content:', notebookContent);
|
||||
return;
|
||||
}
|
||||
|
||||
this.language = this.notebook.metadata.kernelspec?.language || 'python';
|
||||
this.cells = this.createCells();
|
||||
}
|
||||
|
||||
mount() {
|
||||
const parent = this.element.parentElement as HTMLElement;
|
||||
parent.removeChild(this.element);
|
||||
parent.innerHTML = this.cells
|
||||
.filter((cell: HTMLElement) => !!cell?.outerHTML)
|
||||
.map((cell: HTMLElement) => cell.outerHTML)
|
||||
.join('');
|
||||
}
|
||||
|
||||
private getOutputs(cell: any): HTMLElement[] {
|
||||
return (cell.outputs || []).map((output: any) => {
|
||||
const outputElement = document.createElement('div');
|
||||
outputElement.classList.add('jupyter-output');
|
||||
|
||||
if (output.output_type === 'stream') {
|
||||
const textElement = document.createElement('pre');
|
||||
textElement.classList.add('stream-output');
|
||||
textElement.textContent = output.text.join('');
|
||||
outputElement.appendChild(textElement);
|
||||
} else if (output.output_type === 'display_data' || output.output_type === 'execute_result') {
|
||||
if (output.data['text/plain']) {
|
||||
outputElement.innerHTML += `\n<pre>${output.data['text/plain']}</pre>`;
|
||||
}
|
||||
if (output.data['text/html']) {
|
||||
outputElement.innerHTML += '\n' + output.data['text/html'];
|
||||
}
|
||||
|
||||
const images = Object.keys(output.data).filter(key => key.startsWith('image/'));
|
||||
if (images.length > 0) {
|
||||
const imgEl = document.createElement('img');
|
||||
const imgType = images[0]; // Use the first image type found
|
||||
imgEl.src = `data:${imgType};base64,${output.data[imgType]}`;
|
||||
outputElement.innerHTML += imgEl.outerHTML;
|
||||
}
|
||||
} else if (output.output_type === 'execute_result') {
|
||||
if (output.data['text/plain']) {
|
||||
outputElement.innerHTML += `<pre>${output.data['text/plain']}</pre>`;
|
||||
}
|
||||
if (output.data['text/html']) {
|
||||
outputElement.innerHTML += output.data['text/html'];
|
||||
}
|
||||
if (output.data['image/png']) {
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = `data:image/png;base64,${output.data['image/png']}`;
|
||||
outputElement.appendChild(imgEl);
|
||||
}
|
||||
} else if (output.output_type === 'error') {
|
||||
outputElement.classList.add('error');
|
||||
outputElement.textContent = `Error: ${output.ename}: ${output.evalue}`;
|
||||
}
|
||||
|
||||
return outputElement;
|
||||
});
|
||||
}
|
||||
|
||||
private createCellElement(cell: any): HTMLElement {
|
||||
const cellElement = document.createElement('div');
|
||||
const source = cell.source.join('');
|
||||
cellElement.classList.add('jupyter-cell');
|
||||
|
||||
switch (cell.cell_type) {
|
||||
case 'markdown':
|
||||
const converter = new showdown.Converter();
|
||||
cellElement.classList.add('markdown-cell');
|
||||
cellElement.innerHTML = `<div class="markdown-body">${converter.makeHtml(latex.render(source))}</div>`;
|
||||
break;
|
||||
case 'code':
|
||||
cellElement.classList.add('code-cell');
|
||||
cellElement.innerHTML = `<pre class="hljs"><code class="language-${this.language}">${source}</code></pre>`;
|
||||
hljs.highlightElement(cellElement.querySelector('code') as HTMLElement);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return cellElement;
|
||||
}
|
||||
|
||||
|
||||
private createCells(): HTMLElement[] {
|
||||
return (this.notebook.cells || []).map((cell: any) => {
|
||||
const container = document.createElement('div');
|
||||
const cellElement = this.createCellElement(cell);
|
||||
const outputs = this.getOutputs(cell);
|
||||
|
||||
container.classList.add('jupyter-cell-container');
|
||||
container.appendChild(cellElement);
|
||||
outputs.forEach((output: HTMLElement) => container.appendChild(output));
|
||||
return container;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process Jupyter notebooks
|
||||
document.querySelectorAll<HTMLElement>('.jupyter.notebook pre').forEach((el) => {
|
||||
new IPynb(el).mount();
|
||||
});
|
||||
66
public/latex.ts
Normal file
66
public/latex.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import katex from 'katex';
|
||||
|
||||
const delimiters = [
|
||||
{ left: '\\\$\\\$', right: '\\\$\\\$', multiline: true },
|
||||
{ left: '\\\$', right: '\\\$', multiline: false },
|
||||
{ left: '\\\\\[', right: '\\\\\]', multiline: true },
|
||||
{ left: '\\\\\(', right: '\\\\\)', multiline: false },
|
||||
];
|
||||
|
||||
const delimiterMatchers = delimiters.map(
|
||||
(delimiter) => new RegExp(
|
||||
`${delimiter.left}(.*?)${delimiter.right}`,
|
||||
`g${delimiter.multiline ? 'ms' : ''}`
|
||||
)
|
||||
);
|
||||
|
||||
// Replace LaTeX delimiters in a string with KaTeX rendering
|
||||
function render(text: string): string {
|
||||
// Step 1: Replace all LaTeX expressions with placeholders
|
||||
const expressions: Array<{ placeholder: string; latex: string; displayMode: boolean }> = [];
|
||||
let modifiedText = text;
|
||||
let placeholderIndex = 0;
|
||||
|
||||
// Process each delimiter type
|
||||
delimiters.forEach((delimiter, i) => {
|
||||
// Find all matches and replace with placeholders
|
||||
modifiedText = modifiedText.replace(delimiterMatchers[i], (match, latex) => {
|
||||
if (!latex.trim()) {
|
||||
return match; // Return original if content is empty
|
||||
}
|
||||
|
||||
const placeholder = `__KATEX_PLACEHOLDER_${placeholderIndex++}__`;
|
||||
expressions.push({
|
||||
placeholder,
|
||||
latex,
|
||||
displayMode: delimiter.multiline,
|
||||
});
|
||||
|
||||
return placeholder;
|
||||
});
|
||||
});
|
||||
|
||||
// Step 2: Replace placeholders with rendered LaTeX
|
||||
for (const { placeholder, latex, displayMode } of expressions) {
|
||||
try {
|
||||
const rendered = katex.renderToString(latex, {
|
||||
throwOnError: false,
|
||||
displayMode,
|
||||
});
|
||||
modifiedText = modifiedText.replace(placeholder, rendered);
|
||||
} catch (error) {
|
||||
console.error('KaTeX rendering error:', error);
|
||||
// Replace placeholder with original LaTeX if rendering fails
|
||||
modifiedText = modifiedText.replace(
|
||||
placeholder,
|
||||
displayMode ? `$$${latex}$$` : `$${latex}$`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedText;
|
||||
}
|
||||
|
||||
export default {
|
||||
render,
|
||||
};
|
||||
@@ -1,23 +1,8 @@
|
||||
import './style.scss';
|
||||
import './favicon-32.png';
|
||||
import './opengist.svg';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/cs';
|
||||
import 'dayjs/locale/de';
|
||||
import 'dayjs/locale/es';
|
||||
import 'dayjs/locale/fr';
|
||||
import 'dayjs/locale/hu';
|
||||
import 'dayjs/locale/pt';
|
||||
import 'dayjs/locale/ru';
|
||||
import 'dayjs/locale/zh';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import jdenticon from 'jdenticon/standalone';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.locale(window.opengist_locale || 'en');
|
||||
|
||||
jdenticon.update("[data-jdenticon-value]")
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -55,23 +40,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('user-menu').classList.toggle('hidden');
|
||||
})
|
||||
|
||||
document.querySelectorAll('.moment-timestamp').forEach((e: HTMLElement) => {
|
||||
e.title = dayjs.unix(parseInt(e.innerHTML)).format('LLLL');
|
||||
e.innerHTML = dayjs.unix(parseInt(e.innerHTML)).fromNow();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.moment-timestamp-date').forEach((e: HTMLElement) => {
|
||||
e.innerHTML = dayjs.unix(parseInt(e.innerHTML)).format('DD/MM/YYYY HH:mm');
|
||||
});
|
||||
|
||||
document.querySelectorAll('form').forEach((form: HTMLFormElement) => {
|
||||
form.onsubmit = () => {
|
||||
form.querySelectorAll('input[type=datetime-local]').forEach((input: HTMLInputElement) => {
|
||||
console.log(dayjs(input.value).unix());
|
||||
const hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'expiredAtUnix'
|
||||
hiddenInput.value = dayjs(input.value).unix().toString();
|
||||
hiddenInput.value = Math.floor(new Date(input.value).getTime() / 1000).toString();
|
||||
form.appendChild(hiddenInput);
|
||||
});
|
||||
return true;
|
||||
|
||||
27
public/style.css
vendored
27
public/style.css
vendored
@@ -10,6 +10,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--red-diff: rgba(255, 0, 0, .1);
|
||||
--green-diff: rgba(0, 255, 128, .1);
|
||||
--git-diff: rgba(143, 143, 143, 0.38);
|
||||
}
|
||||
|
||||
html {
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
}
|
||||
@@ -41,18 +47,19 @@ pre {
|
||||
.code .line-num {
|
||||
width: 4%;
|
||||
text-align: right;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.red-diff {
|
||||
background-color: rgba(255, 0, 0, .1);
|
||||
background-color: var(--red-diff);
|
||||
}
|
||||
|
||||
.green-diff {
|
||||
background-color: rgba(0, 255, 128, .1);
|
||||
background-color: var(--green-diff);
|
||||
}
|
||||
|
||||
.gray-diff {
|
||||
background-color: rgba(143, 143, 143, 0.38);
|
||||
background-color: var(--git-diff);
|
||||
@apply py-4 !important
|
||||
}
|
||||
|
||||
@@ -181,4 +188,18 @@ dl.dl-config dd {
|
||||
|
||||
.hidden-important {
|
||||
@apply hidden !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* A hack to ensure that Jupyter output images are always rendered with a
|
||||
* neutral background color, even if the image itself does not have one, since
|
||||
* Jupyter usually outputs images with transparent or light backgrounds.
|
||||
*/
|
||||
.dark .jupyter-output img {
|
||||
background-color: #888;
|
||||
}
|
||||
|
||||
|
||||
.pdfobject-container {
|
||||
@apply min-h-[700px] h-[700px] !important;
|
||||
}
|
||||
5
public/style.scss
vendored
5
public/style.scss
vendored
@@ -1,9 +1,14 @@
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
:root {
|
||||
@import "highlight.js/scss/github";
|
||||
@import "github-markdown-css/github-markdown-light";
|
||||
@import './catppuccin-latte';
|
||||
@import './ipynb';
|
||||
}
|
||||
|
||||
.dark {
|
||||
@import "highlight.js/scss/github-dark";
|
||||
@import "github-markdown-css/github-markdown-dark";
|
||||
@import './catppuccin-macchiato';
|
||||
}
|
||||
|
||||
45
public/style_preferences.ts
Normal file
45
public/style_preferences.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const noSoftWrapRadio = document.getElementById('no-soft-wrap');
|
||||
const softWrapRadio = document.getElementById('soft-wrap');
|
||||
|
||||
function updateRootClass() {
|
||||
const table = document.querySelector("table");
|
||||
|
||||
if (softWrapRadio.checked) {
|
||||
table.classList.remove('whitespace-pre');
|
||||
table.classList.add('whitespace-pre-wrap');
|
||||
} else {
|
||||
table.classList.remove('whitespace-pre-wrap');
|
||||
table.classList.add('whitespace-pre');
|
||||
}
|
||||
}
|
||||
|
||||
noSoftWrapRadio.addEventListener('change', updateRootClass);
|
||||
softWrapRadio.addEventListener('change', updateRootClass);
|
||||
|
||||
|
||||
document.getElementById('removedlinecolor').addEventListener('change', function(event) {
|
||||
const color = hexToRgba(event.target.value, 0.1);
|
||||
document.documentElement.style.setProperty('--red-diff', color);
|
||||
});
|
||||
|
||||
document.getElementById('addedlinecolor').addEventListener('change', function(event) {
|
||||
const color = hexToRgba(event.target.value, 0.1);
|
||||
document.documentElement.style.setProperty('--green-diff', color);
|
||||
});
|
||||
|
||||
document.getElementById('gitlinecolor').addEventListener('change', function(event) {
|
||||
const color = hexToRgba(event.target.value, 0.38);
|
||||
document.documentElement.style.setProperty('--git-diff', color);
|
||||
});
|
||||
});
|
||||
|
||||
function hexToRgba(hex, opacity) {
|
||||
hex = hex.replace('#', '');
|
||||
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
3
public/vite.config.js
vendored
3
public/vite.config.js
vendored
@@ -15,7 +15,8 @@ export default defineConfig({
|
||||
'./public/admin.ts',
|
||||
'./public/gist.ts',
|
||||
'./public/embed.ts',
|
||||
'./public/webauthn.ts'
|
||||
'./public/webauthn.ts',
|
||||
'./public/style_preferences.ts'
|
||||
]
|
||||
},
|
||||
assetsInlineLimit: 0,
|
||||
|
||||
@@ -155,7 +155,7 @@ async function loginWithPasskey() {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
window.location.href = `${baseUrl}`;
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
# Start background processes
|
||||
make watch_frontend &
|
||||
make watch_backend &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
trap 'kill $(jobs -p)' EXIT
|
||||
wait
|
||||
make watch_backend &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Function for graceful shutdown
|
||||
cleanup() {
|
||||
echo "Shutting down gracefully..."
|
||||
kill -TERM $FRONTEND_PID $BACKEND_PID 2>/dev/null || true
|
||||
wait $FRONTEND_PID $BACKEND_PID 2>/dev/null || true
|
||||
echo "Shutdown complete"
|
||||
}
|
||||
|
||||
# Set up trap for graceful shutdown
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Wait for background processes
|
||||
wait
|
||||
10
templates/base/base_header.html
vendored
10
templates/base/base_header.html
vendored
@@ -50,6 +50,16 @@
|
||||
{{ else }}
|
||||
<title>{{ if $.c.CustomName }}{{ $.c.CustomName }}{{ else }}Opengist{{ end }}</title>
|
||||
{{ end }}
|
||||
|
||||
{{ if .currentStyle }}
|
||||
<style>
|
||||
:root {
|
||||
--red-diff: rgba({{ hexToRgb .currentStyle.RemovedLineColor }} 0.1);
|
||||
--green-diff: rgba({{ hexToRgb .currentStyle.AddedLineColor }} 0.1);
|
||||
--git-diff: rgba({{ hexToRgb .currentStyle.GitLineColor }} 0.38);
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div id="app" class="text-gray-700 dark:text-white min-h-full bg-white dark:bg-gray-900">
|
||||
|
||||
2
templates/base/gist_header.html
vendored
2
templates/base/gist_header.html
vendored
@@ -91,7 +91,7 @@
|
||||
{{ if .gist.Forked }}
|
||||
<p class="mt-1 max-w-2xl text-sm text-slate-500">{{ .locale.Tr "gist.header.forked-from" }} <a href="{{ $.c.ExternalUrl }}/{{ .gist.Forked.User.Username }}/{{ .gist.Forked.Identifier }}">{{ .gist.Forked.User.Username }}/{{ .gist.Forked.Title }}</a></p>
|
||||
{{ end }}
|
||||
<p class="mt-1 max-w-2xl text-sm text-slate-500">{{ .locale.Tr "gist.header.last-active" }} <span class="moment-timestamp"> {{ .gist.UpdatedAt }} </span>
|
||||
<p class="mt-1 max-w-2xl text-sm text-slate-500">{{ .locale.Tr "gist.header.last-active" }} <span> {{ .gist.UpdatedAt | humanTimeDiff }} </span>
|
||||
{{ if .gist.Private }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300"> {{ visibilityStr .gist.Private false }} </span>{{ end }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm max-w-2xl text-slate-600 dark:text-slate-400">{{ .gist.Description }}</p>
|
||||
|
||||
8
templates/base/settings_footer.html
vendored
Normal file
8
templates/base/settings_footer.html
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{{ if false }}{{/* prevent IDE errors */}}
|
||||
<div><main>
|
||||
{{ end }}
|
||||
|
||||
{{ define "settings_footer" }}
|
||||
</main>
|
||||
</div>
|
||||
{{ end }}
|
||||
28
templates/base/settings_header.html
vendored
Normal file
28
templates/base/settings_header.html
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{{ define "settings_header" }}
|
||||
<div class="py-10">
|
||||
<header class="pb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "settings" }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="mb-4">
|
||||
<div class="">
|
||||
<nav class="flex space-x-4" aria-label="Tabs">
|
||||
<a href="{{ $.c.ExternalUrl }}/settings" class="{{ if eq .settingsHeaderPage "account" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}">{{ .locale.Tr "settings.header.account" }} </a>
|
||||
<a href="{{ $.c.ExternalUrl }}/settings/mfa" class="{{ if eq .settingsHeaderPage "mfa" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.mfa" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/settings/ssh" class="{{ if eq .settingsHeaderPage "ssh" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.ssh" }}</a>
|
||||
<a href="{{ $.c.ExternalUrl }}/settings/style" class="{{ if eq .settingsHeaderPage "style" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.style" }}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if false }}
|
||||
{{/* prevent IDE errors */}}
|
||||
</main></div>
|
||||
{{ end }}
|
||||
13
templates/pages/admin_config.html
vendored
13
templates/pages/admin_config.html
vendored
@@ -71,6 +71,19 @@
|
||||
<dt>OIDC Discovery URL</dt><dd>{{ if .c.OIDCDiscoveryUrl }}<defined>{{ end }}</dd>
|
||||
<dt>OIDC Group Claim Name</dt><dd>{{ .c.OIDCGroupClaimName }}</dd>
|
||||
<dt>OIDC Admin Group</dt><dd>{{ .c.OIDCAdminGroup }}</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>
|
||||
</div>
|
||||
<div class="relative flex justify-center">
|
||||
<span class="bg-gray-50 dark:bg-gray-800 px-2 text-sm text-slate-700 dark:text-slate-300 font-bold">LDAP</span>
|
||||
</div>
|
||||
</div>
|
||||
<dt>LDAP URL</dt><dd>{{ .c.LDAPUrl }}</dd>
|
||||
<dt>LDAP Bind DN</dt><dd>{{ .c.LDAPBindDn }}</dd>
|
||||
<dt>LDAP Bind Credentials</dt><dd>{{ if .c.LDAPBindCredentials }}<defined>{{ end }}</dd>
|
||||
<dt>LDAP Search Base</dt><dd>{{ .c.LDAPSearchBase }}</dd>
|
||||
<dt>LDAP Search Filter</dt><dd>{{ .c.LDAPSearchFilter }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
2
templates/pages/admin_gists.html
vendored
2
templates/pages/admin_gists.html
vendored
@@ -26,7 +26,7 @@
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.Private }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.NbFiles }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300">{{ $gist.NbLikes }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span class="moment-timestamp-date">{{ $gist.CreatedAt }}</span></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span>{{ $gist.CreatedAt | humanDate }}</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">
|
||||
{{ $.csrfHtml }}
|
||||
|
||||
2
templates/pages/admin_invitations.html
vendored
2
templates/pages/admin_invitations.html
vendored
@@ -51,7 +51,7 @@
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap py-2 px-2 text-sm">{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm"><span class="moment-timestamp-date">{{ $invitation.ExpiresAt }}</span></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm"><span>{{ $invitation.ExpiresAt | humanDate }}</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">
|
||||
{{ $.csrfHtml }}
|
||||
|
||||
2
templates/pages/admin_users.html
vendored
2
templates/pages/admin_users.html
vendored
@@ -18,7 +18,7 @@
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-slate-700 dark:text-slate-300 sm:pl-0">{{ $user.ID }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><a href="{{ $.c.ExternalUrl }}/{{ $user.Username }}">{{ $user.Username }}</a></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span class="moment-timestamp-date">{{ $user.CreatedAt }}</span></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span>{{ $user.CreatedAt | humanDate }}</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">
|
||||
{{ $.csrfHtml }}
|
||||
|
||||
2
templates/pages/all.html
vendored
2
templates/pages/all.html
vendored
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{.fromUser.Username}}</h1>
|
||||
<p class="text-sm text-slate-500">{{ .locale.Tr "gist.list.joined" }} <span class="moment-timestamp">{{.fromUser.CreatedAt}}</span></p>
|
||||
<p class="text-sm text-slate-500">{{ .locale.Tr "gist.list.joined" }} <span>{{.fromUser.CreatedAt | humanTimeDiff}}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
|
||||
23
templates/pages/create.html
vendored
23
templates/pages/create.html
vendored
@@ -41,6 +41,24 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div id="file-upload-zone" class="space-y-4">
|
||||
<label for="file-upload" class="cursor-pointer block">
|
||||
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 text-center hover:border-primary-400 dark:hover:border-primary-500 transition-colors">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div class="mt-4">
|
||||
<span class="mt-2 block text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{{ .locale.Tr "gist.new.drop-files" }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">{{ .locale.Tr "gist.new.any-file-type" }}</p>
|
||||
</div>
|
||||
<input id="file-upload" name="file-upload" type="file" class="sr-only" multiple accept="*/*">
|
||||
</label>
|
||||
<div id="uploaded-files" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<button type="button" id="add-file" 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-gray-700 dark:text-white bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">{{ .locale.Tr "gist.new.add-file" }}</button>
|
||||
|
||||
@@ -65,6 +83,11 @@
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
|
||||
<!-- Hidden template for new text editors -->
|
||||
<div id="editor-template" class="hidden">
|
||||
{{ template "_editor" dict "Filename" "" "Content" "" "Binary" false "locale" .locale }}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
25
templates/pages/edit.html
vendored
25
templates/pages/edit.html
vendored
@@ -66,13 +66,29 @@
|
||||
<div id="editors" class="space-y-4">
|
||||
{{ if .dto.Files }}
|
||||
{{ range .dto.Files }}
|
||||
{{ template "_editor" dict "Filename" .Filename "Content" .Content "locale" $.locale }}
|
||||
{{ template "_editor" dict "Filename" .Filename "Content" .Content "Binary" .Binary "locale" $.locale }}
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
{{ template "_editor" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div id="file-upload-zone" class="space-y-4">
|
||||
<label for="file-upload" class="cursor-pointer block">
|
||||
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 text-center hover:border-primary-400 dark:hover:border-primary-500 transition-colors">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div class="mt-4">
|
||||
<span class="mt-2 block text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{{ .locale.Tr "gist.new.drop-files" }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">{{ .locale.Tr "gist.new.any-file-type" }}</p>
|
||||
</div>
|
||||
<input id="file-upload" name="file-upload" type="file" class="sr-only" multiple accept="*/*">
|
||||
</label>
|
||||
<div id="uploaded-files" class="space-y-2"></div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button type="button" id="add-file" 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-gray-700 dark:text-white bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">{{ .locale.Tr "gist.new.add-file" }}</button>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}" class="ml-auto 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 bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 text-rose-600 dark:text-rose-400 hover:text-rose-700">{{ .locale.Tr "gist.edit.cancel" }}</a>
|
||||
@@ -81,6 +97,11 @@
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
|
||||
<!-- Hidden template for new text editors -->
|
||||
<div id="editor-template" class="hidden">
|
||||
{{ template "_editor" dict "Filename" "" "Content" "" "Binary" false "locale" .locale }}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
2
templates/pages/forks.html
vendored
2
templates/pages/forks.html
vendored
@@ -16,7 +16,7 @@
|
||||
</a>
|
||||
<div>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ $gist.User.Username }}" class="text-sm font-medium text-slate-700 dark:text-slate-300">{{ $gist.User.Username }}</a>
|
||||
<p class="text-sm text-slate-500">{{ $.locale.Tr "gist.list.forked" }} <span class="moment-timestamp">{{ $gist.CreatedAt }}</span></p>
|
||||
<p class="text-sm text-slate-500">{{ $.locale.Tr "gist.list.forked" }} <span>{{ $gist.CreatedAt | humanTimeDiff }}</span></p>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<a class="ml-auto text-slate-700 dark:text-slate-300 relative inline-flex items-center space-x-2 rounded-md border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3" href="{{ $.c.ExternalUrl }}/{{ $gist.User.Username }}/{{ $gist.Identifier }}">
|
||||
|
||||
46
templates/pages/gist.html
vendored
46
templates/pages/gist.html
vendored
@@ -3,7 +3,6 @@
|
||||
{{ if .files }}
|
||||
<div class="grid gap-y-4">
|
||||
{{ range $file := .files }}
|
||||
{{ $csv := csvFile $file.File }}
|
||||
<div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 overflow-auto" data-file="{{ $file.Filename }}">
|
||||
<div class="border-b-1 border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 my-auto block">
|
||||
<div class="ml-4 py-1.5 flex">
|
||||
@@ -22,11 +21,13 @@
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/{{ $.commit }}/{{$file.Filename}}" class="relative inline-flex items-center rounded-l-md bg-white text-gray-500 dark:text-slate-300 float-right px-2.5 py-1 leading-4 text-xs font-medium dark:bg-gray-600 border border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-slate-700 dark:hover:text-slate-300 select-none">
|
||||
{{ $.locale.Tr "gist.raw" }}
|
||||
</a>
|
||||
{{ if $file.MimeType.IsText }}
|
||||
<button type="button" class="relative -ml-px inline-flex items-center bg-white text-gray-500 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 px-1 py-1 dark:text-slate-300 dark:bg-gray-600 dark:hover:bg-gray-700 copy-gist-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
</button>
|
||||
{{ end }}
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/download/{{ $.commit }}/{{$file.Filename}}" class="relative -ml-px inline-flex items-center rounded-r-md bg-white text-gray-500 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 px-1 py-1 dark:text-slate-300 dark:bg-gray-600 dark:hover:bg-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
@@ -36,29 +37,35 @@
|
||||
|
||||
<div class="hidden gist-content">{{ $file.Content }}</div>
|
||||
</div>
|
||||
{{ if $file.Truncated }}
|
||||
{{ if and $file.Truncated $file.MimeType.IsText }}
|
||||
<div class="text-sm px-4 py-1.5 border-t-1 border-gray-200 dark:border-gray-700">
|
||||
{{ $.locale.Tr "gist.file-truncated" }} <a href="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/{{ $.commit }}/{{$file.Filename}}">{{ $.locale.Tr "gist.watch-full-file" }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if and (not $csv) (isCsv $file.Filename) }}
|
||||
{{ if not $file.MimeType.CanBeRendered }}
|
||||
<div class="text-sm px-4 py-1.5 border-t-1 border-gray-200 dark:border-gray-700">
|
||||
{{ $.locale.Tr "gist.file-raw" }} <a href="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/{{ $.commit }}/{{$file.Filename}}">{{ $.locale.Tr "gist.watch-full-file" }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if and (not $file.MimeType.IsText) ($file.MimeType.IsCSV) }}
|
||||
<div class="text-sm px-4 py-1.5 border-t-1 border-gray-200 dark:border-gray-700">
|
||||
{{ $.locale.Tr "gist.file-not-valid" }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
{{ if $csv }}
|
||||
{{ if $file.MimeType.IsText }}
|
||||
{{ if eq $file.InternalType "CSVFile" }}
|
||||
<table class="csv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ range $csv.Header }}
|
||||
{{ range $file.Header }}
|
||||
<th>{{ . }}</th>
|
||||
{{ end }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range $csv.Rows }}
|
||||
{{ range $file.Rows }}
|
||||
<tr>
|
||||
{{ range . }}
|
||||
<td>{{ . }}</td>
|
||||
@@ -68,22 +75,43 @@
|
||||
</table>
|
||||
{{ else if isMarkdown $file.Filename }}
|
||||
<div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div>
|
||||
{{ else if isSvg $file.Filename }}
|
||||
{{ else if $file.MimeType.IsSVG }}
|
||||
<div class="p-8 flex justify-center">{{ $file.HTML | safe }}</div>
|
||||
{{ else if isJupyter $file.Filename }}
|
||||
<div class="p-8 jupyter notebook"><pre>{{ $file.Content }}</pre></div>
|
||||
{{ else }}
|
||||
<div class="code">
|
||||
{{ $fileslug := slug $file.Filename }}
|
||||
{{ if ne $file.Content "" }}
|
||||
<table class="chroma table-code w-full whitespace-pre" data-filename-slug="{{ $fileslug }}" data-filename="{{ $file.Filename }}" style="font-size: 0.8em; border-spacing: 0; border-collapse: collapse;">
|
||||
<table class="chroma table-code w-full {{ if $.currentStyle }}{{ if $.currentStyle.SoftWrap }}whitespace-pre-wrap{{ else }}whitespace-pre{{ end }}{{ else }}whitespace-pre{{ end }}" data-filename-slug="{{ $fileslug }}" data-filename="{{ $file.Filename }}" style="font-size: 0.8em; border-spacing: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
{{ $ii := "1" }}
|
||||
{{ $i := toInt $ii }}
|
||||
{{ range $line := $file.Lines }}<tr><td id="file-{{ $fileslug }}-{{$i}}" class="select-none line-num px-4">{{$i}}</td><td class="line-code">{{ $line | safe }}</td></tr>{{ $i = inc $i }}{{ end }}
|
||||
{{ range $line := $file.Lines }}<tr><td id="file-{{ $fileslug }}-{{$i}}" class="select-none line-num px-4">{{$i}}</td><td class="line-code break-all">{{ $line | safe }}</td></tr>{{ $i = inc $i }}{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ else if $file.MimeType.IsImage }}
|
||||
<div class="p-8 flex justify-center">
|
||||
<img src="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/{{ $.commit }}/{{$file.Filename}}" alt="{{ $file.Filename }}" class="max-h-screen object-contain">
|
||||
</div>
|
||||
{{ else if $file.MimeType.IsAudio }}
|
||||
<div class="p-8 flex justify-center">
|
||||
<audio controls class="w-full max-w-lg">
|
||||
<source src="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/{{ $.commit }}/{{$file.Filename}}" type="{{ $file.MimeType.ContentType }}">
|
||||
</audio>
|
||||
</div>
|
||||
{{ else if $file.MimeType.IsVideo }}
|
||||
<div class="p-8 flex justify-center">
|
||||
<video controls class="w-full max-w-lg">
|
||||
<source src="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/{{ $.commit }}/{{$file.Filename}}" type="{{ $file.MimeType.ContentType }}">
|
||||
</video>
|
||||
</div>
|
||||
{{ else if $file.MimeType.IsPDF }}
|
||||
<div class="pdf" data-src="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/{{ $.commit }}/{{$file.Filename}}"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
41
templates/pages/gist_embed.html
vendored
41
templates/pages/gist_embed.html
vendored
@@ -3,26 +3,31 @@
|
||||
{{ range $file := .files }}
|
||||
<div class="rounded-md border-1 border-gray-100 dark:border-gray-800 overflow-auto mb-4">
|
||||
<div class="border-b-1 border-gray-100 dark:border-gray-700 text-xs p-2 pl-4 bg-gray-50 dark:bg-gray-800 text-gray-400">
|
||||
<a target="_blank" href="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}#file-{{ slug $file.Filename }}"><span class="font-bold text-gray-700 dark:text-gray-200">{{ $file.Filename }}</span> · {{ $file.HumanSize }} · {{ $file.Type }}</a>
|
||||
<span class="float-right"><a target="_blank" href="{{ $.baseHttpUrl }}">Hosted via Opengist</a> · <span class="text-gray-700 dark:text-gray-200 font-bold"><a target="_blank" href="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/HEAD/{{$file.Filename}}">view raw</a></span></span>
|
||||
<a target="_blank" href="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}#file-{{ slug $file.Filename }}"><span class="font-bold text-gray-700 dark:text-gray-200">{{ $file.Filename }}</span> · {{ $file.HumanSize }} · {{ $file.Type }}</a>
|
||||
<span class="float-right"><a target="_blank" href="{{ $.baseHttpUrl }}">Hosted via Opengist</a> · <span class="text-gray-700 dark:text-gray-200 font-bold"><a target="_blank" href="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/HEAD/{{$file.Filename}}">view raw</a></span></span>
|
||||
</div>
|
||||
{{ if $file.Truncated }}
|
||||
{{ if and $file.Truncated $file.MimeType.IsText }}
|
||||
<div class="text-xs px-4 bg-gray-50 py-1.5 border-b-1 border-gray-100 dark:border-gray-700">
|
||||
{{ $.locale.Tr "gist.file-truncated" }} <a target="_blank" class="text-primary-600" href="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/HEAD/{{$file.Filename}}">{{ $.locale.Tr "gist.watch-full-file" }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ $csv := csvFile $file.File }}
|
||||
{{ if $csv }}
|
||||
{{ if or (not $file.MimeType.CanBeRendered) $file.MimeType.IsPDF }}
|
||||
<div class="text-sm px-4 py-1.5 border-t-1 border-gray-200 dark:border-gray-700">
|
||||
{{ $.locale.Tr "gist.file-raw" }} <a href="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/HEAD/{{$file.Filename}}">{{ $.locale.Tr "gist.watch-full-file" }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if $file.MimeType.IsText }}
|
||||
{{ if eq $file.InternalType "CSVFile" }}
|
||||
<table class="csv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ range $csv.Header }}
|
||||
{{ range $file.Header }}
|
||||
<th>{{ . }}</th>
|
||||
{{ end }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range $csv.Rows }}
|
||||
{{ range $file.Rows }}
|
||||
<tr>
|
||||
{{ range . }}
|
||||
<td>{{ . }}</td>
|
||||
@@ -32,7 +37,7 @@
|
||||
</table>
|
||||
{{ else if isMarkdown $file.Filename }}
|
||||
<div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div>
|
||||
{{ else if isSvg $file.Filename }}
|
||||
{{ else if $file.MimeType.IsSVG }}
|
||||
<div class="p-8 flex justify-center">{{ $file.HTML | safe }}</div>
|
||||
{{ else }}
|
||||
<div class="code dark:bg-gray-900">
|
||||
@@ -48,7 +53,25 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ else if $file.MimeType.IsImage }}
|
||||
<div class="p-8 flex justify-center">
|
||||
<img src="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/HEAD/{{$file.Filename}}" alt="{{ $file.Filename }}" class="max-h-screen object-contain">
|
||||
</div>
|
||||
{{ else if $file.MimeType.IsAudio }}
|
||||
<div class="p-8 flex justify-center">
|
||||
<audio controls class="w-full max-w-lg">
|
||||
<source src="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/HEAD/{{$file.Filename}}" type="{{ $file.MimeType.ContentType }}">
|
||||
</audio>
|
||||
</div>
|
||||
{{ else if $file.MimeType.IsVideo }}
|
||||
<div class="p-8 flex justify-center">
|
||||
<video controls class="w-full max-w-lg">
|
||||
<source src="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/HEAD/{{$file.Filename}}" type="{{ $file.MimeType.ContentType }}">
|
||||
</video>
|
||||
</div>
|
||||
{{ else if $file.MimeType.IsPDF }}
|
||||
<div class="pdf" data-src="{{ $.baseHttpUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/raw/HEAD/{{$file.Filename}}"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
8
templates/pages/revisions.html
vendored
8
templates/pages/revisions.html
vendored
@@ -16,7 +16,7 @@
|
||||
{{ else }}
|
||||
<svg class="h-5 w-5 rounded-full inline" data-jdenticon-value="{{ $commit.AuthorName }}" width="20" height="20"></svg>
|
||||
{{ end }}
|
||||
<span class="font-bold">{{if $user}}<a href="{{ $.c.ExternalUrl }}/{{$user.Username}}" class="text-slate-300 hover:text-slate-300 hover:underline">{{ $commit.AuthorName }}</a>{{else}}{{ $commit.AuthorName }}{{end}}</span> {{ $.locale.Tr "gist.revision.revised" }} <span class="moment-timestamp font-bold">{{ $commit.Timestamp }}</span>. <a href="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/rev/{{ $commit.Hash }}">{{ $.locale.Tr "gist.revision.go-to-revision" }}</a></h3>
|
||||
<span class="font-bold">{{if $user}}<a href="{{ $.c.ExternalUrl }}/{{$user.Username}}" class="text-slate-300 hover:text-slate-300 hover:underline">{{ $commit.AuthorName }}</a>{{else}}{{ $commit.AuthorName }}{{end}}</span> {{ $.locale.Tr "gist.revision.revised" }} <span class="font-bold">{{ $commit.Timestamp | humanTimeDiffStr }}</span>. <a href="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/rev/{{ $commit.Hash }}">{{ $.locale.Tr "gist.revision.go-to-revision" }}</a></h3>
|
||||
{{ if ne $commit.Changed "" }}
|
||||
<p class="text-sm float-right py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline-flex">
|
||||
@@ -49,12 +49,14 @@
|
||||
<div class="overflow-auto">
|
||||
{{ if $file.Truncated }}
|
||||
<p class="m-2 ml-4 text-sm">{{ $.locale.Tr "gist.revision.diff-truncated" }}</p>
|
||||
{{ else if $file.IsBinary }}
|
||||
<p class="m-2 ml-4 text-sm">{{ $.locale.Tr "gist.revision.binary-file-changes" }}</p>
|
||||
{{ else if and (eq $file.Content "") (ne $file.OldFilename "") }}
|
||||
<p class="m-2 ml-4 text-sm">{{ $.locale.Tr "gist.revision.file-renamed-no-changes" }}</p>
|
||||
{{ else if eq $file.Content "" }}
|
||||
<p class="m-2 ml-4 text-sm">{{ $.locale.Tr "gist.revision.empty-file" }}</p>
|
||||
{{ else }}
|
||||
<table class="code chroma table-code w-full whitespace-pre" data-filename="{{ $file.Filename }}" style="font-size: 0.8em; border-spacing: 0">
|
||||
<table class="code chroma table-code w-full {{ if $.currentStyle }}{{ if $.currentStyle.SoftWrap }}whitespace-pre-wrap{{ else }}whitespace-pre{{ end }}{{ else }}whitespace-pre{{ end }}" data-filename="{{ $file.Filename }}" style="font-size: 0.8em; border-spacing: 0">
|
||||
<tbody>
|
||||
{{ $left := 0 }}
|
||||
{{ $right := 0 }}
|
||||
@@ -83,7 +85,7 @@
|
||||
{{ $right = inc $right }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<td class="select-none" style="width: 2%;">{{ if ne (index $line 0) 64 }}{{ slice $line 0 1 }}{{ end }}</td>
|
||||
<td class="select-none" style="width: 2%; vertical-align: top;">{{ if ne (index $line 0) 64 }}{{ slice $line 0 1 }}{{ end }}</td>
|
||||
<td>{{ if ne (index $line 0) 64 }}{{ slice $line 1 }}{{ else }}{{ $line }}{{ end }}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
316
templates/pages/settings.html
vendored
316
templates/pages/settings.html
vendored
@@ -1,316 +0,0 @@
|
||||
{{ template "header" .}}
|
||||
<div class="py-10">
|
||||
<header class="pb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "settings" }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="relative mx-auto max-w-[40rem] space-y-8">
|
||||
<div class="sm:grid {{ if not .disableForm }}grid-cols-2{{ else }}grid-cols-1{{ end }} gap-x-4 md:gap-x-8 space-y-8 md:space-y-0">
|
||||
<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 h-full">
|
||||
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
|
||||
{{ .locale.Tr "settings.change-username" }}
|
||||
</h2>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/username" method="post">
|
||||
<div>
|
||||
<div class="mt-1">
|
||||
<input id="username-change" name="username" 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>
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<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 "settings.change-username" }}
|
||||
</button>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ if not .disableForm }}
|
||||
<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">
|
||||
{{if .hasPassword}}
|
||||
{{ .locale.Tr "settings.change-password" }}
|
||||
{{else}}
|
||||
{{ .locale.Tr "settings.create-password" }}
|
||||
{{end}}
|
||||
</h2>
|
||||
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
|
||||
{{if .hasPassword}}
|
||||
{{ .locale.Tr "settings.change-password-help" }}
|
||||
{{else}}
|
||||
{{ .locale.Tr "settings.create-password-help" }}
|
||||
{{end}}
|
||||
</h3>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/password" method="post">
|
||||
<div>
|
||||
<label for="password-change" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "settings.password-label-title" }} </label>
|
||||
<div class="mt-1">
|
||||
<input id="password-change" name="password" type="password" 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>
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<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">
|
||||
{{if .hasPassword}}
|
||||
{{ .locale.Tr "settings.change-password" }}
|
||||
{{else}}
|
||||
{{ .locale.Tr "settings.create-password" }}
|
||||
{{end}}
|
||||
</button>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<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 "settings.email" }}
|
||||
</h2>
|
||||
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
|
||||
{{ .locale.Tr "settings.email-help" }}
|
||||
</h3>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/email" method="post">
|
||||
<div>
|
||||
<div class="mt-1">
|
||||
<input id="email" name="email" value="{{ .userLogged.Email }}" type="email" 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>
|
||||
<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 "settings.email-set" }}</button>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }}
|
||||
<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 mb-2">
|
||||
{{ .locale.Tr "settings.link-accounts" }}
|
||||
</h2>
|
||||
<div class="gap-y-2">
|
||||
|
||||
{{ if .githubOauth }}
|
||||
{{ if .userLogged.GithubID }}
|
||||
<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>
|
||||
{{ else }}
|
||||
<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 "settings.link-github-account" }}
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .gitlabOauth }}
|
||||
{{ if .userLogged.GitlabID }}
|
||||
<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>
|
||||
{{ else }}
|
||||
<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 "settings.link-gitlab-account" }}
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .giteaOauth }}
|
||||
{{ if .userLogged.GiteaID }}
|
||||
<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>
|
||||
{{ else }}
|
||||
<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">
|
||||
{{ .locale.Tr "settings.link-gitea-account" }}
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .oidcOauth }}
|
||||
{{ if .userLogged.OIDCID }}
|
||||
<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>
|
||||
{{ else }}
|
||||
<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">
|
||||
Link OpenID account
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</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">
|
||||
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
|
||||
{{ .locale.Tr "settings.add-ssh-key" }}
|
||||
</h2>
|
||||
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
|
||||
{{ .locale.Tr "settings.add-ssh-key-help" }}
|
||||
</h3>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/ssh-keys" method="post">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "settings.add-ssh-key-title" }} </label>
|
||||
<div class="mt-1">
|
||||
<input id="title" name="title" 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>
|
||||
|
||||
<div class="mt-8">
|
||||
<label for="sshkey" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "settings.add-ssh-key-content" }} </label>
|
||||
<div class="mt-1">
|
||||
<textarea id="sshkey" required autocomplete="off" name="content" 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"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<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 "settings.add-ssh-key" }}</button>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</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 .sshKeys }}
|
||||
{{ range $key := .sshKeys }}
|
||||
<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">{{ .Title }}</h3>
|
||||
<p class="mt-1 text-xs text-slate-600 dark:text-slate-400 line-clamp-2 code" style="overflow-wrap: anywhere">SHA256:{{.SHA}}</p>
|
||||
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.ssh-key-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 "settings.ssh-key-never-used" }}</p>
|
||||
{{ else }}
|
||||
<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">
|
||||
<input type="hidden" name="_method" value="DELETE">
|
||||
{{ $.csrfHtml }}
|
||||
|
||||
<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>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 "settings.delete-account" }}
|
||||
</h2>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/account" method="post">
|
||||
<input type="hidden" name="_method" value="DELETE">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
|
||||
|
||||
|
||||
{{ template "footer" .}}
|
||||
162
templates/pages/settings_account.html
vendored
Normal file
162
templates/pages/settings_account.html
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
{{ template "header" .}}
|
||||
{{ template "settings_header" .}}
|
||||
<div class="relative mx-auto max-w-[40rem] space-y-8">
|
||||
<div class="sm:grid {{ if not .disableForm }}grid-cols-2{{ else }}grid-cols-1{{ end }} gap-x-4 md:gap-x-8 space-y-8 md:space-y-0">
|
||||
<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 h-full">
|
||||
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
|
||||
{{ .locale.Tr "settings.change-username" }}
|
||||
</h2>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/username" method="post">
|
||||
<div>
|
||||
<div class="mt-1">
|
||||
<input id="username-change" name="username" 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>
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<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 "settings.change-username" }}
|
||||
</button>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ if not .disableForm }}
|
||||
<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">
|
||||
{{if .hasPassword}}
|
||||
{{ .locale.Tr "settings.change-password" }}
|
||||
{{else}}
|
||||
{{ .locale.Tr "settings.create-password" }}
|
||||
{{end}}
|
||||
</h2>
|
||||
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
|
||||
{{if .hasPassword}}
|
||||
{{ .locale.Tr "settings.change-password-help" }}
|
||||
{{else}}
|
||||
{{ .locale.Tr "settings.create-password-help" }}
|
||||
{{end}}
|
||||
</h3>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/password" method="post">
|
||||
<div>
|
||||
<label for="password-change" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "settings.password-label-title" }} </label>
|
||||
<div class="mt-1">
|
||||
<input id="password-change" name="password" type="password" 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>
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<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">
|
||||
{{if .hasPassword}}
|
||||
{{ .locale.Tr "settings.change-password" }}
|
||||
{{else}}
|
||||
{{ .locale.Tr "settings.create-password" }}
|
||||
{{end}}
|
||||
</button>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<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 "settings.email" }}
|
||||
</h2>
|
||||
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
|
||||
{{ .locale.Tr "settings.email-help" }}
|
||||
</h3>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/email" method="post">
|
||||
<div>
|
||||
<div class="mt-1">
|
||||
<input id="email" name="email" value="{{ .userLogged.Email }}" type="email" 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>
|
||||
<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 "settings.email-set" }}</button>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ if or .githubOauth .gitlabOauth .giteaOauth .oidcOauth }}
|
||||
<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 mb-2">
|
||||
{{ .locale.Tr "settings.link-accounts" }}
|
||||
</h2>
|
||||
<div class="gap-y-2">
|
||||
|
||||
{{ if .githubOauth }}
|
||||
{{ if .userLogged.GithubID }}
|
||||
<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>
|
||||
{{ else }}
|
||||
<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 "settings.link-github-account" }}
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .gitlabOauth }}
|
||||
{{ if .userLogged.GitlabID }}
|
||||
<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>
|
||||
{{ else }}
|
||||
<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 "settings.link-gitlab-account" }}
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .giteaOauth }}
|
||||
{{ if .userLogged.GiteaID }}
|
||||
<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>
|
||||
{{ else }}
|
||||
<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">
|
||||
{{ .locale.Tr "settings.link-gitea-account" }}
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .oidcOauth }}
|
||||
{{ if .userLogged.OIDCID }}
|
||||
<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>
|
||||
{{ else }}
|
||||
<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">
|
||||
Link OpenID account
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</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 "settings.delete-account" }}
|
||||
</h2>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/account" method="post">
|
||||
<input type="hidden" name="_method" value="DELETE">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ template "settings_footer" .}}
|
||||
{{ template "footer" .}}
|
||||
91
templates/pages/settings_mfa.html
vendored
Normal file
91
templates/pages/settings_mfa.html
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
{{ template "header" .}}
|
||||
{{ template "settings_header" .}}
|
||||
<div class="relative mx-auto max-w-[40rem] space-y-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.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>{{ .CreatedAt | humanDate }}</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>{{ .LastUsedAt | humanTimeDiff }}</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>
|
||||
|
||||
<script type="module" src="{{ asset "webauthn.ts" }}"></script>
|
||||
</div>
|
||||
|
||||
{{ template "settings_footer" .}}
|
||||
{{ template "footer" .}}
|
||||
69
templates/pages/settings_ssh.html
vendored
Normal file
69
templates/pages/settings_ssh.html
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
{{ template "header" .}}
|
||||
{{ template "settings_header" .}}
|
||||
<div class="relative mx-auto max-w-[40rem] space-y-8">
|
||||
<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 "settings.add-ssh-key" }}
|
||||
</h2>
|
||||
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
|
||||
{{ .locale.Tr "settings.add-ssh-key-help" }}
|
||||
</h3>
|
||||
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/ssh-keys" method="post">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "settings.add-ssh-key-title" }} </label>
|
||||
<div class="mt-1">
|
||||
<input id="title" name="title" 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>
|
||||
|
||||
<div class="mt-8">
|
||||
<label for="sshkey" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "settings.add-ssh-key-content" }} </label>
|
||||
<div class="mt-1">
|
||||
<textarea id="sshkey" required autocomplete="off" name="content" 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"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<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 "settings.add-ssh-key" }}</button>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</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 .sshKeys }}
|
||||
{{ range $key := .sshKeys }}
|
||||
<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">{{ .Title }}</h3>
|
||||
<p class="mt-1 text-xs text-slate-600 dark:text-slate-400 line-clamp-2 code" style="overflow-wrap: anywhere">SHA256:{{.SHA}}</p>
|
||||
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.ssh-key-added-at" }} <span>{{ .CreatedAt | humanDate }}</span></p>
|
||||
{{ if eq .LastUsedAt 0 }}
|
||||
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.ssh-key-never-used" }}</p>
|
||||
{{ else }}
|
||||
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.ssh-key-last-used" }} <span>{{ .LastUsedAt | humanTimeDiff }}</span></p>
|
||||
{{ end }}
|
||||
</div>
|
||||
<form action="{{ $.c.ExternalUrl }}/settings/ssh-keys/{{.ID}}" method="post" class="inline-block">
|
||||
<input type="hidden" name="_method" value="DELETE">
|
||||
{{ $.csrfHtml }}
|
||||
|
||||
<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>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ template "settings_footer" .}}
|
||||
{{ template "footer" .}}
|
||||
110
templates/pages/settings_style.html
vendored
Normal file
110
templates/pages/settings_style.html
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
{{ template "header" .}}
|
||||
{{ template "settings_header" .}}
|
||||
<div class="relative mx-auto max-w-[40rem] space-y-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 "settings.style.gist-code" }}
|
||||
</h2>
|
||||
<div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 overflow-auto mt-4">
|
||||
<div class="border-b-1 border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 my-auto block">
|
||||
<div class="ml-4 py-1.5 flex">
|
||||
|
||||
<span class="flex-auto inline-flex items-center text-sm text-slate-700 dark:text-slate-300 filename">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-700 dark:text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
<a href="#" class="hover:text-primary-600 ml-2 mr-1">file.txt</a>
|
||||
<span class="hidden sm:block">
|
||||
<span class="text-gray-400"> · 95 B · Text</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="isolate inline-flex rounded-md shadow-sm mr-2">
|
||||
<button class="relative inline-flex items-center rounded-l-md bg-white text-gray-500 dark:text-slate-300 float-right px-2.5 py-1 leading-4 text-xs font-medium dark:bg-gray-600 border border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-slate-700 dark:hover:text-slate-300 select-none">
|
||||
{{ $.locale.Tr "gist.raw" }}
|
||||
</button>
|
||||
<button type="button" class="relative -ml-px inline-flex items-center bg-white text-gray-500 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 px-1 py-1 dark:text-slate-300 dark:bg-gray-600 dark:hover:bg-gray-700 copy-gist-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="relative -ml-px inline-flex items-center rounded-r-md bg-white text-gray-500 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 px-1 py-1 dark:text-slate-300 dark:bg-gray-600 dark:hover:bg-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<div class="code">
|
||||
<table class="chroma table-code w-full {{ if .currentStyle }}{{ if .currentStyle.SoftWrap }}whitespace-pre-wrap{{ else }}whitespace-pre{{ end }}{{ else }}whitespace-pre{{ end }}" style="font-size: 0.8em; border-spacing: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="select-none line-num px-4">1</td><td class="line-code break-all">This is a string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="select-none line-num px-4">2</td><td class="line-code break-all">This is a really really really really really really really really long string</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="select-none line-num px-4">3</td><td class="line-code break-all"></td>
|
||||
</tr>
|
||||
<tr class="red-diff">
|
||||
<td class="select-none line-num px-4">4</td><td class="line-code break-all">- code removed</td>
|
||||
</tr>
|
||||
<tr class="red-diff">
|
||||
<td class="select-none line-num px-4">5</td><td class="line-code break-all">- another pretty pretty pretty pretty pretty pretty long code removed</td>
|
||||
</tr>
|
||||
<tr class="green-diff">
|
||||
<td class="select-none line-num px-4">6</td><td class="line-code break-all">+ code added</td>
|
||||
</tr>
|
||||
<tr class="green-diff">
|
||||
<td class="select-none line-num px-4">7</td><td class="line-code break-all">+ added a line which help to demonstrate the difference between enabling and disabling soft wrap</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="select-none line-num px-4">8</td><td class="line-code break-all"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<form method="post" action="{{ $.c.ExternalUrl }}/settings/style" class="mr-2">
|
||||
<div class="mt-6 space-y-6 sm:flex sm:items-center sm:space-x-10 sm:space-y-0">
|
||||
<div class="flex items-center">
|
||||
<input id="no-soft-wrap" value="false" name="softwrap" type="radio" {{ if .currentStyle }}{{ if not .currentStyle.SoftWrap }}checked{{ end }}{{ else }}checked{{ end }} class="relative size-4 appearance-none rounded-full border border-gray-300 bg-white before:absolute before:inset-1 before:rounded-full before:bg-white checked:border-indigo-600 checked:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:before:bg-gray-400 forced-colors:appearance-auto forced-colors:before:hidden [&:not(:checked)]:before:hidden">
|
||||
<label for="no-soft-wrap" class="ml-3 block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "settings.style.no-soft-wrap" }}</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input id="soft-wrap" value="true" name="softwrap" type="radio" {{ if .currentStyle }}{{ if .currentStyle.SoftWrap }}checked{{ end }}{{ end }} class="relative size-4 appearance-none rounded-full border border-gray-300 bg-white before:absolute before:inset-1 before:rounded-full before:bg-white checked:border-indigo-600 checked:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:before:bg-gray-400 forced-colors:appearance-auto forced-colors:before:hidden [&:not(:checked)]:before:hidden">
|
||||
<label for="soft-wrap" class="ml-3 block text-sm font-medium text-slate-700 dark:text-slate-300">{{ .locale.Tr "settings.style.soft-wrap" }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-6 sm:flex sm:items-center sm:space-x-10 sm:space-y-0">
|
||||
<div class="flex-2">
|
||||
<label for="removedlinecolor" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{{ .locale.Tr "settings.style.removed-lines-color" }}</label>
|
||||
<input type="color" value="{{ if .currentStyle }}{{ .currentStyle.RemovedLineColor }}{{ else }}#ff0000{{ end }}" id="removedlinecolor" name="removedlinecolor">
|
||||
</div>
|
||||
<div class="flex-2">
|
||||
<label for="addedlinecolor" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{{ .locale.Tr "settings.style.added-lines-color" }}</label>
|
||||
<input type="color" value="{{ if .currentStyle }}{{ .currentStyle.AddedLineColor }}{{ else }}#00ff80{{ end }}" id="addedlinecolor" name="addedlinecolor">
|
||||
</div>
|
||||
<div class="flex-2">
|
||||
<label for="gitlinecolor" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{{ .locale.Tr "settings.style.git-lines-color" }}</label>
|
||||
<input type="color" value="{{ if .currentStyle }}{{ .currentStyle.GitLineColor }}{{ else }}#8f8f8f{{ end }}" id="gitlinecolor" name="gitlinecolor">
|
||||
</div>
|
||||
</div>
|
||||
{{ .csrfHtml }}
|
||||
<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 "settings.style.save-style" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="{{ asset "style_preferences.ts" }}"></script>
|
||||
|
||||
{{ template "settings_footer" .}}
|
||||
{{ template "footer" .}}
|
||||
2
templates/pages/totp.html
vendored
2
templates/pages/totp.html
vendored
@@ -23,7 +23,7 @@
|
||||
|
||||
</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>
|
||||
<a href="{{ $.c.ExternalUrl }}/settings/mfa" 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 }}
|
||||
|
||||
16
templates/partials/_editor.html
vendored
16
templates/partials/_editor.html
vendored
@@ -1,15 +1,16 @@
|
||||
{{ define "_editor" }}
|
||||
<div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 editor">
|
||||
<div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 editor"{{ if .Binary }} data-binary-original-name="{{ .Filename }}"{{ end }}>
|
||||
<div class="border-b-1 border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 my-auto flex">
|
||||
<p class="mx-2 my-2 inline-flex">
|
||||
<input type="text" name="name" value="{{ .Filename }}" placeholder="{{ $.locale.Tr "gist.new.filename-with-extension" }}" style="line-height: 0.05em" class="form-filename bg-white dark:bg-gray-900 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-200 dark:border-gray-700 rounded-l-md gist-title" autocomplete="off" data-lpignore data-bwignore data-1p-ignore>
|
||||
<button style="line-height: 0.05em" class="delete-file -ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-200 dark:border-gray-700 text-sm font-medium rounded-r-md text-slate-700 dark:text-slate-300 bg-gray-50 dark:bg-gray-800 hover:bg-white dark:hover:bg-gray-900 focus:outline-none" type="button">
|
||||
<button style="line-height: 0.05em" class="delete-file -ml-px relative inline-flex items-center space-x-2 px-4 py-2 border text-rose-600 dark:text-rose-400 border-gray-200 dark:border-gray-700 text-sm font-medium rounded-r-md bg-gray-50 dark:bg-gray-800 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:outline-none" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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>
|
||||
</button>
|
||||
</p>
|
||||
<button type="button" class="md-preview hidden whitespace-nowrap text-slate-700 dark:text-slate-300 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-900 my-2 px-2 text-xs font-medium 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">{{ $.locale.Tr "gist.new.preview" }}</button>
|
||||
{{ if not .Binary }}
|
||||
<div class="hidden mx-2 my-2 sm:inline-flex ml-auto space-x-2">
|
||||
<select class="editor-indent-type whitespace-nowrap text-slate-700 dark:text-slate-300 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-900 pr-8 text-xs font-medium 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">
|
||||
<optgroup label="{{ $.locale.Tr "gist.new.indent-mode" }}">
|
||||
@@ -31,8 +32,15 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<input type="hidden" value="{{ .Content }}" name="content" class="form-filecontent" autocomplete="off">
|
||||
<div class="hidden preview chroma markdown markdown-body p-8"></div>
|
||||
{{ if not .Binary }}
|
||||
<input type="hidden" value="{{ .Content }}" name="content" class="form-filecontent" autocomplete="off">
|
||||
<div class="hidden preview chroma markdown markdown-body p-8"></div>
|
||||
{{ else }}
|
||||
<div class="text-sm px-4 py-1.5 border-t-1 border-gray-200 dark:border-gray-700">
|
||||
{{ $.locale.Tr "gist.file-binary-edit" }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
40
templates/partials/_gist_preview.html
vendored
40
templates/partials/_gist_preview.html
vendored
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<h5 class="text-sm text-slate-500 pb-1">{{ .locale.Tr "gist.list.last-active" }} <span class="moment-timestamp">{{ .gist.UpdatedAt }}</span>
|
||||
<h5 class="text-sm text-slate-500 pb-1">{{ .locale.Tr "gist.list.last-active" }} <span>{{ .gist.UpdatedAt | humanTimeDiff }}</span>
|
||||
{{ if .gist.Forked }} • {{ .locale.Tr "gist.list.forked-from" }} <a href="{{ .c.ExternalUrl }}/{{ .gist.Forked.User.Username }}/{{ .gist.Forked.Identifier }}">{{ .gist.Forked.User.Username }}/{{ .gist.Forked.Title }}</a> {{ end }}
|
||||
{{ if .gist.Private }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300"> {{ visibilityStr .gist.Private false }} </span>{{ end }}</h5>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
@@ -60,24 +60,28 @@
|
||||
<div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 overflow-auto hover:border-primary-600">
|
||||
<div class="code overflow-auto">
|
||||
{{ if .gist.PreviewFilename }}
|
||||
{{ if isMarkdown .gist.PreviewFilename }}
|
||||
<div class="chroma preview markdown markdown-body p-8">{{ .gist.HTML | safe }}</div>
|
||||
{{ else }}
|
||||
<table class="chroma table-code w-full whitespace-pre" data-filename="{{ .gist.PreviewFilename }}" style="font-size: 0.8em; border-spacing: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
{{ $ii := "1" }}
|
||||
{{ $i := toInt $ii }}
|
||||
{{ range $line := .gist.Lines }}
|
||||
{{ if .gist.Preview }}
|
||||
{{ if isMarkdown .gist.PreviewFilename }}
|
||||
<div class="chroma preview markdown markdown-body p-8">{{ .gist.HTML | safe }}</div>
|
||||
{{ else }}
|
||||
<table class="chroma table-code w-full {{ if .currentStyle }}{{ if .currentStyle.SoftWrap }}whitespace-pre-wrap{{ else }}whitespace-pre{{ end }}{{ else }}whitespace-pre{{ end }}" data-filename="{{ .gist.PreviewFilename }}" style="font-size: 0.8em; border-spacing: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
{{ $ii := "1" }}
|
||||
{{ $i := toInt $ii }}
|
||||
{{ range $line := .gist.Lines }}
|
||||
|
||||
<tr>
|
||||
<td class="select-none line-num px-4">{{$i}}</td>
|
||||
<td class="line-code">{{ $line | safe }}</td>
|
||||
</tr>
|
||||
{{ $i = inc $i }}
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
<tr>
|
||||
<td class="select-none line-num px-4">{{$i}}</td>
|
||||
<td class="line-code break-all">{{ $line | safe }}</td>
|
||||
</tr>
|
||||
{{ $i = inc $i }}
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="pl-4 py-0.5 text-xs"><p>{{ .locale.Tr "gist.preview-non-available" }}</p></div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="pl-4 py-0.5 text-xs"><p>{{ .locale.Tr "gist.no-content" }}</p></div>
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user