Compare commits
1 Commits
v1.11.0
...
feat/logpa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59440faedb |
33
CHANGELOG.md
33
CHANGELOG.md
@@ -1,38 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [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:
|
||||
@bash ./scripts/watch.sh
|
||||
@sh ./scripts/watch.sh
|
||||
|
||||
clean:
|
||||
@echo "Cleaning up build artifacts..."
|
||||
|
||||
@@ -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.11
|
||||
docker pull ghcr.io/thomiceli/opengist:1.10
|
||||
```
|
||||
|
||||
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.11
|
||||
image: ghcr.io/thomiceli/opengist:1.10
|
||||
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.11.0/opengist1.11.0-linux-amd64.tar.gz
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.10.0/opengist1.10.0-linux-amd64.tar.gz
|
||||
|
||||
tar xzvf opengist1.11.0-linux-amd64.tar.gz
|
||||
tar xzvf opengist1.10.0-linux-amd64.tar.gz
|
||||
cd opengist
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
# https://github.com/thomiceli/opengist/blob/master/docs/configuration/cheat-sheet.md
|
||||
|
||||
# Set the log level to one of the following: debug, info, warn, error, fatal. Default: warn
|
||||
log-level: debug
|
||||
log-level: warn
|
||||
|
||||
# Set the log output to one or more of the following: `stdout`, `file`. Default: stdout,file
|
||||
log-output: stdout,file
|
||||
|
||||
# Set the path to the log file.
|
||||
log-path: /tmp/logaaa
|
||||
|
||||
# Public URL to access to Opengist
|
||||
external-url:
|
||||
|
||||
@@ -43,7 +46,6 @@ 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
|
||||
@@ -52,9 +54,6 @@ 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
|
||||
|
||||
|
||||
@@ -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.11</span>
|
||||
<span class="pr-1">Released 1.10</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
|
||||
</svg>
|
||||
|
||||
@@ -4,49 +4,48 @@ 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. 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). |
|
||||
| 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. |
|
||||
| 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,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.11.0/opengist1.11.0-linux-amd64.tar.gz
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.10.0/opengist1.10.0-linux-amd64.tar.gz
|
||||
|
||||
tar xzvf opengist1.11.0-linux-amd64.tar.gz
|
||||
tar xzvf opengist1.10.0-linux-amd64.tar.gz
|
||||
cd opengist
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
@@ -10,7 +10,7 @@ Requirements:
|
||||
git clone https://github.com/thomiceli/opengist
|
||||
cd opengist
|
||||
|
||||
git checkout v1.11.0 # optional, to checkout the latest release
|
||||
git checkout v1.10.0 # 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.11.0/opengist1.11.0-linux-amd64.tar.gz
|
||||
wget https://github.com/thomiceli/opengist/releases/download/v1.10.0/opengist1.10.0-linux-amd64.tar.gz
|
||||
|
||||
tar xzvf opengist1.11.0-linux-amd64.tar.gz
|
||||
tar xzvf opengist1.10.0-linux-amd64.tar.gz
|
||||
cd opengist
|
||||
chmod +x opengist
|
||||
./opengist # with or without `--config config.yml`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Init Gists via Git
|
||||
|
||||
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.
|
||||
Opengist allows you to create new snippets via Git over HTTP.
|
||||
|
||||
Simply init a new Git repository where your file(s) is/are located:
|
||||
|
||||
@@ -10,41 +10,19 @@ git add .
|
||||
git commit -m "My cool snippet"
|
||||
```
|
||||
|
||||
### 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.
|
||||
Then add this Opengist special remote URL and push your changes:
|
||||
|
||||
```shell
|
||||
git remote add origin http://opengist.url/thomas/my-custom-gist
|
||||
git remote add origin http://localhost:6157/init
|
||||
|
||||
git push -u origin master
|
||||
```
|
||||
|
||||
**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:
|
||||
Log in with your Opengist account credentials, and your snippet will be created at the specified URL:
|
||||
|
||||
```shell
|
||||
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]
|
||||
Username for 'http://localhost:6157': thomas
|
||||
Password for 'http://thomas@localhost:6157':
|
||||
Enumerating objects: 3, done.
|
||||
Counting objects: 100% (3/3), done.
|
||||
Delta compression using up to 8 threads
|
||||
@@ -52,12 +30,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://opengist.url/thomas/6051e930f140429f9a2f3bb1fa101066
|
||||
remote: Your new repository has been created here: http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066
|
||||
remote:
|
||||
remote: If you want to keep working with your gist, you could set the remote URL via:
|
||||
remote: git remote set-url origin http://opengist.url/thomas/6051e930f140429f9a2f3bb1fa101066
|
||||
remote: git remote set-url origin http://localhost:6157/thomas/6051e930f140429f9a2f3bb1fa101066
|
||||
remote:
|
||||
To http://opengist.url/init
|
||||
To http://localhost:6157/init
|
||||
* [new branch] master -> master
|
||||
```
|
||||
|
||||
|
||||
4
go.mod
4
go.mod
@@ -4,10 +4,9 @@ go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/Kunde21/markdownfmt/v3 v3.1.0
|
||||
github.com/alecthomas/chroma/v2 v2.20.0
|
||||
github.com/alecthomas/chroma/v2 v2.16.0
|
||||
github.com/blevesearch/bleve/v2 v2.5.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/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
|
||||
@@ -67,6 +66,7 @@ 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
|
||||
|
||||
8
go.sum
8
go.sum
@@ -9,11 +9,11 @@ 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.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA=
|
||||
github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
version: 16.7.27
|
||||
version: 16.5.6
|
||||
- name: meilisearch
|
||||
repository: https://meilisearch.github.io/meilisearch-kubernetes
|
||||
version: 0.17.1
|
||||
digest: sha256:ad702e35f258fed1f804d3e48b071767499f5730e099a8c461610950e5182368
|
||||
generated: "2025-09-21T04:49:08.679554149+02:00"
|
||||
version: 0.12.0
|
||||
digest: sha256:31084e570aa16e3a26317aeb6d0d5dec62540c314ee4f703374e6e7827399fa6
|
||||
generated: "2025-03-27T11:34:51.840778733+01:00"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: opengist
|
||||
description: Opengist Helm chart for Kubernetes
|
||||
type: application
|
||||
version: 0.3.0
|
||||
appVersion: 1.11.0
|
||||
version: 0.2.0
|
||||
appVersion: 1.10.0
|
||||
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.7.27
|
||||
version: 16.5.6
|
||||
condition: postgresql.enabled
|
||||
- name: meilisearch
|
||||
repository: https://meilisearch.github.io/meilisearch-kubernetes
|
||||
version: 0.17.1
|
||||
version: 0.12.0
|
||||
condition: meilisearch.enabled
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Opengist Helm Chart
|
||||
|
||||
 
|
||||
 
|
||||
|
||||
Opengist Helm chart for Kubernetes.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package totp
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package password
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -6,9 +6,8 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type argon2ID struct {
|
||||
@@ -25,7 +25,6 @@ func (p *OIDCProvider) RegisterProvider() error {
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
config.C.OIDCGroupClaimName,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package password
|
||||
|
||||
import "github.com/thomiceli/opengist/internal/auth"
|
||||
|
||||
func HashPassword(code string) (string, error) {
|
||||
return Argon2id.Hash(code)
|
||||
return auth.Argon2id.Hash(code)
|
||||
}
|
||||
|
||||
func VerifyPassword(code, hashedCode string) (bool, error) {
|
||||
return Argon2id.Verify(code, hashedCode)
|
||||
return auth.Argon2id.Verify(code, hashedCode)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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,12 +36,11 @@ var CmdStart = cli.Command{
|
||||
|
||||
Initialize(ctx)
|
||||
|
||||
server := server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false)
|
||||
go server.Start()
|
||||
go server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false).Start()
|
||||
go ssh.Start()
|
||||
|
||||
<-stopCtx.Done()
|
||||
shutdown(server)
|
||||
shutdown()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -131,7 +130,7 @@ func Initialize(ctx *cli.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown(server *server.Server) {
|
||||
func shutdown() {
|
||||
log.Info().Msg("Shutting down database...")
|
||||
if err := db.Close(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to close database")
|
||||
@@ -142,8 +141,6 @@ func shutdown(server *server.Server) {
|
||||
index.Close()
|
||||
}
|
||||
|
||||
server.Stop()
|
||||
|
||||
log.Info().Msg("Shutdown complete")
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ type config struct {
|
||||
|
||||
LogLevel string `yaml:"log-level" env:"OG_LOG_LEVEL"`
|
||||
LogOutput string `yaml:"log-output" env:"OG_LOG_OUTPUT"`
|
||||
LogPath string `yaml:"log-path" env:"OG_LOG_PATH"`
|
||||
ExternalUrl string `yaml:"external-url" env:"OG_EXTERNAL_URL"`
|
||||
OpengistHome string `yaml:"opengist-home" env:"OG_OPENGIST_HOME"`
|
||||
|
||||
@@ -51,8 +52,6 @@ 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"`
|
||||
@@ -115,8 +114,6 @@ 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"
|
||||
@@ -156,6 +153,10 @@ func InitConfig(configPath string, out io.Writer) error {
|
||||
c.OpengistHome = filepath.Join(homeDir, ".opengist")
|
||||
}
|
||||
|
||||
if c.LogPath == "" {
|
||||
c.LogPath = filepath.Join(GetHomeDir(), "log")
|
||||
}
|
||||
|
||||
if err = checks(c); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -173,7 +174,7 @@ func InitConfig(configPath string, out io.Writer) error {
|
||||
}
|
||||
|
||||
func InitLog() {
|
||||
if err := os.MkdirAll(filepath.Join(GetHomeDir(), "log"), 0755); err != nil {
|
||||
if err := os.MkdirAll(C.LogPath, 0755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -214,7 +215,7 @@ func InitLog() {
|
||||
logWriters = append(logWriters, consoleWriter)
|
||||
defer func() { log.Debug().Msg("Logging to stdout") }()
|
||||
case "file":
|
||||
file, err := os.OpenFile(filepath.Join(GetHomeDir(), "log", "opengist.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
file, err := os.OpenFile(filepath.Join(C.LogPath, "opengist.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,16 @@ 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"
|
||||
@@ -155,7 +154,7 @@ func Setup(dbUri string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}); err != nil {
|
||||
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -269,5 +268,5 @@ func DeprecationDBFilename() {
|
||||
}
|
||||
|
||||
func TruncateDatabase() error {
|
||||
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{})
|
||||
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -418,20 +420,12 @@ 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)),
|
||||
})
|
||||
}
|
||||
return files, err
|
||||
@@ -452,20 +446,12 @@ 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)),
|
||||
}, err
|
||||
}
|
||||
|
||||
@@ -487,14 +473,8 @@ func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error {
|
||||
}
|
||||
|
||||
for _, file := range *files {
|
||||
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
|
||||
}
|
||||
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,28 +532,19 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
|
||||
gist.Preview = ""
|
||||
gist.PreviewFilename = ""
|
||||
} else {
|
||||
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
|
||||
|
||||
if !file.MimeType.CanBeEdited() {
|
||||
continue
|
||||
}
|
||||
|
||||
split := strings.Split(file.Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = file.Content
|
||||
}
|
||||
file, err := gist.File("HEAD", filesStr[0], true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
split := strings.Split(file.Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = file.Content
|
||||
}
|
||||
|
||||
gist.PreviewFilename = file.Filename
|
||||
}
|
||||
|
||||
if withTimestampUpdate {
|
||||
@@ -642,6 +613,30 @@ 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 {
|
||||
@@ -691,15 +686,10 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
|
||||
|
||||
fileDTOs := make([]FileDTO, 0, len(files))
|
||||
for _, file := range files {
|
||||
f := FileDTO{
|
||||
fileDTOs = append(fileDTOs, FileDTO{
|
||||
Filename: file.Filename,
|
||||
}
|
||||
if file.MimeType.CanBeEdited() {
|
||||
f.Content = file.Content
|
||||
} else {
|
||||
f.Binary = true
|
||||
}
|
||||
fileDTOs = append(fileDTOs, f)
|
||||
Content: file.Content,
|
||||
})
|
||||
}
|
||||
|
||||
return &GistDTO{
|
||||
@@ -736,10 +726,8 @@ type VisibilityDTO struct {
|
||||
}
|
||||
|
||||
type FileDTO struct {
|
||||
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
|
||||
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
|
||||
Content string `validate:"required"`
|
||||
}
|
||||
|
||||
func (dto *GistDTO) ToGist() *Gist {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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"
|
||||
"slices"
|
||||
|
||||
"github.com/thomiceli/opengist/internal/auth"
|
||||
"github.com/thomiceli/opengist/internal/auth/password"
|
||||
ogtotp "github.com/thomiceli/opengist/internal/auth/totp"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"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 := ogtotp.AESEncrypt(config.SecretKey, secretBytes)
|
||||
encrypted, err := auth.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 := ogtotp.AESDecrypt(config.SecretKey, ciphertext)
|
||||
secretBytes, err := auth.AESDecrypt(config.SecretKey, ciphertext)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
@@ -202,11 +203,6 @@ 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
|
||||
@@ -385,17 +381,6 @@ 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)
|
||||
|
||||
@@ -580,6 +565,50 @@ 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 {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
type MimeType struct {
|
||||
ContentType string
|
||||
}
|
||||
|
||||
func (mt MimeType) IsText() bool {
|
||||
return strings.Contains(mt.ContentType, "text/")
|
||||
}
|
||||
|
||||
func (mt MimeType) IsCSV() bool {
|
||||
return strings.Contains(mt.ContentType, "text/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) MimeType {
|
||||
return MimeType{mimetype.Detect(data).String()}
|
||||
}
|
||||
@@ -3,23 +3,27 @@ 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:"-"`
|
||||
IsBinary bool `json:"-"`
|
||||
MimeType MimeType `json:"-"`
|
||||
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
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
@@ -58,8 +62,6 @@ 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
|
||||
@@ -204,20 +206,6 @@ 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 {
|
||||
@@ -356,3 +344,27 @@ 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
|
||||
}
|
||||
|
||||
@@ -23,12 +23,9 @@ 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
|
||||
@@ -49,8 +46,6 @@ 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
|
||||
@@ -120,7 +115,6 @@ 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
|
||||
@@ -221,8 +215,6 @@ 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
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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) getFile() *git.File {
|
||||
return r.File
|
||||
}
|
||||
|
||||
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,44 +5,47 @@ 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 HighlightedFile struct {
|
||||
type RenderedFile struct {
|
||||
*git.File
|
||||
Type string `json:"type"`
|
||||
Lines []string `json:"-"`
|
||||
HTML string `json:"-"`
|
||||
}
|
||||
|
||||
func (r HighlightedFile) getFile() *git.File {
|
||||
return r.File
|
||||
}
|
||||
|
||||
type RenderedGist struct {
|
||||
*db.Gist
|
||||
Lines []string
|
||||
HTML string
|
||||
}
|
||||
|
||||
func highlightFile(file *git.File) (HighlightedFile, error) {
|
||||
rendered := HighlightedFile{
|
||||
File: file,
|
||||
}
|
||||
if !file.MimeType.IsText() {
|
||||
return rendered, nil
|
||||
}
|
||||
func HighlightFile(file *git.File) (RenderedFile, error) {
|
||||
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
|
||||
@@ -71,6 +74,38 @@ func highlightFile(file *git.File) (HighlightedFile, 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,
|
||||
@@ -111,12 +146,18 @@ func HighlightGistPreview(gist *db.Gist) (RenderedGist, error) {
|
||||
return rendered, err
|
||||
}
|
||||
|
||||
func renderSvgFile(file *git.File) HighlightedFile {
|
||||
return HighlightedFile{
|
||||
func RenderSvgFile(file *git.File) RenderedFile {
|
||||
rendered := RenderedFile{
|
||||
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,8 +2,6 @@ package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
|
||||
"github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
@@ -14,6 +12,7 @@ import (
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/util"
|
||||
"go.abhg.dev/goldmark/mermaid"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
|
||||
@@ -28,11 +27,11 @@ func MarkdownGistPreview(gist *db.Gist) (RenderedGist, error) {
|
||||
}, err
|
||||
}
|
||||
|
||||
func renderMarkdownFile(file *git.File) (HighlightedFile, error) {
|
||||
func MarkdownFile(file *git.File) (RenderedFile, error) {
|
||||
var buf bytes.Buffer
|
||||
err := newMarkdownWithSvgExtension().Convert([]byte(file.Content), &buf)
|
||||
|
||||
return HighlightedFile{
|
||||
return RenderedFile{
|
||||
File: file,
|
||||
HTML: buf.String(),
|
||||
Type: "Markdown",
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
)
|
||||
|
||||
type RenderedFile interface {
|
||||
getFile() *git.File
|
||||
}
|
||||
|
||||
type NonHighlightedFile struct {
|
||||
*git.File
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (r NonHighlightedFile) getFile() *git.File {
|
||||
return r.File
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Error().Err(err).Msg("Error parsing CSV file for " + file.Filename)
|
||||
}
|
||||
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,16 +2,15 @@ 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
|
||||
@@ -58,7 +57,7 @@ func (ctx *Context) DataMap() echo.Map {
|
||||
}
|
||||
|
||||
func (ctx *Context) ErrorRes(code int, message string, err error) error {
|
||||
if code >= 500 && err != nil {
|
||||
if code >= 500 {
|
||||
var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
|
||||
skipLogger.Error().Err(err).Msg(message)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/thomiceli/opengist/internal/auth"
|
||||
"github.com/thomiceli/opengist/internal/auth/ldap"
|
||||
passwordpkg "github.com/thomiceli/opengist/internal/auth/password"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/i18n"
|
||||
@@ -125,15 +124,15 @@ func ProcessLogin(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), 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")
|
||||
if ldap.Enabled() {
|
||||
if user, err = tryLdapLogin(ctx, dto.Username, dto.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if user == nil {
|
||||
if user, err = tryDbLogin(ctx, dto.Username, dto.Password); user == nil {
|
||||
return err
|
||||
}
|
||||
return ctx.ErrorRes(500, "Authentication system error", nil)
|
||||
}
|
||||
|
||||
// handle MFA
|
||||
@@ -161,3 +160,59 @@ func Logout(ctx *context.Context) error {
|
||||
ctx.DeleteCsrfCookie()
|
||||
return ctx.RedirectTo("/all")
|
||||
}
|
||||
|
||||
func tryDbLogin(ctx *context.Context, username, password string) (user *db.User, err error) {
|
||||
if user, err = db.GetUserByUsername(username); err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ctx.ErrorRes(500, "Cannot get user", err)
|
||||
}
|
||||
|
||||
log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP())
|
||||
ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error")
|
||||
return nil, ctx.RedirectTo("/login")
|
||||
}
|
||||
|
||||
if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok {
|
||||
if err != nil {
|
||||
return nil, 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 nil, ctx.RedirectTo("/login")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func tryLdapLogin(ctx *context.Context, username, password string) (user *db.User, err error) {
|
||||
ok, err := ldap.Authenticate(username, password)
|
||||
if err != nil {
|
||||
log.Info().Err(err).Msgf("LDAP authentication error")
|
||||
return nil, ctx.ErrorRes(500, "Cannot get user", err)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
log.Warn().Msg("Invalid LDAP authentication attempt from " + ctx.RealIP())
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if user, err = db.GetUserByUsername(username); err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ctx.ErrorRes(500, "Cannot get user", 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, ctx.ErrorRes(500, "Cannot create user", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
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 {
|
||||
@@ -48,16 +43,10 @@ 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"
|
||||
@@ -69,57 +58,10 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
dto.Files = append(dto.Files, db.FileDTO{
|
||||
Filename: strings.TrimSpace(name),
|
||||
Filename: strings.Trim(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)
|
||||
@@ -158,13 +100,24 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
if gist.Title == "" {
|
||||
if dto.Files[0].Filename == "" {
|
||||
if ctx.Request().PostForm["name"][0] == "" {
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
} else {
|
||||
gist.Title = dto.Files[0].Filename
|
||||
gist.Title = ctx.Request().PostForm["name"][0]
|
||||
}
|
||||
}
|
||||
|
||||
if len(dto.Files) > 0 {
|
||||
split := strings.Split(dto.Files[0].Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = dto.Files[0].Content
|
||||
}
|
||||
|
||||
gist.PreviewFilename = dto.Files[0].Filename
|
||||
}
|
||||
|
||||
if err = gist.InitRepository(); err != nil {
|
||||
return ctx.ErrorRes(500, "Error creating the repository", err)
|
||||
}
|
||||
@@ -185,9 +138,6 @@ 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,6 +7,7 @@ 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 {
|
||||
@@ -19,8 +20,10 @@ func RawFile(ctx *context.Context) error {
|
||||
if file == nil {
|
||||
return ctx.NotFound("File not found")
|
||||
}
|
||||
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
|
||||
ctx.Response().Header().Set("Content-Disposition", "inline; filename=\""+file.Filename+"\"")
|
||||
contentType := handlers.GetContentTypeFromFilename(file.Filename)
|
||||
ContentDisposition := handlers.GetContentDisposition(file.Filename)
|
||||
ctx.Response().Header().Set("Content-Type", contentType)
|
||||
ctx.Response().Header().Set("Content-Disposition", ContentDisposition)
|
||||
return ctx.PlainText(200, file.Content)
|
||||
}
|
||||
|
||||
@@ -35,7 +38,7 @@ func DownloadFile(ctx *context.Context) error {
|
||||
return ctx.NotFound("File not found")
|
||||
}
|
||||
|
||||
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
|
||||
ctx.Response().Header().Set("Content-Type", "text/plain")
|
||||
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+file.Filename)
|
||||
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
|
||||
_, err = ctx.Response().Write([]byte(file.Content))
|
||||
|
||||
@@ -5,13 +5,12 @@ 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 {
|
||||
@@ -35,7 +34,7 @@ func GistIndex(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Error fetching files", err)
|
||||
}
|
||||
|
||||
renderedFiles := render.RenderFiles(files)
|
||||
renderedFiles := render.HighlightFiles(files)
|
||||
|
||||
ctx.SetData("page", "code")
|
||||
ctx.SetData("commit", revision)
|
||||
@@ -52,7 +51,7 @@ func GistJson(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Error fetching files", err)
|
||||
}
|
||||
|
||||
renderedFiles := render.RenderFiles(files)
|
||||
renderedFiles := render.HighlightFiles(files)
|
||||
ctx.SetData("files", renderedFiles)
|
||||
|
||||
topics, err := gist.GetTopics()
|
||||
@@ -107,7 +106,7 @@ func GistJs(ctx *context.Context) error {
|
||||
return ctx.ErrorRes(500, "Error fetching files", err)
|
||||
}
|
||||
|
||||
renderedFiles := render.RenderFiles(files)
|
||||
renderedFiles := render.HighlightFiles(files)
|
||||
ctx.SetData("files", renderedFiles)
|
||||
|
||||
htmlbuf := bytes.Buffer{}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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,6 +6,10 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/thomiceli/opengist/internal/auth/ldap"
|
||||
"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"
|
||||
@@ -20,8 +24,7 @@ import (
|
||||
"github.com/thomiceli/opengist/internal/auth"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var routes = []struct {
|
||||
@@ -43,211 +46,166 @@ 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
|
||||
}
|
||||
return &route
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ldap
|
||||
ldapSuccess := false
|
||||
if ldap.Enabled() {
|
||||
if ok, err := ldap.Authenticate(userToCheckPermissions.Username, authPassword); !ok {
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("LDAP authentication error")
|
||||
}
|
||||
log.Warn().Msg("Invalid LDAP authentication attempt from " + ctx.RealIP())
|
||||
} else {
|
||||
ldapSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
// password
|
||||
if !ldapSuccess {
|
||||
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)
|
||||
}
|
||||
ldapSuccess := false
|
||||
if ldap.Enabled() {
|
||||
if ok, err := ldap.Authenticate(user.Username, authPassword); !ok {
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("LDAP authentication error")
|
||||
}
|
||||
log.Warn().Msg("Invalid LDAP authentication attempt from " + ctx.RealIP())
|
||||
} else {
|
||||
ldapSuccess = true
|
||||
}
|
||||
}
|
||||
if !ldapSuccess {
|
||||
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 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
|
||||
return ctx.NotFound("Gist not found")
|
||||
}
|
||||
|
||||
func uploadPack(ctx *context.Context) error {
|
||||
@@ -373,26 +331,6 @@ 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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -140,3 +141,22 @@ 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,13 +3,6 @@ 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"
|
||||
@@ -22,6 +15,12 @@ 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() {
|
||||
@@ -55,7 +54,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())
|
||||
|
||||
|
||||
@@ -4,6 +4,16 @@ 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"
|
||||
@@ -14,17 +24,6 @@ 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 {
|
||||
@@ -59,8 +58,23 @@ func (s *Server) setFuncMap() {
|
||||
"isMarkdown": func(i string) bool {
|
||||
return strings.ToLower(filepath.Ext(i)) == ".md"
|
||||
},
|
||||
"isJupyter": func(i string) bool {
|
||||
return strings.ToLower(filepath.Ext(i)) == ".ipynb"
|
||||
"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
|
||||
},
|
||||
"httpStatusText": http.StatusText,
|
||||
"loadedTime": func(startTime time.Time) string {
|
||||
@@ -176,16 +190,6 @@ func (s *Server) setFuncMap() {
|
||||
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,8 +29,6 @@ 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)
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
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"
|
||||
@@ -51,19 +45,7 @@ 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)
|
||||
@@ -72,106 +54,12 @@ func (s *Server) startHTTP() {
|
||||
}
|
||||
}
|
||||
|
||||
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,28 +203,49 @@ 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 {
|
||||
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
}
|
||||
|
||||
login(t, s, admin)
|
||||
@@ -235,24 +256,23 @@ 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 {
|
||||
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
}
|
||||
|
||||
login(t, s, admin)
|
||||
@@ -260,155 +280,31 @@ func TestGitOperations(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush)
|
||||
gitOperations(test.credentials, test.user, test.url, "kaguya-file.txt", 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, pushOptions string, file string) error {
|
||||
f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, file))
|
||||
func clientGitPush(url string) error {
|
||||
f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, "newfile.txt"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = f.WriteString("new file")
|
||||
_ = f.Close()
|
||||
f.Close()
|
||||
|
||||
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", file).Run()
|
||||
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", "newfile.txt").Run()
|
||||
_ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "commit", "-m", "new file").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()
|
||||
}
|
||||
err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", "origin", "master").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, 302)
|
||||
err = s.Request("POST", "/", gist2, 400)
|
||||
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("3")
|
||||
gist3db, err := db.GetGistByID("2")
|
||||
require.NoError(t, err)
|
||||
|
||||
gist3files, err := git.GetFilesOfRepository(gist3db.User.Username, gist3db.Uuid, "HEAD")
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -154,16 +153,8 @@ 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,12 +17,10 @@
|
||||
"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",
|
||||
@@ -30,7 +28,6 @@
|
||||
"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"
|
||||
@@ -1871,6 +1868,13 @@
|
||||
"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",
|
||||
@@ -2424,16 +2428,6 @@
|
||||
"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",
|
||||
@@ -2711,33 +2705,6 @@
|
||||
"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",
|
||||
@@ -2830,11 +2797,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
|
||||
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "14 || >=16.14"
|
||||
}
|
||||
},
|
||||
"node_modules/math-expression-evaluator": {
|
||||
"version": "1.4.0",
|
||||
@@ -3184,13 +3154,6 @@
|
||||
"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",
|
||||
@@ -5362,33 +5325,6 @@
|
||||
"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,12 +21,10 @@
|
||||
"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",
|
||||
@@ -34,7 +32,6 @@
|
||||
"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,11 +117,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
let deleteBtns = dom.querySelector<HTMLButtonElement>("button.delete-file");
|
||||
if (deleteBtns !== null) {
|
||||
deleteBtns.onclick = () => {
|
||||
// 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);
|
||||
}
|
||||
editorsjs.splice(editorsjs.indexOf(editor), 1);
|
||||
dom.remove();
|
||||
checkForFirstDeleteButton();
|
||||
};
|
||||
@@ -200,27 +196,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
let arr = Array.from(allEditorsdom);
|
||||
arr.forEach((el: HTMLElement) => {
|
||||
// in case we edit the gist contents
|
||||
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();
|
||||
};
|
||||
}
|
||||
}
|
||||
let currEditor = newEditor(el, el.querySelector<HTMLInputElement>(".form-filecontent")!.value);
|
||||
editorsjs.push(currEditor);
|
||||
});
|
||||
|
||||
checkForFirstDeleteButton();
|
||||
|
||||
document.getElementById("add-file")!.onclick = () => {
|
||||
const template = document.getElementById("editor-template")!;
|
||||
const newEditorDom = template.firstElementChild!.cloneNode(true) as HTMLElement;
|
||||
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();
|
||||
|
||||
// creating the new codemirror editor and append it in the editor div
|
||||
editorsjs.push(newEditor(newEditorDom));
|
||||
@@ -230,56 +220,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.querySelector<HTMLFormElement>("form#create")!.onsubmit = () => {
|
||||
let j = 0;
|
||||
document.querySelectorAll<HTMLInputElement>(".form-filecontent").forEach((el) => {
|
||||
if (j < editorsjs.length) {
|
||||
el.value = encodeURIComponent(editorsjs[j++].state.doc.toString());
|
||||
}
|
||||
document.querySelectorAll<HTMLInputElement>(".form-filecontent").forEach((e) => {
|
||||
e.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) => {
|
||||
@@ -296,22 +239,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
function checkForFirstDeleteButton() {
|
||||
// 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");
|
||||
}
|
||||
});
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
function showDeleteButton(editorDom: HTMLElement) {
|
||||
@@ -322,140 +259,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
checkForFirstDeleteButton();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
document.onsubmit = () => {
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
|
||||
// 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 assets/*.ttf assets/*.woff assets/*.woff2
|
||||
//go:embed manifest.json assets/*.js assets/*.css assets/*.svg assets/*.png
|
||||
var Files embed.FS
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
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')) {
|
||||
@@ -78,6 +75,5 @@ if (document.getElementById('gist').dataset.own) {
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".pdf").forEach((el) => {
|
||||
PDFObject.embed(el.dataset.src || "", el);
|
||||
})
|
||||
|
||||
|
||||
|
||||
15
public/ipynb.css
vendored
15
public/ipynb.css
vendored
@@ -1,15 +0,0 @@
|
||||
.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
127
public/ipynb.ts
@@ -1,127 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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,8 +1,23 @@
|
||||
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', () => {
|
||||
@@ -40,13 +55,23 @@ 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 = Math.floor(new Date(input.value).getTime() / 1000).toString();
|
||||
hiddenInput.value = dayjs(input.value).unix().toString();
|
||||
form.appendChild(hiddenInput);
|
||||
});
|
||||
return true;
|
||||
|
||||
14
public/style.css
vendored
14
public/style.css
vendored
@@ -188,18 +188,4 @@ 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,14 +1,9 @@
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ async function loginWithPasskey() {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = `${baseUrl}`;
|
||||
window.location.href = '/';
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
# Start background processes
|
||||
make watch_frontend &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
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
|
||||
trap 'kill $(jobs -p)' EXIT
|
||||
wait
|
||||
|
||||
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> {{ .gist.UpdatedAt | humanTimeDiff }} </span>
|
||||
<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>
|
||||
{{ 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>
|
||||
|
||||
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>{{ $gist.CreatedAt | humanDate }}</span></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span class="moment-timestamp-date">{{ $gist.CreatedAt }}</span></td>
|
||||
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<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>{{ $invitation.ExpiresAt | humanDate }}</span></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm"><span class="moment-timestamp-date">{{ $invitation.ExpiresAt }}</span></td>
|
||||
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<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>{{ $user.CreatedAt | humanDate }}</span></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-700 dark:text-slate-300"><span class="moment-timestamp-date">{{ $user.CreatedAt }}</span></td>
|
||||
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<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>{{.fromUser.CreatedAt | humanTimeDiff}}</span></p>
|
||||
<p class="text-sm text-slate-500">{{ .locale.Tr "gist.list.joined" }} <span class="moment-timestamp">{{.fromUser.CreatedAt}}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
|
||||
23
templates/pages/create.html
vendored
23
templates/pages/create.html
vendored
@@ -41,24 +41,6 @@
|
||||
{{ 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>
|
||||
|
||||
@@ -83,11 +65,6 @@
|
||||
{{ .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,29 +66,13 @@
|
||||
<div id="editors" class="space-y-4">
|
||||
{{ if .dto.Files }}
|
||||
{{ range .dto.Files }}
|
||||
{{ template "_editor" dict "Filename" .Filename "Content" .Content "Binary" .Binary "locale" $.locale }}
|
||||
{{ template "_editor" dict "Filename" .Filename "Content" .Content "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>
|
||||
@@ -97,11 +81,6 @@
|
||||
{{ .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>{{ $gist.CreatedAt | humanTimeDiff }}</span></p>
|
||||
<p class="text-sm text-slate-500">{{ $.locale.Tr "gist.list.forked" }} <span class="moment-timestamp">{{ $gist.CreatedAt }}</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 }}">
|
||||
|
||||
42
templates/pages/gist.html
vendored
42
templates/pages/gist.html
vendored
@@ -3,6 +3,7 @@
|
||||
{{ 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">
|
||||
@@ -21,13 +22,11 @@
|
||||
<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" />
|
||||
@@ -37,35 +36,29 @@
|
||||
|
||||
<div class="hidden gist-content">{{ $file.Content }}</div>
|
||||
</div>
|
||||
{{ if and $file.Truncated $file.MimeType.IsText }}
|
||||
{{ if $file.Truncated }}
|
||||
<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 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) }}
|
||||
{{ if and (not $csv) (isCsv $file.Filename) }}
|
||||
<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 $file.MimeType.IsText }}
|
||||
{{ if $file.MimeType.IsCSV }}
|
||||
{{ if $csv }}
|
||||
<table class="csv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ range $file.Header }}
|
||||
{{ range $csv.Header }}
|
||||
<th>{{ . }}</th>
|
||||
{{ end }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range $file.Rows }}
|
||||
{{ range $csv.Rows }}
|
||||
<tr>
|
||||
{{ range . }}
|
||||
<td>{{ . }}</td>
|
||||
@@ -75,10 +68,8 @@
|
||||
</table>
|
||||
{{ else if isMarkdown $file.Filename }}
|
||||
<div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div>
|
||||
{{ else if $file.MimeType.IsSVG }}
|
||||
{{ else if isSvg $file.Filename }}
|
||||
<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 }}
|
||||
@@ -93,25 +84,6 @@
|
||||
{{ 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,31 +3,26 @@
|
||||
{{ 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 and $file.Truncated $file.MimeType.IsText }}
|
||||
{{ if $file.Truncated }}
|
||||
<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 }}
|
||||
{{ 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 $file.MimeType.IsCSV }}
|
||||
{{ $csv := csvFile $file.File }}
|
||||
{{ if $csv }}
|
||||
<table class="csv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ range $file.Header }}
|
||||
{{ range $csv.Header }}
|
||||
<th>{{ . }}</th>
|
||||
{{ end }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range $file.Rows }}
|
||||
{{ range $csv.Rows }}
|
||||
<tr>
|
||||
{{ range . }}
|
||||
<td>{{ . }}</td>
|
||||
@@ -37,7 +32,7 @@
|
||||
</table>
|
||||
{{ else if isMarkdown $file.Filename }}
|
||||
<div class="chroma markdown markdown-body p-8">{{ $file.HTML | safe }}</div>
|
||||
{{ else if $file.MimeType.IsSVG }}
|
||||
{{ else if isSvg $file.Filename }}
|
||||
<div class="p-8 flex justify-center">{{ $file.HTML | safe }}</div>
|
||||
{{ else }}
|
||||
<div class="code dark:bg-gray-900">
|
||||
@@ -53,25 +48,7 @@
|
||||
{{ 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>
|
||||
|
||||
4
templates/pages/revisions.html
vendored
4
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="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>
|
||||
<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>
|
||||
{{ 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,8 +49,6 @@
|
||||
<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 "" }}
|
||||
|
||||
4
templates/pages/settings_mfa.html
vendored
4
templates/pages/settings_mfa.html
vendored
@@ -63,11 +63,11 @@
|
||||
</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>
|
||||
<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>{{ .LastUsedAt | humanTimeDiff }}</span></p>
|
||||
<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">
|
||||
|
||||
4
templates/pages/settings_ssh.html
vendored
4
templates/pages/settings_ssh.html
vendored
@@ -42,11 +42,11 @@
|
||||
<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>
|
||||
<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>{{ .LastUsedAt | humanTimeDiff }}</span></p>
|
||||
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.ssh-key-last-used" }} <span class="moment-timestamp">{{ .LastUsedAt }}</span></p>
|
||||
{{ end }}
|
||||
</div>
|
||||
<form action="{{ $.c.ExternalUrl }}/settings/ssh-keys/{{.ID}}" method="post" class="inline-block">
|
||||
|
||||
16
templates/partials/_editor.html
vendored
16
templates/partials/_editor.html
vendored
@@ -1,16 +1,15 @@
|
||||
{{ define "_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="rounded-md border border-1 border-gray-200 dark:border-gray-700 editor">
|
||||
<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 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">
|
||||
<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">
|
||||
<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" }}">
|
||||
@@ -32,15 +31,8 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
{{ end }}
|
||||
</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 }}
|
||||
<input type="hidden" value="{{ .Content }}" name="content" class="form-filecontent" autocomplete="off">
|
||||
<div class="hidden preview chroma markdown markdown-body p-8"></div>
|
||||
</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>{{ .gist.UpdatedAt | humanTimeDiff }}</span>
|
||||
<h5 class="text-sm text-slate-500 pb-1">{{ .locale.Tr "gist.list.last-active" }} <span class="moment-timestamp">{{ .gist.UpdatedAt }}</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,28 +60,24 @@
|
||||
<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 .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 break-all">{{ $line | safe }}</td>
|
||||
</tr>
|
||||
{{ $i = inc $i }}
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
{{ if isMarkdown .gist.PreviewFilename }}
|
||||
<div class="chroma preview markdown markdown-body p-8">{{ .gist.HTML | safe }}</div>
|
||||
{{ else }}
|
||||
<div class="pl-4 py-0.5 text-xs"><p>{{ .locale.Tr "gist.preview-non-available" }}</p></div>
|
||||
{{ end }}
|
||||
<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 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.no-content" }}</p></div>
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user