Compare commits

..

1 Commits

Author SHA1 Message Date
Thomas Miceli
59440faedb Specify log path 2025-06-05 11:18:03 +02:00
79 changed files with 787 additions and 2132 deletions

View File

@@ -1,50 +1,5 @@
# Changelog
## [1.11.1](https://github.com/thomiceli/opengist/compare/v1.11.0...v1.11.1) - 2025-09-30
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- More translation strings (#511)
### Fixed
- CSV errors for rendering (#514)
### Other
- Reset default log level to warn
## [1.11.0](https://github.com/thomiceli/opengist/compare/v1.10.0...v1.11.0) - 2025-09-21
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- LDAP authentication (#470)
- Listen to Unix websocket (#484)
- Binary files support (#503)
- Support for rendering .ipynb Jupyter/IPython notebooks (#491)
- File upload on gist creation/edition (#507)
- Read psql sslmode from db uri (#462)
- OIDC group claim name to OpenID request (#490)
- Reworked user settings page (#467)
- Style preference tab for user (#467)
- Init gist with regular urls via git CLI (http) (#501)
### Fixed
- Gitlab avatar (#461)
- Correct German spelling, use consistent wording (#468)
- Filename unescape (#474)
- Fix Markdown preview links (#475)
- Replace Unicode characters with HTML entity codes in embed template (#480)
- Redirect to $baseUrl after auth with passkey instead of / (#482)
- Human date on iOS devices (#510)
### Docs
- Add Proxmox VE Helper-Script (#473)
### Other
- Use Helm deployment.env[] values (#471)
- Update Helm Postgres version
- Use database for Gist init queue (#498)
- Update go dep chroma (#493)
## [1.10.0](https://github.com/thomiceli/opengist/compare/v1.9.1...v1.10.0) - 2025-04-07
See here how to [update](https://opengist.io/docs/update) Opengist.

View File

@@ -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..."

View File

@@ -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.1/opengist1.11.1-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.1-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`

View File

@@ -8,6 +8,9 @@ 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

View File

@@ -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>

View File

@@ -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). |

View File

@@ -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.1/opengist1.11.1-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.1-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`

View File

@@ -10,7 +10,7 @@ Requirements:
git clone https://github.com/thomiceli/opengist
cd opengist
git checkout v1.11.1 # optional, to checkout the latest release
git checkout v1.10.0 # optional, to checkout the latest release
make
./opengist

View File

@@ -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.1/opengist1.11.1-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.1-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`

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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"

View File

@@ -2,8 +2,8 @@ apiVersion: v2
name: opengist
description: Opengist Helm chart for Kubernetes
type: application
version: 0.4.0
appVersion: 1.11.1
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

View File

@@ -1,6 +1,6 @@
# Opengist Helm Chart
![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![AppVersion: 1.11.1](https://img.shields.io/badge/AppVersion-1.11.1-informational?style=flat-square)
![Version: 0.2.0](https://img.shields.io/badge/Version-0.2.0-informational?style=flat-square) ![AppVersion: 1.10.0](https://img.shields.io/badge/AppVersion-1.10.0-informational?style=flat-square)
Opengist Helm chart for Kubernetes.

View File

@@ -17,7 +17,7 @@ configExistingSecret: ""
image:
repository: ghcr.io/thomiceli/opengist
pullPolicy: Always
tag: "1.11.1"
tag: "1.10.0"
digest: ""
imagePullSecrets: []
# - name: "image-pull-secret"

View File

@@ -1,4 +1,4 @@
package totp
package auth
import (
"crypto/aes"

View File

@@ -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 {

View File

@@ -25,7 +25,6 @@ func (p *OIDCProvider) RegisterProvider() error {
"openid",
"email",
"profile",
config.C.OIDCGroupClaimName,
)
if err != nil {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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{})
}

View File

@@ -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), filepath.Ext(fileCat.Name)),
})
}
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), filepath.Ext(filename)),
}, 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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -1,91 +0,0 @@
package git
import (
"fmt"
"strings"
"github.com/gabriel-vasile/mimetype"
)
type MimeType struct {
ContentType string
extension string
}
func (mt MimeType) IsText() bool {
return strings.Contains(mt.ContentType, "text/")
}
func (mt MimeType) IsCSV() bool {
return strings.Contains(mt.ContentType, "text/csv") &&
(strings.HasSuffix(mt.extension, ".csv"))
}
func (mt MimeType) IsImage() bool {
return strings.Contains(mt.ContentType, "image/")
}
func (mt MimeType) IsSVG() bool {
return strings.Contains(mt.ContentType, "image/svg+xml")
}
func (mt MimeType) IsPDF() bool {
return strings.Contains(mt.ContentType, "application/pdf")
}
func (mt MimeType) IsAudio() bool {
return strings.Contains(mt.ContentType, "audio/")
}
func (mt MimeType) IsVideo() bool {
return strings.Contains(mt.ContentType, "video/")
}
func (mt MimeType) CanBeHighlighted() bool {
return mt.IsText() && !mt.IsCSV()
}
func (mt MimeType) CanBeEmbedded() bool {
return mt.IsImage() || mt.IsPDF() || mt.IsAudio() || mt.IsVideo()
}
func (mt MimeType) CanBeRendered() bool {
return mt.IsText() || mt.IsImage() || mt.IsSVG() || mt.IsPDF() || mt.IsAudio() || mt.IsVideo()
}
func (mt MimeType) CanBeEdited() bool {
return mt.IsText() || mt.IsSVG()
}
func (mt MimeType) RenderType() string {
t := strings.Split(mt.ContentType, "/")
str := ""
if len(t) == 2 {
str = fmt.Sprintf("(%s)", strings.ToUpper(t[1]))
}
// More user friendly description
if mt.IsImage() || mt.IsSVG() {
return fmt.Sprintf("Image %s", str)
}
if mt.IsAudio() {
return fmt.Sprintf("Audio %s", str)
}
if mt.IsVideo() {
return fmt.Sprintf("Video %s", str)
}
if mt.IsPDF() {
return "PDF"
}
if mt.IsCSV() {
return "CSV"
}
if mt.IsText() {
return "Text"
}
return "Binary"
}
func DetectMimeType(data []byte, extension string) MimeType {
return MimeType{mimetype.Detect(data).String(), extension}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -266,67 +266,3 @@ validation.invalid: Geçersiz %s
html.title.admin-panel: Yönetici paneli
settings.ssh-key-exists: SSH anahtarı zaten mevcut
gist.search.help.topic: Verilen konuyla ilgili gist'ler
gist.search.placeholder.unlisted: Listelenmemiş
settings.header.style: Stil
auth.mfa.passkey: Parola Anahtarı
auth.mfa.waiting-for-passkey-input: Tarayıcı etkileşiminden gelecek girdi bekleniyor...
settings.header.account: Hesap
settings.style.no-soft-wrap: Yumuşak Satır Kaydırma Yok
auth.totp: Zamana Dayalı Tek Kullanımlık Parola (TOTP)
flash.admin.sync-gist-languages: Gist dilleri senkronize ediliyor...
auth.mfa.passkeys-help: Hesabınıza giriş yapmak ve çok faktörlü kimlik doğrulama yöntemi olarak kullanmak için bir geçiş anahtarı ekleyin.
validation.invalid-gist-topics: Geçersiz gist konuları, harf veya rakamla başlamalı, 50 karakterden uzun olmamalı ve tire içerebilir.
auth.totp.enter-recovery-key: veya cihazınızı kaybettiyseniz kurtarma anahtarını kullanın
auth.totp.save-recovery-codes: Kurtarma kodlarınızı güvenli bir yerde saklayın. Bu kodları, kimlik doğrulayıcı uygulamanıza erişimi kaybetmeniz durumunda hesabınıza yeniden erişmek için kullanabilirsiniz.
error.not-in-mfa-session: Kullanıcı çok faktörlü kimlik doğrulama oturumunda değil
admin.invitations.delete_confirm: Bu daveti silmek istiyor musunuz?
auth.totp.help: TOTP, paylaşılan bir gizli anahtarı kullanarak tek kullanımlık bir parola üreten, iki faktörlü kimlik doğrulama yöntemidir.
auth.totp.use: TOTP kullan
auth.totp.regenerate-recovery-codes: Kurtarma kodlarını yeniden oluştur
auth.totp.already-enabled: TOTP zaten etkinleştirilmiş
auth.totp.invalid-secret: Geçersiz TOTP gizli anahtarı
auth.totp.invalid-code: Geçersiz TOTP kodu
auth.totp.code-used: '%s kurtarma kodu kullanıldı, artık geçersiz. Şu anda çok faktörlü kimlik doğrulamayı devre dışı bırakmak veya kodlarınızı yeniden oluşturmak isteyebilirsiniz.'
flash.auth.passkey-registred: '%s geçiş anahtarı kaydedildi'
gist.new.topics: Konular (boşluklarla ayır)
gist.list.topic-results-topic: Tüm %s konusuyla eşleşen gist'ler
gist.list.topic-results: Konuyla eşleşen tüm gist'ler
gist.search.placeholder.title: Başlık
gist.search.placeholder.visibility: Görünürlük
gist.search.placeholder.public: Halka açık
gist.search.placeholder.private: Özel
gist.search.placeholder.language: Lisan
gist.search.placeholder.all: Tümü
gist.search.placeholder.topics: Başlıklar
gist.search.placeholder.search: Ara
gist.delete.confirm: Bu Gist'i silmek istediğinizden emin misiniz?
flash.auth.passkey-deleted: Geçiş anahtarı silindi
settings.header.mfa: ÇFKD
settings.header.ssh: SSH
settings.style.gist-code: Gist kodu
settings.style.soft-wrap: Yumuşak Satır Kaydırma
settings.style.removed-lines-color: Silinen satırların rengi
settings.style.added-lines-color: Eklenen satırların rengi
settings.style.git-lines-color: Git satırların rengi
settings.style.save-style: Stili kaydet
auth.mfa: Çok Faktörlü Kimlik Doğrulama
auth.mfa.passkeys: Parola Anahtarları
auth.mfa.use-passkey: Parola Anahtarı kullan
auth.mfa.bind-passkey: Parola Anahtarı bağla
auth.mfa.login-with-passkey: Parola Anahtarı ile Giriş yap
auth.mfa.use-passkey-to-finish: Kimlik doğrulamayı tamamlamak için bir geçiş anahtarı kullanın
auth.mfa.passkey-name: İsim
auth.mfa.delete-passkey: Sil
auth.mfa.passkey-added-at: Eklendi
auth.mfa.passkey-never-used: Hiç kullanılmadı
auth.mfa.passkey-last-used: Son kullanım
auth.mfa.delete-passkey-confirm: Geçiş Anahtarının silinmesini onaylayın
auth.totp.disabled: TOTP başarıyla devre dışı bırakıldı
auth.totp.disable: TOTP devre dışı bırak
auth.totp.enter-code: Kimlik Doğrulayıcı uygulamasındaki kodu girin
auth.totp.code: Kod
auth.totp.submit: Kaydet
auth.totp.proceed: Onayla
auth.totp.scan-qr-code: İki faktörlü kimlik doğrulamayı etkinleştirmek için aşağıdaki QR kodunu kimlik doğrulayıcı uygulamanızla tarayın veya aşağıdaki metni girin, ardından oluşturulan kodla onaylayın.
admin.actions.sync-gist-languages: Tüm gist dillerini senkronize et

View File

@@ -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) InternalType() string {
return "CSVFile"
}
func renderCsvFile(file *git.File) (*CSVFile, error) {
reader := csv.NewReader(strings.NewReader(file.Content))
records, err := reader.ReadAll()
if err != nil {
return nil, err
}
header := records[0]
numColumns := len(header)
for i := 1; i < len(records); i++ {
if len(records[i]) != numColumns {
return nil, fmt.Errorf("CSV file has invalid row at index %d", i)
}
}
return &CSVFile{
File: file,
Type: "CSV",
Header: header,
Rows: records[1:],
}, nil
}

View File

@@ -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) InternalType() string {
return "HighlightedFile"
}
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 {

View File

@@ -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",

View File

@@ -1,88 +0,0 @@
package render
import (
"path/filepath"
"sync"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/git"
)
type RenderedFile interface {
InternalType() string
}
type NonHighlightedFile struct {
*git.File
Type string `json:"type"`
}
func (r NonHighlightedFile) InternalType() string {
return "NonHighlightedFile"
}
func RenderFiles(files []*git.File) []RenderedFile {
const numWorkers = 10
jobs := make(chan int, numWorkers)
renderedFiles := make([]RenderedFile, len(files))
var wg sync.WaitGroup
worker := func() {
for idx := range jobs {
renderedFiles[idx] = processFile(files[idx])
}
wg.Done()
}
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker()
}
for i := range files {
jobs <- i
}
close(jobs)
wg.Wait()
return renderedFiles
}
func processFile(file *git.File) RenderedFile {
mt := file.MimeType
if mt.IsCSV() {
rendered, err := renderCsvFile(file)
if err != nil {
rendered, err := highlightFile(file)
if err != nil {
log.Error().Err(err).Msg("Error rendering gist preview for " + file.Filename)
}
return rendered
}
return rendered
} else if mt.IsText() && filepath.Ext(file.Filename) == ".md" {
rendered, err := renderMarkdownFile(file)
if err != nil {
log.Error().Err(err).Msg("Error rendering markdown file for " + file.Filename)
}
return rendered
} else if mt.IsSVG() {
rendered := renderSvgFile(file)
return rendered
} else if mt.CanBeEmbedded() {
rendered := NonHighlightedFile{File: file, Type: mt.RenderType()}
file.Content = ""
return rendered
} else if mt.CanBeRendered() {
rendered, err := highlightFile(file)
if err != nil {
log.Error().Err(err).Msg("Error rendering gist preview for " + file.Filename)
}
return rendered
} else {
rendered := NonHighlightedFile{File: file, Type: mt.RenderType()}
file.Content = ""
return rendered
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -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))

View File

@@ -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{}

View File

@@ -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",
})
}

View File

@@ -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 {

View File

@@ -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 + "\""
}

View File

@@ -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())

View File

@@ -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"))

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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);
}
});
});

View File

@@ -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

View File

@@ -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
View File

@@ -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));
}

View File

@@ -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();
});

View File

@@ -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,
};

View File

@@ -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
View File

@@ -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
View File

@@ -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';
}

View File

@@ -155,7 +155,7 @@ async function loginWithPasskey() {
}
setTimeout(() => {
window.location.href = `${baseUrl}`;
window.location.href = '/';
}, 100);
} catch (error) {
console.error('Login error:', error);

View File

@@ -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

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }}">

View File

@@ -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 eq $file.InternalType "CSVFile" }}
{{ 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 }}

View File

@@ -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> &middot; {{ $file.HumanSize }} &middot; {{ $file.Type }}</a>
<span class="float-right"><a target="_blank" href="{{ $.baseHttpUrl }}">Hosted via Opengist</a> &middot; <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 eq $file.InternalType "CSVFile" }}
{{ $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>

View File

@@ -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 "" }}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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 }}

View File

@@ -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 }}