Compare commits

...

11 Commits

Author SHA1 Message Date
Thomas Miceli 72c5696cd7 v1.15.1
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
2026-08-04 08:44:01 +02:00
Klippo 8dce010592 fix(git): stop unbounded diff content growth for large files (#787)
* fix(git): stop unbounded diff content growth for large files

parseDiffContent's per-file truncation counter was declared but never
incremented, so the byte cap on diff content never triggered resulting
in performance degradation for large files.

* Fix remaining bytes

---------

Co-authored-by: Thomas Miceli <tho.miceli@gmail.com>
2026-08-04 08:38:24 +02:00
Thomas 3b5fb8f918 Fix admin pagination with new URLs (#782)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
2026-07-31 22:43:57 +02:00
Thomas Miceli 31e3f9d3ce v1.15.0
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
2026-07-30 19:44:32 +02:00
Thomas a7b4f6df31 Improve Git dumb HTTP security (#780)
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
2026-07-30 10:21:32 +02:00
Thomas 0e14994532 Add OG home env var in Dockerfile (#779)
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Go CI / Lint (push) Has been cancelled
2026-07-30 03:27:33 +02:00
Zadkiel AHARONIAN 15e5241f19 fix(helm): bind strategy configuration to deployment (#777)
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
* fix(helm): bind strategy configuration to deployment

* fix(helm): rightly place deployment strategy

Add deployment strategy configuration to Helm template.
2026-07-28 21:55:55 +02:00
Bnyro 0f62b87733 fix(oidc): scope includes trailing whitespace if groups not configured (#775)
Go CI / Check (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Go CI / Lint (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
2026-07-27 03:56:15 +02:00
Thomas d45898b67f Update deps (#776)
* Update deps

* Tidy
2026-07-27 03:51:00 +02:00
Thomas 5ae2942bfa UI rework (#757) 2026-07-27 03:41:17 +02:00
Alexander Fortin ec429f51c6 fix: return 200 for HEAD / so uptime monitors don't fail (#773) 2026-07-27 03:32:35 +02:00
157 changed files with 5492 additions and 5219 deletions
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## [1.15.1](https://github.com/thomiceli/opengist/compare/v1.15.0...v1.15.1) - 2026-08-04
See here how to [update](https://opengist.io/docs/update) Opengist.
### Fixed
- Admin pagination with new URLs (#782)
- Stop unbounded diff content growth for large files (#787)
## [1.15.0](https://github.com/thomiceli/opengist/compare/v1.14.0...v1.15.0) - 2026-07-30
See here how to [update](https://opengist.io/docs/update) Opengist.
### Added
- UI rework (#757)
- Gist settings page, with metadata edition and danger zone (#757)
- Explore users page, with search and sorting (#757)
- Explore topics page (#757)
### Changed
- Move reserved routes under the `/-/` prefix: `/-/login`, `/-/register`, `/-/all`, `/-/search`, `/-/topics`, `/:user/-/liked`, ... (#757)
### Fixed
- Improve Git dumb HTTP security (#780)
- OIDC scope includes trailing whitespace if groups not configured (#775)
- Return 200 for `HEAD /` so uptime monitors don't fail (#773)
- Bind strategy configuration to deployment in Helm chart (#777)
- `OG_OPENGIST_HOME` env var in Dockerfile (#779)
### Other
- Update deps (#776)
## [1.14.0](https://github.com/thomiceli/opengist/compare/v1.13.1...v1.14.0) - 2026-07-21
See here how to [update](https://opengist.io/docs/update) Opengist.
+2
View File
@@ -63,6 +63,8 @@ COPY --from=build --chown=opengist:opengist /opengist/config.yml /config.yml
COPY --from=build --chown=opengist:opengist /opengist/opengist .
COPY --from=build --chown=opengist:opengist /opengist/docker ./docker
ENV OG_OPENGIST_HOME=/opengist
EXPOSE 6157 6158 2222
VOLUME /opengist
HEALTHCHECK --interval=60s --timeout=30s --start-period=15s --retries=3 CMD curl -f http://localhost:6157/healthcheck || exit 1
+1 -1
View File
@@ -100,4 +100,4 @@ update_js_deps:
update_go_deps:
@echo "Updating Go dependencies..."
@go get -u ./... && go mod tidy
@go get -u ./... && go mod tidy
+4 -4
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.14
docker pull ghcr.io/thomiceli/opengist:1.15.1
```
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.14
image: ghcr.io/thomiceli/opengist:1.15.1
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.14.0/opengist1.14.0-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.15.1/opengist1.15.1-linux-amd64.tar.gz
tar xzvf opengist1.14.0-linux-amd64.tar.gz
tar xzvf opengist1.15.1-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`
+1 -1
View File
@@ -193,7 +193,7 @@ export default defineConfig({
]
},
{
text: 'v1.14.0',
text: 'v1.15.1',
items: [
{ text: 'Changelog', link: '/changelog' },
{ text: 'Releases', link: 'https://github.com/thomiceli/opengist/releases' },
@@ -1,8 +1,8 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const version = 'v1.14'
const dockerCommand = 'docker run -p 6157:6157 -v "$HOME/.opengist:/opengist" ghcr.io/thomiceli/opengist:1.14'
const version = 'v1.15'
const dockerCommand = 'docker run -p 6157:6157 -v "$HOME/.opengist:/opengist" ghcr.io/thomiceli/opengist:1.15'
const stars = ref(null)
const formattedStars = computed(() => {
@@ -61,7 +61,7 @@ onBeforeUnmount(() => clearTimeout(timer))
rel="noopener noreferrer"
>
<span class="og-badge__dot" />
{{ version }} released: Restful API + brand new docs page
{{ version }} released: Major UI rework
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="og-badge__arrow">
<path stroke-linecap="round" stroke-linejoin="round" d="m9 6 6 6-6 6" />
</svg>
+10
View File
@@ -11,3 +11,13 @@ curl http://localhost:6157/healthcheck
```json
{"database":"ok","opengist":"ok","time":"2024-01-04T05:18:33+01:00"}
```
## `HEAD /healthcheck`
A `HEAD` request to `/healthcheck` returns the same status code as the `GET`
request above, but without a body. This is handy for uptime monitors and load
balancers that probe the endpoint with `curl -I`:
```shell
curl -I http://localhost:6157/healthcheck
```
+2 -2
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.14.0/opengist1.14.0-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.15.1/opengist1.15.1-linux-amd64.tar.gz
tar xzvf opengist1.14.0-linux-amd64.tar.gz
tar xzvf opengist1.15.1-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`
+1 -1
View File
@@ -10,7 +10,7 @@ Requirements:
git clone https://github.com/thomiceli/opengist
cd opengist
git checkout v1.14.0
git checkout v1.15.1
make
./opengist
+391 -1306
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -10,10 +10,10 @@
"preview": "vitepress preview"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"tailwindcss": "^4.2.4",
"vitepress": "^2.0.0-alpha.17",
"vue": "^3.5.27",
"@tailwindcss/vite": "^4.3.3",
"tailwindcss": "^4.3.3",
"vitepress": "^2.0.0-alpha.18",
"vue": "^3.5.40",
"yaml": "^2.9.0"
}
}
+2 -2
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.14.0/opengist1.14.0-linux-amd64.tar.gz
wget https://github.com/thomiceli/opengist/releases/download/v1.15.1/opengist1.15.1-linux-amd64.tar.gz
tar xzvf opengist1.14.0-linux-amd64.tar.gz
tar xzvf opengist1.15.1-linux-amd64.tar.gz
cd opengist
chmod +x opengist
./opengist # with or without `--config config.yml`
+6 -6
View File
@@ -7,7 +7,7 @@ require (
github.com/alecthomas/chroma/v2 v2.27.0
github.com/blevesearch/bleve/v2 v2.6.0
github.com/dustin/go-humanize v1.0.1
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gabriel-vasile/mimetype v1.4.15
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-playground/validator/v10 v10.30.3
@@ -22,7 +22,7 @@ require (
github.com/meilisearch/meilisearch-go v0.36.3
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.24.0
github.com/prometheus/client_golang v1.24.1
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
@@ -64,7 +64,7 @@ require (
github.com/blevesearch/zapx/v14 v14.4.3 // indirect
github.com/blevesearch/zapx/v15 v15.4.3 // indirect
github.com/blevesearch/zapx/v16 v16.3.4 // indirect
github.com/blevesearch/zapx/v17 v17.2.0 // indirect
github.com/blevesearch/zapx/v17 v17.2.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
@@ -96,8 +96,8 @@ require (
github.com/labstack/gommon v0.5.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
@@ -106,7 +106,7 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
+14 -14
View File
@@ -58,8 +58,8 @@ github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2l
github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0=
github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg=
github.com/blevesearch/zapx/v17 v17.2.0 h1:+4Zn/lDFqnoD2XRvwcw3K1ivwUjIQKIxST3Jr1LCcDE=
github.com/blevesearch/zapx/v17 v17.2.0/go.mod h1:qwCdOYIG1kG0V4iYxwzNMwhxZxw7IXnSRrMobR1C/S0=
github.com/blevesearch/zapx/v17 v17.2.1 h1:yIfupsQn2YlP5uKoWK8iGZ3uvxeQ2TQSNPEMnRVqPoA=
github.com/blevesearch/zapx/v17 v17.2.1/go.mod h1:qwCdOYIG1kG0V4iYxwzNMwhxZxw7IXnSRrMobR1C/S0=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -87,8 +87,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -183,8 +183,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -203,10 +203,10 @@ github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/meilisearch/meilisearch-go v0.36.3 h1:Yx1aTY5jDgtbStPVkhJTDoLnZTy5sejQSPyjfNMy6e4=
@@ -230,12 +230,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+14 -4
View File
@@ -1,18 +1,28 @@
# Helm Chart Changelog
# 0.10.0 - 2026-07-21
## 0.12.0 - 2026-08-04
- Bump Opengist image to 1.15.1
## 0.11.0 - 2026-07-30
- Bump Opengist image to 1.15.0
- Bind strategy configuration to deployment
## 0.10.0 - 2026-07-21
- Bump Opengist image to 1.14.0
# 0.9.0 - 2026-06-10
## 0.9.0 - 2026-06-10
- Bump Opengist image to 1.13.1
# 0.8.0 - 2026-06-09
## 0.8.0 - 2026-06-09
- Bump Opengist image to 1.13.0
# 0.7.0 - 2026-03-14
## 0.7.0 - 2026-03-14
- Bump Opengist image to 1.12.2
- Add environment variables and secrets to statefulset
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: opengist
description: Opengist Helm chart for Kubernetes
type: application
version: 0.10.0
appVersion: 1.14.0
version: 0.12.0
appVersion: 1.15.1
home: https://opengist.io
icon: https://raw.githubusercontent.com/thomiceli/opengist/master/public/img/opengist.svg
sources:
+1 -1
View File
@@ -1,6 +1,6 @@
# Opengist Helm Chart
![Version: 0.10.0](https://img.shields.io/badge/Version-0.10.0-informational?style=flat-square) ![AppVersion: 1.14.0](https://img.shields.io/badge/AppVersion-1.14.0-informational?style=flat-square)
![Version: 0.12.0](https://img.shields.io/badge/Version-0.12.0-informational?style=flat-square) ![AppVersion: 1.15.1](https://img.shields.io/badge/AppVersion-1.15.1-informational?style=flat-square)
Opengist Helm chart for Kubernetes. Check [CHANGELOG.md](CHANGELOG.md) for release notes.
+4
View File
@@ -20,6 +20,10 @@ spec:
selector:
matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }}
{{- with .Values.strategy }}
strategy:
{{- toYaml . | nindent 4 }}
{{- end }}
template:
metadata:
annotations:
+1 -1
View File
@@ -18,7 +18,7 @@ configExistingSecret: ""
image:
repository: ghcr.io/thomiceli/opengist
pullPolicy: Always
tag: "1.14.0"
tag: "1.15.1"
digest: ""
imagePullSecrets: []
# - name: "image-pull-secret"
+10
View File
@@ -59,6 +59,15 @@ func IsRunning(actionType int) bool {
return actionType >= 0 && actionType < numActions && running[actionType].Load()
}
func IsPeriodic(actionType int) bool {
a, ok := registry[actionType]
return ok && a.spec != ""
}
func Spec(actionType int) string {
return registry[actionType].spec
}
func RunOnce(actionType int) {
a, ok := registry[actionType]
if !ok {
@@ -89,6 +98,7 @@ func RunOnce(actionType int) {
}()
log.Info().Msgf("Starting running action %d", actionType)
time.Sleep(100 * time.Millisecond)
a.run()
log.Info().Msgf("Finished running action %d", actionType)
}
+6 -4
View File
@@ -20,15 +20,17 @@ type OIDCProvider struct {
}
func (p *OIDCProvider) RegisterProvider() error {
scopes := []string{"openid", "email", "profile"}
if config.C.OIDCGroupClaimName != "" {
scopes = append(scopes, config.C.OIDCGroupClaimName)
}
oidcProvider, err := openidConnect.New(
config.C.OIDCClientKey,
config.C.OIDCSecret,
urlJoin(p.URL, "/oauth/openid-connect/callback"),
config.C.OIDCDiscoveryUrl,
"openid",
"email",
"profile",
config.C.OIDCGroupClaimName,
scopes...,
)
if err != nil {
+2 -2
View File
@@ -129,10 +129,10 @@ func TestCmdAdminCreateUser_MissingPassword(t *testing.T) {
func TestCmdAdminCreateUser_InvalidUsername(t *testing.T) {
// Reserved names must be rejected with the same rules as the web form.
err := runCreateUser(t, "--username", "login", "--password", "pw")
err := runCreateUser(t, "--username", "oauth", "--password", "pw")
require.Error(t, err)
_, fetchErr := db.GetUserByUsername("login")
_, fetchErr := db.GetUserByUsername("oauth")
require.Error(t, fetchErr, "reserved user must not be persisted")
}
+5
View File
@@ -11,6 +11,11 @@ import (
// keyed by Action (the action's identifier); LockedUntil holds the Unix
// timestamp the current lease expires at (0 = free).
type ActionLock struct {
// autoIncrement:false is required: Action holds the action's own identifier,
// and the first action (SyncReposFromFS) is 0. Without this, GORM treats the
// int primary key as auto-increment and, on Create with the zero value, lets
// the DB assign an id instead — so the action=0 row never exists and its lock
// can never be acquired.
Action int `gorm:"primaryKey;autoIncrement:false"`
LockedUntil int64
}
+154
View File
@@ -167,6 +167,94 @@ func GetAllGistsForCurrentUser(currentUserId uint, since *time.Time, offset int,
return gists, err
}
// GetAllGistsForCurrentUserFiltered is the filterable variant of
// GetAllGistsForCurrentUser used by the explore "All gists" page. It applies the
// same visibility rule (public gists plus currentUserId's own) and optionally
// narrows by title, language, visibility and topics, returning the total match
// count alongside the page of results.
func GetAllGistsForCurrentUserFiltered(currentUserId uint, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) {
var gists []*Gist
var count int64
baseQuery := db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("gists.private = 0 or gists.user_id = ?", currentUserId).
Model(&Gist{})
if title != "" {
baseQuery = baseQuery.Where("gists.title like ?", "%"+title+"%")
}
if language != "" {
baseQuery = baseQuery.Joins("join gist_languages on gists.id = gist_languages.gist_id").
Where("gist_languages.language = ?", language)
}
if visibility != "" {
baseQuery = baseQuery.Where("gists.private = ?", ParseVisibility(visibility))
}
if len(topics) > 0 {
baseQuery = baseQuery.Joins("join gist_topics on gists.id = gist_topics.gist_id").
Where("gist_topics.topic in ?", topics)
}
err := baseQuery.Count(&count).Error
if err != nil {
return nil, 0, err
}
err = baseQuery.Limit(11).
Offset(offset * 10).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, count, err
}
// GetAllGistsByTopicFiltered returns gists carrying `topic` that are visible to
// currentUserId, optionally narrowed further by the faceted filter params
// (title, language, visibility and extra topics). The path topic is always
// required; any extra topics from the filter are ANDed on top so the result
// stays scoped to the topic page.
func GetAllGistsByTopicFiltered(currentUserId uint, topic string, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) {
var gists []*Gist
var count int64
baseQuery := db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", topic).
Model(&Gist{})
if title != "" {
baseQuery = baseQuery.Where("gists.title like ?", "%"+title+"%")
}
if language != "" {
baseQuery = baseQuery.Joins("join gist_languages on gists.id = gist_languages.gist_id").
Where("gist_languages.language = ?", language)
}
if visibility != "" {
baseQuery = baseQuery.Where("gists.private = ?", ParseVisibility(visibility))
}
for _, t := range topics {
baseQuery = baseQuery.Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", t)
}
err := baseQuery.Count(&count).Error
if err != nil {
return nil, 0, err
}
err = baseQuery.Limit(11).
Offset(offset * 10).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, count, err
}
// GetAllGistsFromUserVisibleTo returns gists owned by fromUserId, filtered
// to what currentUserId is allowed to see (public always; private/unlisted
// only when currentUserId == fromUserId). Same pagination/since shape as
@@ -344,6 +432,26 @@ func CountAllGistsLikedByUser(fromUserId uint, currentUserId uint) (int64, error
return count, err
}
func likedAllStatement(currentUserId uint) *gorm.DB {
return db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("exists (select 1 from likes where likes.gist_id = gists.id)")
}
func GetAllGistsLiked(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
query := likedAllStatement(currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
}
func forkedStatement(fromUserId uint, currentUserId uint) *gorm.DB {
return db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("gists.forked_id is not null and ((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
@@ -375,6 +483,29 @@ func CountAllGistsForkedByUser(fromUserId uint, currentUserId uint) (int64, erro
return count, err
}
// forkedAllStatement scopes the explore "recently forked" feed to the source
// gists that have been forked from - i.e. gists that at least one other gist
// points to via forked_id - rather than the fork copies themselves.
func forkedAllStatement(currentUserId uint) *gorm.DB {
return db.Preload("User").Preload("Forked.User").Preload("Topics").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("exists (select 1 from gists as forks where forks.forked_id = gists.id)")
}
func GetAllGistsForked(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
query := forkedAllStatement(currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
}
// applySince narrows a gist query to rows updated at or after `since` when it
// is non-nil, matching the filter the API list queries apply. Kept separate so
// the count helpers stay in sync with their Find counterparts.
@@ -1005,6 +1136,29 @@ type VisibilityDTO struct {
Private Visibility `validate:"number,min=0,max=2" form:"private"`
}
// GistMetadataDTO carries the editable gist metadata (title, URL path,
// description, topics) without the files, so the settings page can update them
// independently of the gist content.
type GistMetadataDTO struct {
Title string `validate:"max=250" form:"title"`
Description string `validate:"max=1000" form:"description"`
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
Topics string `validate:"gisttopics" form:"topics"`
}
func (dto *GistMetadataDTO) ToExistingGist(gist *Gist) *Gist {
gist.Title = dto.Title
gist.Description = dto.Description
gist.URL = dto.URL
topics := strings.Fields(dto.Topics)
gistTopics := make([]GistTopic, 0, len(topics))
for _, topic := range topics {
gistTopics = append(gistTopics, GistTopic{Topic: topic})
}
gist.Topics = gistTopics
return gist
}
type FileDTO struct {
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
Content string
+50
View File
@@ -27,3 +27,53 @@ func GetGistLanguagesForUser(fromUserId, currentUserId uint) ([]struct {
return results, err
}
// GetGistLanguages returns the most common languages across every gist visible
// to currentUserId (public gists plus their own). Used to populate the language
// facet on the explore "All gists" filter.
func GetGistLanguages(currentUserId uint) ([]struct {
Language string
Count int64
}, error) {
var results []struct {
Language string
Count int64
}
err := db.Model(&GistLanguage{}).
Select("language, count(*) as count").
Joins("JOIN gists ON gists.id = gist_languages.gist_id").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Group("language").
Order("count DESC").
Limit(15).
Find(&results).Error
return results, err
}
// GetGistLanguagesByTopic returns the most common languages across the gists
// carrying `topic` that are visible to currentUserId. Used to populate the
// language facet on the /-/topics/{topic} filter so it only lists languages
// present among that topic's gists.
func GetGistLanguagesByTopic(currentUserId uint, topic string) ([]struct {
Language string
Count int64
}, error) {
var results []struct {
Language string
Count int64
}
err := db.Model(&GistLanguage{}).
Select("language, count(*) as count").
Joins("JOIN gists ON gists.id = gist_languages.gist_id").
Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId).
Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", topic).
Group("language").
Order("count DESC").
Limit(15).
Find(&results).Error
return results, err
}
+25
View File
@@ -4,3 +4,28 @@ type GistTopic struct {
GistID uint `gorm:"primaryKey"`
Topic string `gorm:"primaryKey;size:50"`
}
// TopicCount is a topic together with the number of gists tagged with it.
type TopicCount struct {
Topic string
Count int64
}
// GetTopicsWithCount returns topics in use, together with how many gists carry
// each, ordered from most to least used and paginated (limit is typically
// perPage+1 so callers can detect a following page). Only topics on gists
// visible to currentUserId are counted (public gists, plus the user's own
// private ones).
func GetTopicsWithCount(currentUserId uint, offset int, limit int, perPage int) ([]*TopicCount, error) {
var topics []*TopicCount
err := db.Model(&GistTopic{}).
Select("gist_topics.topic as topic, count(*) as count").
Joins("join gists on gists.id = gist_topics.gist_id").
Where("gists.private = 0 or gists.user_id = ?", currentUserId).
Group("gist_topics.topic").
Order("count desc, gist_topics.topic asc").
Limit(limit).
Offset(offset * perPage).
Find(&topics).Error
return topics, err
}
+65
View File
@@ -124,6 +124,70 @@ func GetAllUsers(offset int) ([]*User, error) {
return users, err
}
// UserWithNbGists is a user together with the number of gists they own that are
// visible to the viewer (public gists, plus the viewer's own private ones).
type UserWithNbGists struct {
*User
NbGists int64
}
// GetUsersWithGistCounts returns a paginated, sorted page of users for the
// explore "Users" list, each annotated with how many gists they own that are
// visible to currentUserId. When query is non-empty, users are filtered to those
// whose username contains it (case-insensitive). sortColumn must be a
// whitelisted column name ("username_normalized" or "created_at") and order
// "asc" or "desc"; both are validated by the caller. limit is typically
// perPage+1 so callers can detect a following page.
func GetUsersWithGistCounts(currentUserId uint, query string, offset int, limit int, perPage int, sortColumn string, order string) ([]*UserWithNbGists, error) {
var users []*User
stmt := db.Model(&User{})
if query != "" {
stmt = stmt.Where("username_normalized LIKE ?", "%"+strings.ToLower(query)+"%")
}
if err := stmt.
Order(sortColumn + " " + order).
Limit(limit).
Offset(offset * perPage).
Find(&users).Error; err != nil {
return nil, err
}
result := make([]*UserWithNbGists, 0, len(users))
if len(users) == 0 {
return result, nil
}
ids := make([]uint, len(users))
for i, u := range users {
ids[i] = u.ID
}
type countRow struct {
UserID uint
Count int64
}
var rows []countRow
if err := db.Model(&Gist{}).
Select("user_id, count(*) as count").
Where("user_id IN ?", ids).
Where("private = 0 or user_id = ?", currentUserId).
Group("user_id").
Find(&rows).Error; err != nil {
return nil, err
}
counts := make(map[uint]int64, len(rows))
for _, r := range rows {
counts[r.UserID] = r.Count
}
for _, u := range users {
result = append(result, &UserWithNbGists{User: u, NbGists: counts[u.ID]})
}
return result, nil
}
func GetUserByUsername(username string) (*User, error) {
user := new(User)
err := db.
@@ -301,6 +365,7 @@ type UserUsernameDTO struct {
type UserStyleDTO struct {
SoftWrap bool `form:"softwrap" json:"soft_wrap"`
ThemeColor string `form:"themecolor" json:"theme_color" validate:"omitempty,themecolor"`
RemovedLineColor string `form:"removedlinecolor" json:"removed_line_color" validate:"min=0,max=7"`
AddedLineColor string `form:"addedlinecolor" json:"added_line_color" validate:"min=0,max=7"`
GitLineColor string `form:"gitlinecolor" json:"git_line_color" validate:"min=0,max=7"`
+96 -1
View File
@@ -1,6 +1,7 @@
package git
import (
"bufio"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
"os"
@@ -189,7 +190,6 @@ func TestFork(t *testing.T) {
require.NoError(t, err, "Could not get files of repository")
require.Equal(t, files1, files2, "Files are not the same")
}
func TestTruncate(t *testing.T) {
@@ -229,6 +229,101 @@ func TestTruncate(t *testing.T) {
require.Equal(t, 2, len(content), "Content size is not correct")
}
func TestLogDiffTruncation(t *testing.T) {
SetupTest(t)
defer TeardownTest(t)
CommitToBare(t, "thomas", "gist1", map[string]string{
"my_file.txt": "A",
})
// Write enough lines to guarantee the diff content exceeds maxBytes
// (diffSize).
var builder strings.Builder
lineCount := diffSize/len("A\n") + 100 // comfortably past the threshold
for range lineCount {
builder.WriteString("A\n")
}
fullContent := builder.String()
CommitToBare(t, "thomas", "gist1", map[string]string{
"my_file.txt": fullContent,
})
// 11 is arbitrary but comfortably above the 2 commits we expect back;
// it just ensures GetLog isn't itself limiting the result set.
commits, err := GetLog("thomas", "gist1", "HEAD", 0, 11)
require.NoError(t, err, "Could not get log")
require.Len(t, commits, 2, "Commits count are not correct")
// Large-file commit: content must be truncated and bounded near
// maxBytes, not left to grow with the full file.
largeFile := commits[0].Files[0]
require.Len(t, commits[0].Files, 1, "Files count are not correct")
require.True(t, largeFile.Truncated, "Diff content should be truncated for a large file")
require.Less(t, len(largeFile.Content), len(fullContent),
"Truncated content must be smaller than the original — content should not grow indefinitely")
require.LessOrEqual(t, len(largeFile.Content), diffSize+len("A\n"),
"Truncated content should be bounded at approximately maxBytes, not just under some loose multiple of it")
// Small-file commit: sanity check that truncation doesn't kick in
// when it shouldn't.
smallFile := commits[1].Files[0]
require.False(t, smallFile.Truncated, "Small diff content should not be truncated")
require.Equal(t, "@@ -0,0 +1 @@\n+A\n\\ No newline at end of file\n",
smallFile.Content, "Small file content should be preserved as-is")
}
// TestParseDiffContentBudget drives parseDiffContent directly, the way parseLog
// does, with a reader sized exactly maxBytes. Going through GetLog cannot cover
// this: a line longer than the buffer comes back from ReadLine as a fragment and
// is drained without ever reaching the per-line clamp, so the clamp is only
// reachable by a long line that still fits in the buffer.
func TestParseDiffContentBudget(t *testing.T) {
const maxBytes = 64
// filler emits n bytes as 2-byte lines, leaving currFileLineCount just shy
// of the cap so the following line lands on the budget boundary.
filler := func(b *strings.Builder, n int) {
for range n / 2 {
b.WriteString("A\n")
}
}
tests := []struct {
name string
longLine int
}{
// Below the buffer size, so ReadLine returns these whole rather than as
// fragments. Lengths straddle the point where the old code set
// Truncated, which is why the flag was silently missed just under it.
{"long line just under maxBytes", maxBytes - 2},
{"long line at maxBytes-1", maxBytes - 1},
{"long line exactly maxBytes", maxBytes},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var b strings.Builder
filler(&b, maxBytes-2)
b.WriteString(strings.Repeat("B", tt.longLine) + "\n")
b.WriteString("diff --git a/x b/x\n") // ends the file's diff content
currentFile := &File{}
input := bufio.NewReaderSize(strings.NewReader(b.String()), maxBytes)
_, _, err := parseDiffContent(currentFile, maxBytes, input)
require.NoError(t, err, "Could not parse diff content")
// The trailing newline of the final line is appended after the
// budget check, so one byte of overshoot is expected.
require.LessOrEqual(t, len(currentFile.Content), maxBytes+1,
"Content must stay within the byte budget, not grow to a multiple of it")
require.True(t, currentFile.Truncated,
"Truncated must be set whenever content is clipped, otherwise the UI renders a clipped diff as complete")
})
}
}
func TestGitInitBranchNames(t *testing.T) {
SetupTest(t)
defer TeardownTest(t)
+1 -2
View File
@@ -21,8 +21,7 @@ func (mt MimeType) IsText() bool {
}
func (mt MimeType) IsCSV() bool {
return strings.HasPrefix(mt.ContentType, "text/csv") &&
(strings.HasSuffix(mt.extension, ".csv"))
return strings.HasSuffix(mt.extension, ".csv") && mt.IsText()
}
func (mt MimeType) IsImage() bool {
+3 -2
View File
@@ -340,11 +340,12 @@ func parseDiffContent(currentFile *File, maxBytes int, input *bufio.Reader) (lin
}
}
if len(line) > maxBytes {
if remaining := maxBytes - currFileLineCount; len(line) > remaining {
currentFile.Truncated = true
line = line[:maxBytes]
line = line[:remaining]
}
currentFile.Content += line + "\n"
currFileLineCount += len(line) + 1
}
}
-9
View File
@@ -289,8 +289,6 @@ admin.versions: الإصدارات
admin.ssh_keys: مفاتيح SSH
admin.stats: الإحصائيات
admin.actions: الإجراءات
admin.actions.sync-fs: مزامنة المقاطع من نظام الملفات
admin.actions.sync-db: مزامنة المقاطع من قاعدة البيانات
admin.actions.git-gc: تنفيذ جمع القمامة لجميع مستودعات Git
admin.actions.sync-previews: مزامنة معاينات جميع المقاطع
admin.actions.reset-hooks: إعادة تعيين خطافات خادم Git لجميع المستودعات
@@ -335,13 +333,6 @@ flash.admin.user-deleted: تم حذف المستخدم
flash.admin.gist-deleted: تم حذف المقطع
flash.admin.invitation-created: تم إنشاء الدعوة
flash.admin.invitation-deleted: تم حذف الدعوة
flash.admin.sync-fs: تتم مزامنة المستودعات من نظام الملفات...
flash.admin.sync-db: تتم مزامنة المستودعات من قاعدة البيانات...
flash.admin.git-gc: جارٍ تنفيذ جمع القمامة للمستودعات...
flash.admin.sync-previews: تتم مزامنة معاينات المقاطع...
flash.admin.reset-hooks: جارٍ إعادة تعيين خطافات خادم Git لجميع المستودعات...
flash.admin.index-gists: جارٍ إعادة بناء فهرس البحث...
flash.admin.sync-gist-languages: تتم مزامنة لغات المقاطع...
flash.auth.username-exists: اسم المستخدم موجود بالفعل
flash.auth.invalid-credentials: بيانات الاعتماد غير صالحة
-8
View File
@@ -155,8 +155,6 @@ admin.versions: Verze
admin.ssh_keys: SSH klíče
admin.stats: Statistiky
admin.actions: Akce
admin.actions.sync-fs: Synchronizovat gisty ze souborového systému
admin.actions.sync-db: Synchronizovat gisty z databáze
admin.actions.git-gc: Garbage collect git repozitářů
admin.id: ID
admin.user: Uživatel
@@ -201,14 +199,8 @@ flash.admin.user-deleted: ''
flash.admin.gist-deleted: ''
flash.admin.invitation-created: ''
flash.admin.invitation-deleted: ''
flash.admin.sync-fs: ''
flash.admin.sync-db: ''
flash.admin.git-gc: ''
flash.admin.sync-previews: ''
gist.new.create-a-new-gist: ''
gist.edit.edit-gist: ''
flash.admin.reset-hooks: ''
flash.admin.index-gists: ''
flash.auth.username-exists: ''
flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: ''
-9
View File
@@ -187,8 +187,6 @@ admin.versions: 'Versionen'
admin.ssh_keys: 'SSH Schlüssel'
admin.stats: 'Statistiken'
admin.actions: 'Aktionen'
admin.actions.sync-fs: 'Gists auf dem Dateisystem sychronisieren'
admin.actions.sync-db: 'Gists von der Datenbank synchronisieren'
admin.actions.git-gc: '„garbage collection“ bei allen git Repositories ausführen'
admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren'
admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories synchronisieren'
@@ -231,12 +229,6 @@ flash.admin.user-deleted: 'Benutzer wurde gelöscht'
flash.admin.gist-deleted: 'Gist wurde gelöscht'
flash.admin.invitation-created: 'Einladung wurde erstellt'
flash.admin.invitation-deleted: 'Einladung wurde gelöscht'
flash.admin.sync-fs: 'Synchronisiere Repositories vom Dateisystem...'
flash.admin.sync-db: 'Synchronisiere Repositories aus der Datenbank...'
flash.admin.git-gc: 'Sammle Repositories...'
flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...'
flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...'
flash.admin.index-gists: 'Suchindex wird neu aufgebaut...'
flash.auth.username-exists: 'Benutzername existiert bereits'
flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen'
@@ -305,7 +297,6 @@ auth.totp.save-recovery-codes: Speichere deine Wiederherstellungscodes an einem
error.not-in-mfa-session: Nutzer ist nicht in einer Zwei-Faktor-Sitzung
gist.revision.binary-file-changes: Änderungen an Binärdateien werden nicht angezeigt
error.no-file-uploaded: Keine Datei hochgeladen
flash.admin.sync-gist-languages: Gist-Sprachen werden synchronisiert …
validation.invalid-gist-topics: Ungültige Gist-Themen. Sie müssen mit einem Buchstaben oder einer Zahl beginnen, dürfen maximal 50 Zeichen lang sein und dürfen Bindestriche enthalten
gist.new.topics: Themen (durch Leerzeichen getrennt)
gist.preview-non-available: Vorschau nicht verfügbar
+40 -13
View File
@@ -6,6 +6,7 @@ gist.header.like: Like
gist.header.unlike: Unlike
gist.header.fork: Fork
gist.header.edit: Edit
gist.header.edit-files: Edit files
gist.header.delete: Delete
gist.header.archive: Archive
gist.header.unarchive: Unarchive
@@ -16,6 +17,7 @@ gist.header.last-active: Last active
gist.header.expires: Expires
gist.header.select-tab: Select a tab
gist.header.code: Code
gist.header.more-actions: More actions
gist.header.revisions: Revisions
gist.header.revision: Revision
gist.header.clone-http: Clone via %s
@@ -35,6 +37,7 @@ 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.embed.view-all-files: View all files
gist.new.new_gist: New gist
gist.new.title: Title
@@ -74,9 +77,18 @@ gist.edit.cancel: Cancel
gist.edit.save: Save
gist.delete.confirm: Are you sure you want to delete this gist ?
gist.settings.visibility-help: Control who can see and find this gist.
gist.settings.archive-help: Archived gists become read-only. You can unarchive at any time.
gist.settings.danger-zone: Danger zone
gist.settings.delete-gist: Delete this gist
gist.settings.delete-help: Once deleted, this gist and all its revisions are gone for good.
gist.list.joined: Joined
gist.list.all: All gists
gist.list.recently-liked: Recently liked
gist.list.recently-forked: Recently forked
gist.list.search-results: Search results
gist.list.search-for: Results for
gist.list.sort: Sort
gist.list.sort-by-created: created
gist.list.sort-by-updated: updated
@@ -84,19 +96,31 @@ gist.list.order-by-asc: Least recently
gist.list.order-by-desc: Recently
gist.list.select-tab: Select a tab
gist.list.liked: Liked
gist.list.likes: likes
gist.list.likes: Likes
gist.list.forked: Forked
gist.list.forked-from: Forked from
gist.list.forks: forks
gist.list.forks: Forks
gist.list.files: files
gist.list.last-active: Last active
gist.list.no-gists: No gists
gist.list.topics: Topics
gist.list.users: Users
gist.list.no-topics: No topics
gist.list.topic: Topic
gist.list.gists: Gists
gist.list.all-liked-by: All gists liked by %s
gist.list.all-forked-by: All gists forked by %s
gist.list.all-from: All gists from %s
gist.list.topic-results-topic: All gists matching topic %s
gist.list.topic-results: All gists matching topic
explore.users.no-users: No users
explore.users.search-placeholder: Search users
explore.users.sort-username-asc: Username (A → Z)
explore.users.sort-username-desc: Username (Z → A)
explore.users.sort-joined-desc: Newest
explore.users.sort-joined-asc: Oldest
gist.search.found: gists found
gist.search.no-results: No gists found
@@ -175,6 +199,7 @@ settings.change-password-help: Change your password to login to Opengist via HTT
settings.password-label-title: Password
settings.header.account: Account
settings.header.mfa: MFA
settings.header.authentication: Authentication
settings.header.ssh: SSH
settings.header.tokens: Access tokens
settings.header.style: Style
@@ -270,6 +295,7 @@ auth.totp.scan-qr-code: Scan the QR code below with your authenticator app to en
error: Error
error.go-home: Back to home
error.page-not-found: Page not found
error.bad-request: Bad request
error.signup-disabled: Signing up is disabled
@@ -287,7 +313,11 @@ error.cannot-open-file: Cannot open uploaded file
header.menu.all: All
header.menu.new: New
header.menu.topics: Topics
header.menu.users: Users
header.menu.search: Search
header.menu.recently-liked: Recently liked
header.menu.recently-forked: Recently forked
header.menu.my-gists: My gists
header.menu.liked: Liked
header.menu.admin: Admin
@@ -299,6 +329,8 @@ header.menu.light: Light
header.menu.dark: Dark
header.menu.system: System
footer.powered-by: Powered by %s
ui.feedback-new: Give feedback on the new UI
sidebar.collapse: Collapse
pagination.older: Older
pagination.newer: Newer
@@ -316,8 +348,11 @@ admin.versions: Versions
admin.ssh_keys: SSH keys
admin.stats: Stats
admin.actions: Actions
admin.actions.sync-fs: Synchronize gists from filesystem
admin.actions.sync-db: Synchronize gists from database
admin.actions.subtitle: Run maintenance tasks manually. Some tasks also run automatically on a schedule.
admin.actions.run: Run
admin.actions.running: Running
admin.actions.sync-fs: Delete gists without a repository on disk
admin.actions.sync-db: Delete repositories without a gist in the database
admin.actions.git-gc: Garbage collect all git repositories
admin.actions.sync-previews: Synchronize all gists previews
admin.actions.reset-hooks: Reset Git server hooks for all repositories
@@ -366,15 +401,6 @@ flash.admin.user-deleted: User has been deleted
flash.admin.gist-deleted: Gist has been deleted
flash.admin.invitation-created: Invitation has been created
flash.admin.invitation-deleted: Invitation has been deleted
flash.admin.sync-fs: Syncing repositories from filesystem...
flash.admin.sync-db: Syncing repositories from database...
flash.admin.git-gc: Garbage collecting repositories...
flash.admin.sync-previews: Syncing Gist previews...
flash.admin.reset-hooks: Resetting Git server hooks for all repositories...
flash.admin.index-gists: Rebuilding search index...
flash.admin.sync-gist-languages: Syncing Gist languages...
flash.admin.delete-expired-gists: Deleting expired gists...
flash.admin.sync-ssh-keys: Regenerating the authorized_keys file...
flash.auth.username-exists: Username already exists
flash.auth.invalid-credentials: Invalid credentials
@@ -392,6 +418,7 @@ flash.gist.visibility-changed: Gist visibility has been changed
flash.gist.archived: Gist has been archived
flash.gist.unarchived: Gist has been unarchived
flash.gist.deleted: Gist has been deleted
flash.gist.updated: Gist has been updated
flash.gist.fork-own-gist: Unable to fork own gists
flash.gist.forked: Gist has been forked
-8
View File
@@ -148,8 +148,6 @@ admin.versions: Versiones
admin.ssh_keys: Claves SSH
admin.stats: Estadísticas
admin.actions: Acciones
admin.actions.sync-fs: Sincronizar gists desde el sistema de archivos
admin.actions.sync-db: Sincronizar gists desde la base de datos
admin.actions.git-gc: Recolectar basura en los repositorios Git
admin.id: ID
admin.user: Usuario
@@ -226,12 +224,6 @@ flash.admin.user-deleted: 'El usuario ha sido eliminado'
flash.admin.gist-deleted: 'El gist ha sido eliminado'
flash.admin.invitation-created: 'La invitación ha sido creada'
flash.admin.invitation-deleted: 'La invitación ha sido eliminada'
flash.admin.sync-fs: 'Sincronizando repositorios desde el sistema de archivos...'
flash.admin.sync-db: 'Sincronizando repositorios desde la base de datos...'
flash.admin.git-gc: 'Recolectando basura en los repositorios...'
flash.admin.sync-previews: 'Sincronizando vistas previas de gists...'
flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...'
flash.admin.index-gists: 'Reconstruyendo índice de búsqueda...'
flash.auth.username-exists: 'El nombre de usuario ya existe'
flash.auth.invalid-credentials: 'Credenciales incorrectas'
flash.auth.account-linked-oauth: 'Cuenta vinculada a %s'
-8
View File
@@ -148,8 +148,6 @@ admin.versions: Versions
admin.ssh_keys: Clés SSH
admin.stats: Statistiques
admin.actions: Actions
admin.actions.sync-fs: Synchroniser les gists depuis le système de fichiers
admin.actions.sync-db: Synchroniser les gists depuis la base de données
admin.actions.git-gc: Nettoyage des dépôts git
admin.id: ID
admin.user: Utilisateur
@@ -226,12 +224,6 @@ flash.admin.user-deleted: 'L''utilisateur a été supprimé'
flash.admin.gist-deleted: 'Le gist a été supprimée'
flash.admin.invitation-created: 'L''invitation a été créée'
flash.admin.invitation-deleted: 'L''invitation a été supprimée'
flash.admin.sync-fs: 'Synchronisation des dépôts à partir du système de fichiers...'
flash.admin.sync-db: 'Synchronisation des dépôts à partir de la base de données...'
flash.admin.git-gc: 'Nettoyage des dépôts...'
flash.admin.sync-previews: 'Synchronisation des aperçus du Gist...'
flash.admin.reset-hooks: 'Réinitialisation des hooks du serveur Git pour tous les dépôts...'
flash.admin.index-gists: 'Reconstruction de l''index de recherche...'
flash.auth.username-exists: 'Nom d''utilisateur déjà utilisé'
flash.auth.invalid-credentials: 'Identifiants non valides'
flash.auth.account-linked-oauth: 'Compte lié à %s'
-8
View File
@@ -165,8 +165,6 @@ admin.versions: Verziók
admin.ssh_keys: SSH kulcsok
admin.stats: Statisztikák
admin.actions: Műveletek
admin.actions.sync-fs: Gistek szinkronizálása a fájlrendszerrel
admin.actions.sync-db: Gistek szinkronizálása az adatbázissal
admin.actions.git-gc: Használatlan git repository-k eltávolítása
admin.actions.sync-previews: Gist előnézetek szinkronizálása
admin.actions.reset-hooks: Git server hook-ok alaphelyzetbe állítása minden repository-nál
@@ -227,12 +225,6 @@ flash.admin.user-deleted: ''
flash.admin.gist-deleted: ''
flash.admin.invitation-created: ''
flash.admin.invitation-deleted: ''
flash.admin.sync-fs: ''
flash.admin.sync-db: ''
flash.admin.git-gc: ''
flash.admin.sync-previews: ''
flash.admin.reset-hooks: ''
flash.admin.index-gists: ''
flash.auth.username-exists: ''
flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: ''
-9
View File
@@ -186,8 +186,6 @@ admin.versions: 'Versioni'
admin.ssh_keys: 'Chiavi SSH'
admin.stats: 'Statistiche'
admin.actions: 'Azioni'
admin.actions.sync-fs: 'Sincronizza gists dal filesystem'
admin.actions.sync-db: 'Sincronizza gists dal database'
admin.actions.git-gc: 'Esegui la garbage collection da tutti i repositories'
admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists'
admin.actions.reset-hooks: 'Resetta tutti gli hook del server Git per tutti i repositories'
@@ -230,12 +228,6 @@ flash.admin.user-deleted: 'L''utente è stato eliminato'
flash.admin.gist-deleted: 'Il gist è stato eliminato'
flash.admin.invitation-created: 'L''invito è stato creato'
flash.admin.invitation-deleted: 'L''invito è stato eliminato'
flash.admin.sync-fs: 'Sincronizzando i repositories dal filesystem...'
flash.admin.sync-db: 'Sincronizzando i repositories dal database...'
flash.admin.git-gc: 'Eseguendo il garbage collector dei repositories...'
flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...'
flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...'
flash.admin.index-gists: 'Ricostruzione indice di ricerca...'
flash.auth.username-exists: 'Il nome utente esiste già'
flash.auth.invalid-credentials: 'Credenziali errate'
@@ -295,7 +287,6 @@ auth.totp: Time based one-time password (TOTP)
auth.totp.help: Il TOTP è un metodo di autenticazione a due fattori che usa una chiave segreta per generare una one-time password (OTP).
error.not-in-mfa-session: Non stai usando l'autenticazione a due fattori
admin.actions.sync-gist-languages: Sincronizza tutte le lingue dei gist
flash.admin.sync-gist-languages: Sincronizzazione delle lingue gist...
flash.auth.passkey-registred: Passkey %s registrata
flash.auth.passkey-deleted: Passkey eliminata
validation.invalid-gist-topics: 'Argomenti del gist non validi: devono iniziare con una lettera o un numero ed essere composti da al massimo 50 caratteri. Possono includere trattini'
-9
View File
@@ -236,8 +236,6 @@ admin.versions: ''
admin.ssh_keys: ''
admin.stats: ''
admin.actions: ''
admin.actions.sync-fs: ''
admin.actions.sync-db: ''
admin.actions.git-gc: ''
admin.actions.sync-previews: ''
admin.actions.reset-hooks: ''
@@ -282,13 +280,6 @@ flash.admin.user-deleted: ''
flash.admin.gist-deleted: ''
flash.admin.invitation-created: ''
flash.admin.invitation-deleted: ''
flash.admin.sync-fs: ''
flash.admin.sync-db: ''
flash.admin.git-gc: ''
flash.admin.sync-previews: ''
flash.admin.reset-hooks: ''
flash.admin.index-gists: ''
flash.admin.sync-gist-languages: ''
flash.auth.username-exists: ''
flash.auth.invalid-credentials: ''
-9
View File
@@ -366,15 +366,6 @@ flash.admin.user-deleted: 사용자가 삭제되었습니다
flash.admin.gist-deleted: Gist가 삭제되었습니다
flash.admin.invitation-created: 초대가 생성되었습니다
flash.admin.invitation-deleted: 초대가 삭제되었습니다
flash.admin.sync-fs: 파일 시스템에서 리포지토리를 동기화하는 중...
flash.admin.sync-db: 데이터베이스에서 리포지토리를 동기화하는 중...
flash.admin.git-gc: 리포지토리를 가비지 컬렉션하는 중...
flash.admin.sync-previews: Gist 미리보기를 동기화하는 중...
flash.admin.reset-hooks: 모든 리포지토리의 Git 서버 훅을 초기화하는 중...
flash.admin.index-gists: 검색 인덱스를 재구성하는 중...
flash.admin.sync-gist-languages: Gist 언어를 동기화하는 중...
flash.admin.delete-expired-gists: 만료된 gist를 삭제하는 중...
flash.admin.sync-ssh-keys: authorized_keys 파일을 재생성하는 중...
flash.auth.username-exists: 이미 존재하는 사용자 이름입니다
flash.auth.invalid-credentials: 잘못된 인증 정보입니다
-8
View File
@@ -222,8 +222,6 @@ admin.versions: 'Wersje'
admin.ssh_keys: 'Klucze SSH'
admin.stats: 'Statystyki'
admin.actions: 'Akcje'
admin.actions.sync-fs: 'Synchronizuj Gisty z systemu plików'
admin.actions.sync-db: 'Synchronizuj Gisty z bazy danych'
admin.actions.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git'
admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów'
admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytoriów'
@@ -266,12 +264,6 @@ flash.admin.user-deleted: 'Użytkownik został usunięty'
flash.admin.gist-deleted: 'Gist został usunięty'
flash.admin.invitation-created: 'Zaproszenie zostało stworzone'
flash.admin.invitation-deleted: 'Zaproszenie zostało usunięte'
flash.admin.sync-fs: 'Synchronizowanie repozytoriów z systemu plików...'
flash.admin.sync-db: 'Synchronizowanie repozytoriów z bazy danych...'
flash.admin.git-gc: 'Zbieranie śmieci w repozytoriach...'
flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...'
flash.admin.reset-hooks: 'Resetowanie hooków serwera Git dla wszystkich repozytoriów...'
flash.admin.index-gists: 'Przebudowywanie indeksu wyszukiwania...'
flash.auth.username-exists: 'Nazwa użytkownika już istnieje'
flash.auth.invalid-credentials: 'Niepoprawne dane logowania'
-8
View File
@@ -146,8 +146,6 @@ admin.versions: Versões
admin.ssh_keys: Chaves SSH
admin.stats: Estatísticas
admin.actions: Ações
admin.actions.sync-fs: Sincronizar gists do sistema de arquivos
admin.actions.sync-db: Sincronizar gists do banco de dados
admin.actions.git-gc: Coletar lixo nos repositórios Git
admin.id: ID
admin.user: Usuário
@@ -172,7 +170,6 @@ admin.gists.private: Privado
admin.gists.nb-files: Núm. de arquivos
admin.gists.nb-likes: Núm. de curtidas
admin.gists.delete_confirm: Quer excluir este gist?
flash.admin.index-gists: ''
gist.header.embed: ''
gist.header.embed-help: ''
gist.new.url: ''
@@ -228,11 +225,6 @@ flash.admin.user-deleted: ''
flash.admin.gist-deleted: ''
flash.admin.invitation-created: ''
flash.admin.invitation-deleted: ''
flash.admin.sync-fs: ''
flash.admin.sync-db: ''
flash.admin.git-gc: ''
flash.admin.sync-previews: ''
flash.admin.reset-hooks: ''
flash.auth.username-exists: ''
flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: ''
-9
View File
@@ -148,8 +148,6 @@ admin.versions: Версии
admin.ssh_keys: Ключи SSH
admin.stats: Статистика
admin.actions: Действия
admin.actions.sync-fs: Синхронизировать фрагменты из файловой системы
admin.actions.sync-db: Синхронизировать фрагменты с базой данных
admin.actions.git-gc: Сборка мусора в репозиториях Git
admin.id: ID
admin.user: Пользователь
@@ -227,12 +225,6 @@ flash.admin.user-deleted: 'Пользователь удалён'
flash.admin.gist-deleted: 'Фрагмент удалён'
flash.admin.invitation-created: 'Приглашение создано'
flash.admin.invitation-deleted: 'Приглашение удалено'
flash.admin.sync-fs: 'Выполняется синхронизация репозиториев с файловой системой…'
flash.admin.sync-db: 'Выполняется синхронизация репозиториев с базой данных…'
flash.admin.git-gc: 'Сборка мусора в репозиториях…'
flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…'
flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…'
flash.admin.index-gists: 'Перестроение поискового индекса…'
flash.auth.username-exists: 'Такое имя пользователя уже занято'
flash.auth.invalid-credentials: 'Некорректные данные для входа'
flash.auth.account-linked-oauth: 'Учётная запись связана с %s'
@@ -334,7 +326,6 @@ auth.totp.help: TOTP — это метод двухфакторной аутен
auth.totp.use: Использовать TOTP
auth.totp.regenerate-recovery-codes: Сгенерировать коды восстановления заново
auth.totp: Одноразовый пароль по времени (TOTP)
flash.admin.sync-gist-languages: Обновление языков фрагментов…
settings.token-created: Токен создан, обязательно сохраните его, повторно он показан не будет!
settings.token-last-used: Последнее использование
settings.token-no-expiration: Бессрочно
-9
View File
@@ -186,8 +186,6 @@ admin.versions: Sürümler
admin.ssh_keys: SSH anahtarları
admin.stats: İstatistikler
admin.actions: Eylemler
admin.actions.sync-fs: Gistleri dosya sisteminden senkronize et
admin.actions.sync-db: Gistleri veri tabanından senkronize et
admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle
admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et
admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla
@@ -229,12 +227,6 @@ flash.admin.user-deleted: Kullanıcı silindi
flash.admin.gist-deleted: Gist silindi
flash.admin.invitation-created: Davetiye oluşturuldu
flash.admin.invitation-deleted: Davetiye silindi
flash.admin.sync-fs: Depolar dosya sisteminden senkronize ediliyor...
flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor...
flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor...
flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor...
flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor...
flash.admin.index-gists: Arama dizini yeniden oluşturuluyor...
flash.auth.username-exists: Kullanıcı adı zaten mevcut
flash.auth.invalid-credentials: Geçersiz kimlik bilgileri
@@ -274,7 +266,6 @@ auth.mfa.waiting-for-passkey-input: Tarayıcı etkileşiminden gelecek girdi bek
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
-8
View File
@@ -187,8 +187,6 @@ admin.versions: Версії
admin.ssh_keys: Ключі SSH
admin.stats: Статистика
admin.actions: Дії
admin.actions.sync-fs: Синхронізувати gists з файлової системи
admin.actions.sync-db: Синхронізувати gists з базою даних
admin.actions.git-gc: Збір сміття з репозиторіїв Git
admin.actions.sync-previews: Синхронізувати всі gists перегляди
admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв
@@ -231,12 +229,6 @@ flash.admin.user-deleted: Користувач був видалений
flash.admin.gist-deleted: Gist був видалений
flash.admin.invitation-created: Запрошення було створено
flash.admin.invitation-deleted: Запрошення було видалено
flash.admin.sync-fs: Синхронізація репозиторіїв за файловою системою...
flash.admin.sync-db: Синхронізація репозиторіїв за базою даних...
flash.admin.git-gc: Збір сміття з репозиторіїв...
flash.admin.sync-previews: Синхронізація Gist переглядів...
flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв...
flash.admin.index-gists: Перебудова пошукового індексу...
flash.auth.username-exists: Це ім'я користувача вже існує
flash.auth.invalid-credentials: Недійсні облікові дані
-9
View File
@@ -177,8 +177,6 @@ admin.versions: 版本
admin.ssh_keys: SSH 密钥
admin.stats: 状态
admin.actions: 动作
admin.actions.sync-fs: 从文件系统同步 Gist
admin.actions.sync-db: 从数据库同步 Gist
admin.actions.git-gc: 对 Git 仓库执行垃圾回收
admin.id: ID
admin.user: 用户
@@ -257,12 +255,6 @@ flash.admin.user-deleted: '用户已被删除'
flash.admin.gist-deleted: 'Gist 已被删除'
flash.admin.invitation-created: '邀请已被创建'
flash.admin.invitation-deleted: '邀请已被删除'
flash.admin.sync-fs: '正在从文件系统同步存储库...'
flash.admin.sync-db: '正在从数据库同步存储库...'
flash.admin.git-gc: '正在进行存储库垃圾回收...'
flash.admin.sync-previews: '正在同步 Gist 预览...'
flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...'
flash.admin.index-gists: '正在重建搜索索引...'
flash.auth.username-exists: '用户名已存在'
flash.auth.invalid-credentials: '无效的凭证'
flash.auth.account-linked-oauth: '帐户已关联到 %s'
@@ -330,7 +322,6 @@ gist.new.topics: 主题(用空格分隔)
validation.invalid-gist-topics: 无效的 Gists 主题,它们必须以字母或数字开头,长度不超过50个字符,并且可以包含连字符
gist.list.topic-results-topic: '%s 与主题匹配的所有 Gists'
admin.actions.sync-gist-languages: 同步所有 gists 语言
flash.admin.sync-gist-languages: 正在同步 Gist 语言...
gist.list.topic-results: 所有匹配主题的 Gist
gist.search.help.topic: 具有给定主题的 Gists
gist.search.placeholder.title: 标题
-8
View File
@@ -157,8 +157,6 @@ admin.versions: 版本
admin.ssh_keys: SSH 金鑰
admin.stats: 統計
admin.actions: 操作
admin.actions.sync-fs: 從系統同步 Gists
admin.actions.sync-db: 從資料庫同步 Gists
admin.actions.git-gc: 清理所有的 git 儲存庫
admin.actions.sync-previews: 同步所有 Gists 預覽
admin.actions.reset-hooks: 重置 Git 伺服器所有儲存庫的 Git hooks
@@ -226,12 +224,6 @@ flash.admin.user-deleted: ''
flash.admin.gist-deleted: ''
flash.admin.invitation-created: ''
flash.admin.invitation-deleted: ''
flash.admin.sync-fs: ''
flash.admin.sync-db: ''
flash.admin.git-gc: ''
flash.admin.sync-previews: ''
flash.admin.reset-hooks: ''
flash.admin.index-gists: ''
flash.auth.username-exists: ''
flash.auth.invalid-credentials: ''
flash.auth.account-linked-oauth: ''
+25 -3
View File
@@ -23,9 +23,14 @@ func NewValidator() *OpengistValidator {
_ = v.RegisterValidation("alphanumdashunderorempty", validateAlphaNumDashUnderOrEmpty)
_ = v.RegisterValidation("gisttopics", validateGistTopics)
_ = v.RegisterValidation("expirationdate", validateExpirationDate)
_ = v.RegisterValidation("themecolor", validateThemeColor)
return &OpengistValidator{v}
}
var ThemeColors = []string{
"red", "amber", "emerald", "sky", "indigo", "purple", "neutral",
}
func (cv *OpengistValidator) Validate(i interface{}) error {
return cv.v.Struct(i)
}
@@ -69,7 +74,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
name := fl.Field().String()
restrictedNames := map[string]struct{}{}
for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} {
for _, restrictedName := range []string{"api", "assets", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} {
restrictedNames[restrictedName] = struct{}{}
}
@@ -79,7 +84,9 @@ func validateReservedKeywords(fl validator.FieldLevel) bool {
}
func validateAlphaNumDash(fl validator.FieldLevel) bool {
return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(fl.Field().String())
value := fl.Field().String()
return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(value) &&
regexp.MustCompile(`[a-zA-Z0-9]`).MatchString(value)
}
func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool {
@@ -87,13 +94,28 @@ func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool {
}
func validateAlphaNumDashUnder(fl validator.FieldLevel) bool {
return regexp.MustCompile(`^[a-zA-Z0-9-_]+$`).MatchString(fl.Field().String())
value := fl.Field().String()
return regexp.MustCompile(`^[a-zA-Z0-9-_]+$`).MatchString(value) &&
regexp.MustCompile(`[a-zA-Z0-9]`).MatchString(value)
}
func validateAlphaNumDashUnderOrEmpty(fl validator.FieldLevel) bool {
return regexp.MustCompile(`^$|^[a-zA-Z0-9-_]+$`).MatchString(fl.Field().String())
}
func validateThemeColor(fl validator.FieldLevel) bool {
color := fl.Field().String()
if color == "" {
return true
}
for _, c := range ThemeColors {
if c == color {
return true
}
}
return false
}
func validateGistTopics(fl validator.FieldLevel) bool {
topicsInput := fl.Field().String()
if topicsInput == "" {
+54
View File
@@ -0,0 +1,54 @@
package validator
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestReservedUsernames(t *testing.T) {
v := NewValidator()
for _, name := range []string{
"register",
"login",
"logout",
"settings",
"admin-panel",
"all",
"search",
} {
t.Run("allowed "+name, func(t *testing.T) {
require.NoError(t, v.Var(name, "notreserved"))
})
}
for _, name := range []string{
"assets",
"init",
"healthcheck",
"preview",
"metrics",
"mfa",
"webauthn",
"oauth",
} {
t.Run("reserved "+name, func(t *testing.T) {
require.Error(t, v.Var(name, "notreserved"))
})
}
}
func TestUsernameRequiresAlphanumericCharacter(t *testing.T) {
v := NewValidator()
for _, name := range []string{"-", "---"} {
require.Error(t, v.Var(name, "alphanumdash"))
}
for _, name := range []string{"-", "---", "_", "-_-"} {
require.Error(t, v.Var(name, "alphanumdashunder"))
}
require.NoError(t, v.Var("user-name", "alphanumdash"))
require.NoError(t, v.Var("user_name", "alphanumdashunder"))
}
+82 -27
View File
@@ -2,59 +2,114 @@ package admin
import (
"github.com/thomiceli/opengist/internal/actions"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/web/context"
)
// actionView is the template model for a single row on the admin actions page.
type actionView struct {
Path string // POST endpoint suffix under /-/admin-panel, e.g. "sync-fs"
LabelKey string // i18n key for the action's label
Running bool // currently in progress in this instance
Periodic bool // also runs automatically on a schedule
Spec string // raw schedule spec, e.g. "@every 72h"; "" if not periodic
}
// adminActions lists every action shown on the actions page, in display order.
// The Path values match the POST routes registered in the router.
var adminActions = []struct {
Type int
Path string
Key string
}{
{actions.SyncReposFromFS, "sync-fs", "admin.actions.sync-fs"},
{actions.SyncReposFromDB, "sync-db", "admin.actions.sync-db"},
{actions.GitGcRepos, "gc-repos", "admin.actions.git-gc"},
{actions.SyncGistPreviews, "sync-previews", "admin.actions.sync-previews"},
{actions.ResetHooks, "reset-hooks", "admin.actions.reset-hooks"},
{actions.IndexGists, "index-gists", "admin.actions.index-gists"},
{actions.SyncGistLanguages, "sync-languages", "admin.actions.sync-gist-languages"},
{actions.DeleteExpiredGists, "delete-expired-gists", "admin.actions.delete-expired-gists"},
{actions.SyncSSHKeys, "sync-ssh-keys", "admin.actions.sync-ssh-keys"},
}
func AdminActions(ctx *context.Context) error {
ctx.SetData("htmlTitle", ctx.TrH("admin.actions")+" - "+ctx.TrH("admin.admin_panel"))
ctx.SetData("adminHeaderPage", "actions")
manageSSHKeys := config.C.SshManagesAuthorizedKeys()
views := make([]actionView, 0, len(adminActions))
anyRunning := false
for _, a := range adminActions {
// The authorized_keys action is only relevant when Opengist manages the file.
if a.Type == actions.SyncSSHKeys && !manageSSHKeys {
continue
}
running := actions.IsRunning(a.Type)
if running {
anyRunning = true
}
views = append(views, actionView{
Path: a.Path,
LabelKey: a.Key,
Running: running,
Periodic: actions.IsPeriodic(a.Type),
Spec: actions.Spec(a.Type),
})
}
ctx.SetData("actions", views)
ctx.SetData("anyRunning", anyRunning)
// Set when arriving right after triggering a run: keeps the list polling for
// a moment even if the goroutine hasn't flipped the running flag yet.
ctx.SetData("pollNow", ctx.QueryParam("run") == "1")
return ctx.Html("admin_actions.html")
}
func AdminSyncReposFromFS(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success")
go actions.RunOnce(actions.SyncReposFromFS)
return ctx.RedirectTo("/admin-panel")
return runAdminAction(ctx, actions.SyncReposFromFS, "flash.admin.sync-fs")
}
func AdminSyncReposFromDB(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success")
go actions.RunOnce(actions.SyncReposFromDB)
return ctx.RedirectTo("/admin-panel")
return runAdminAction(ctx, actions.SyncReposFromDB, "flash.admin.sync-db")
}
func AdminGcRepos(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success")
go actions.RunOnce(actions.GitGcRepos)
return ctx.RedirectTo("/admin-panel")
return runAdminAction(ctx, actions.GitGcRepos, "flash.admin.git-gc")
}
func AdminSyncGistPreviews(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success")
go actions.RunOnce(actions.SyncGistPreviews)
return ctx.RedirectTo("/admin-panel")
return runAdminAction(ctx, actions.SyncGistPreviews, "flash.admin.sync-previews")
}
func AdminResetHooks(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success")
go actions.RunOnce(actions.ResetHooks)
return ctx.RedirectTo("/admin-panel")
return runAdminAction(ctx, actions.ResetHooks, "flash.admin.reset-hooks")
}
func AdminIndexGists(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success")
go actions.RunOnce(actions.IndexGists)
return ctx.RedirectTo("/admin-panel")
return runAdminAction(ctx, actions.IndexGists, "flash.admin.index-gists")
}
func AdminSyncGistLanguages(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-gist-languages"), "success")
go actions.RunOnce(actions.SyncGistLanguages)
return ctx.RedirectTo("/admin-panel")
return runAdminAction(ctx, actions.SyncGistLanguages, "flash.admin.sync-gist-languages")
}
func AdminDeleteExpiredGists(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.delete-expired-gists"), "success")
go actions.RunOnce(actions.DeleteExpiredGists)
return ctx.RedirectTo("/admin-panel")
return runAdminAction(ctx, actions.DeleteExpiredGists, "flash.admin.delete-expired-gists")
}
func AdminSyncSSHKeys(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-ssh-keys"), "success")
go actions.RunOnce(actions.SyncSSHKeys)
return ctx.RedirectTo("/admin-panel")
return runAdminAction(ctx, actions.SyncSSHKeys, "flash.admin.sync-ssh-keys")
}
func runAdminAction(ctx *context.Context, actionType int, legacyFlashKey string) error {
go actions.RunOnce(actionType)
if ctx.QueryParam("legacy") == "1" {
ctx.AddFlash(ctx.Tr(legacyFlashKey), "success")
return ctx.RedirectTo("/-/admin-panel")
}
return ctx.RedirectTo("/-/admin-panel/actions?run=1")
}
@@ -1,46 +0,0 @@
package admin_test
import (
"testing"
"github.com/stretchr/testify/require"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestAdminActions(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
urls := []string{
"/admin-panel/sync-fs",
"/admin-panel/sync-db",
"/admin-panel/gc-repos",
"/admin-panel/sync-previews",
"/admin-panel/reset-hooks",
"/admin-panel/index-gists",
"/admin-panel/sync-languages",
}
s.Register(t, "thomas")
s.Register(t, "nonadmin")
t.Run("NoUser", func(t *testing.T) {
for _, url := range urls {
s.Request(t, "POST", url, nil, 404)
}
})
t.Run("AdminUser", func(t *testing.T) {
s.Login(t, "thomas")
for _, url := range urls {
resp := s.Request(t, "POST", url, nil, 302)
require.Equal(t, "/admin-panel", resp.Header.Get("Location"))
}
})
t.Run("NonAdminUser", func(t *testing.T) {
s.Login(t, "nonadmin")
for _, url := range urls {
s.Request(t, "POST", url, nil, 404)
}
})
}
+8 -6
View File
@@ -44,6 +44,8 @@ func AdminIndex(ctx *context.Context) error {
}
ctx.SetData("countKeys", countKeys)
// The legacy admin dashboard renders actions on this page. Keep supplying
// their state while the new UI uses the dedicated actions page.
ctx.SetData("syncReposFromFS", actions.IsRunning(actions.SyncReposFromFS))
ctx.SetData("syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB))
ctx.SetData("gitGcRepos", actions.IsRunning(actions.GitGcRepos))
@@ -70,7 +72,7 @@ func AdminUsers(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot get users", err)
}
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "admin-panel/users", 1, nil); err != nil {
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "-/admin-panel/users", 1, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
@@ -88,7 +90,7 @@ func AdminGists(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot get gists", err)
}
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "admin-panel/gists", 1, nil); err != nil {
if err = handlers.Paginate(ctx, data, pageInt, 10, "data", "-/admin-panel/gists", 1, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
@@ -108,7 +110,7 @@ func AdminUserDelete(ctx *context.Context) error {
opengistssh.SyncAuthorizedKeysLogged()
ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success")
return ctx.RedirectTo("/admin-panel/users")
return ctx.RedirectTo("/-/admin-panel/users")
}
func AdminGistDelete(ctx *context.Context) error {
@@ -124,7 +126,7 @@ func AdminGistDelete(ctx *context.Context) error {
gist.RemoveFromIndex()
ctx.AddFlash(ctx.Tr("flash.admin.gist-deleted"), "success")
return ctx.RedirectTo("/admin-panel/gists")
return ctx.RedirectTo("/-/admin-panel/gists")
}
func AdminConfig(ctx *context.Context) error {
@@ -187,7 +189,7 @@ func AdminInvitationsCreate(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.admin.invitation-created"), "success")
return ctx.RedirectTo("/admin-panel/invitations")
return ctx.RedirectTo("/-/admin-panel/invitations")
}
func AdminInvitationsDelete(ctx *context.Context) error {
@@ -202,5 +204,5 @@ func AdminInvitationsDelete(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.admin.invitation-deleted"), "success")
return ctx.RedirectTo("/admin-panel/invitations")
return ctx.RedirectTo("/-/admin-panel/invitations")
}
+24 -23
View File
@@ -20,11 +20,12 @@ func TestAdminPages(t *testing.T) {
defer webtest.Teardown(t)
urls := []string{
"/admin-panel",
"/admin-panel/users",
"/admin-panel/gists",
"/admin-panel/invitations",
"/admin-panel/configuration",
"/-/admin-panel",
"/-/admin-panel/users",
"/-/admin-panel/gists",
"/-/admin-panel/invitations",
"/-/admin-panel/configuration",
"/-/admin-panel/actions",
}
s.Register(t, "thomas")
@@ -67,12 +68,12 @@ func TestAdminSetConfig(t *testing.T) {
s.Register(t, "nonadmin")
t.Run("NoUser", func(t *testing.T) {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
})
t.Run("NonAdminUser", func(t *testing.T) {
s.Login(t, "nonadmin")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404)
})
t.Run("AdminUser", func(t *testing.T) {
@@ -83,13 +84,13 @@ func TestAdminSetConfig(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "0", val)
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200)
val, err = db.GetSetting(setting)
require.NoError(t, err)
require.Equal(t, "1", val)
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200)
val, err = db.GetSetting(setting)
require.NoError(t, err)
@@ -110,12 +111,12 @@ func TestAdminPagination(t *testing.T) {
t.Run("Pagination", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "GET", "/admin-panel/users", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=2", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=3", nil, 404)
s.Request(t, "GET", "/admin-panel/users?page=0", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=-1", nil, 200)
s.Request(t, "GET", "/admin-panel/users?page=a", nil, 200)
s.Request(t, "GET", "/-/admin-panel/users", nil, 200)
s.Request(t, "GET", "/-/admin-panel/users?page=2", nil, 200)
s.Request(t, "GET", "/-/admin-panel/users?page=3", nil, 404)
s.Request(t, "GET", "/-/admin-panel/users?page=0", nil, 200)
s.Request(t, "GET", "/-/admin-panel/users?page=-1", nil, 200)
s.Request(t, "GET", "/-/admin-panel/users?page=a", nil, 200)
})
}
@@ -147,11 +148,11 @@ func TestAdminUserOperations(t *testing.T) {
require.NoError(t, err)
require.Equal(t, int64(2), count)
s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 404)
s.Request(t, "POST", "/-/admin-panel/users/2/delete", nil, 404)
s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 302)
s.Request(t, "POST", "/-/admin-panel/users/2/delete", nil, 302)
count, err = db.CountAll(db.User{})
require.NoError(t, err)
@@ -192,11 +193,11 @@ func TestAdminGistOperations(t *testing.T) {
_, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier()))
require.NoError(t, err)
s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 404)
s.Request(t, "POST", "/-/admin-panel/gists/1/delete", nil, 404)
s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 302)
s.Request(t, "POST", "/-/admin-panel/gists/1/delete", nil, 302)
count, err = db.CountAll(db.Gist{})
require.NoError(t, err)
@@ -217,7 +218,7 @@ func TestAdminInvitationOperations(t *testing.T) {
t.Run("Invitation", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
"nbMax": {""},
"expiredAtUnix": {""},
}, 302)
@@ -228,7 +229,7 @@ func TestAdminInvitationOperations(t *testing.T) {
require.Equal(t, uint(10), invitation1.NbMax)
require.InDelta(t, time.Now().Unix()+604800, invitation1.ExpiresAt, 10)
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
"nbMax": {"aa"},
"expiredAtUnix": {"1735722000"},
}, 302)
@@ -242,7 +243,7 @@ func TestAdminInvitationOperations(t *testing.T) {
NbMax: 10,
})
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
"nbMax": {"20"},
"expiredAtUnix": {"1735722000"},
}, 302)
@@ -260,7 +261,7 @@ func TestAdminInvitationOperations(t *testing.T) {
require.NoError(t, err)
require.Equal(t, int64(3), count)
s.Request(t, "POST", "/admin-panel/invitations/1/delete", nil, 302)
s.Request(t, "POST", "/-/admin-panel/invitations/1/delete", nil, 302)
count, err = db.CountAll(db.Invitation{})
require.NoError(t, err)
+9 -9
View File
@@ -48,7 +48,7 @@ func Oauth(ctx *context.Context) error {
provider, err := oauth.DefineProvider(providerStr, opengistUrl)
if err != nil {
ctx.AddFlash(ctx.Tr("error.oauth-unsupported"), "error")
return ctx.Redirect(302, "/login")
return ctx.Redirect(302, "/-/login")
}
if err = provider.RegisterProvider(); err != nil {
@@ -63,7 +63,7 @@ func OauthCallback(ctx *context.Context) error {
provider, err := oauth.CompleteUserAuth(ctx)
if err != nil {
ctx.AddFlash(fmt.Sprintf("%s: %s", ctx.Tr("auth.oauth.no-provider"), err.Error()), "error")
return ctx.Redirect(302, "/login")
return ctx.Redirect(302, "/-/login")
}
currUser := ctx.User
@@ -74,7 +74,7 @@ func OauthCallback(ctx *context.Context) error {
// check if this OAuth account is already linked to another user
if existingUser, err := db.GetUserByProvider(user.UserID, provider.GetProvider()); err == nil && existingUser != nil {
ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
provider.UpdateUserDB(currUser)
@@ -84,7 +84,7 @@ func OauthCallback(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", config.C.OIDCProviderName), "success")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
userDB, err := db.GetUserByProvider(user.UserID, provider.GetProvider())
@@ -92,7 +92,7 @@ func OauthCallback(ctx *context.Context) error {
if err != nil {
if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
return ctx.Redirect(302, "/-/login")
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -136,7 +136,7 @@ func OauthCallback(ctx *context.Context) error {
func OauthRegister(ctx *context.Context) error {
if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
return ctx.Redirect(302, "/-/login")
}
sess := ctx.GetSession()
@@ -154,7 +154,7 @@ func OauthRegister(ctx *context.Context) error {
func ProcessOauthRegister(ctx *context.Context) error {
if ctx.GetData("DisableSignup") == true {
ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error")
return ctx.Redirect(302, "/login")
return ctx.Redirect(302, "/-/login")
}
sess := ctx.GetSession()
@@ -292,8 +292,8 @@ func OauthUnlink(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", config.C.OIDCProviderName), "success")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
+1 -1
View File
@@ -103,6 +103,6 @@ func TestOIDCLoginPKCE(t *testing.T) {
})
defer resp.Body.Close()
require.Equal(t, "/login", resp.Request.URL.Path)
require.Equal(t, "/-/login", resp.Request.URL.Path)
})
}
+2 -2
View File
@@ -131,7 +131,7 @@ func ProcessLogin(ctx *context.Context) error {
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")
return ctx.RedirectTo("/-/login")
}
return ctx.ErrorRes(500, "Authentication system error", nil)
}
@@ -159,5 +159,5 @@ func ProcessLogin(ctx *context.Context) error {
func Logout(ctx *context.Context) error {
ctx.DeleteSession()
ctx.DeleteCsrfCookie()
return ctx.RedirectTo("/all")
return ctx.RedirectTo("/-/all")
}
+36 -31
View File
@@ -16,7 +16,7 @@ func TestRegisterPage(t *testing.T) {
s.Register(t, "thomas")
t.Run("Form", func(t *testing.T) {
s.Request(t, "GET", "/register", nil, 200)
s.Request(t, "GET", "/-/register", nil, 200)
s.TestCtxData(t, echo.Map{
"isLoginPage": false,
"disableForm": false,
@@ -26,10 +26,10 @@ func TestRegisterPage(t *testing.T) {
t.Run("FormDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "GET", "/register", nil, 200)
s.Request(t, "GET", "/-/register", nil, 200)
s.TestCtxData(t, echo.Map{
"disableSignup": true,
})
@@ -37,9 +37,9 @@ func TestRegisterPage(t *testing.T) {
t.Run("FormDisabledWithInviteCode", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
"nbMax": {"10"},
"expiredAtUnix": {""},
}, 302)
@@ -49,11 +49,11 @@ func TestRegisterPage(t *testing.T) {
s.Logout()
s.Request(t, "GET", "/register", nil, 200)
s.Request(t, "GET", "/-/register", nil, 200)
s.TestCtxData(t, echo.Map{
"disableSignup": true,
})
s.Request(t, "GET", "/register?code="+invitation.Code, nil, 200)
s.Request(t, "GET", "/-/register?code="+invitation.Code, nil, 200)
s.TestCtxData(t, echo.Map{
"disableSignup": false,
})
@@ -73,7 +73,7 @@ func TestProcessRegister(t *testing.T) {
require.True(t, user.IsAdmin)
s.Logout()
s.Request(t, "POST", "/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302)
user, err = db.GetUserByUsername("seconduser")
require.NoError(t, err)
require.False(t, user.IsAdmin)
@@ -81,27 +81,32 @@ func TestProcessRegister(t *testing.T) {
})
t.Run("DuplicateUsername", func(t *testing.T) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302)
s.Logout()
s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200)
s.Logout()
})
t.Run("InvalidUsername", func(t *testing.T) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "", Password: "password123"}, 200)
s.Request(t, "POST", "/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "", Password: "password123"}, 200)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "-", Password: "password123"}, 200)
exists, err := db.UserExists("-")
require.NoError(t, err)
require.False(t, exists)
})
t.Run("EmptyPassword", func(t *testing.T) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "newuser", Password: ""}, 200)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "newuser", Password: ""}, 200)
})
t.Run("RegisterDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "POST", "/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403)
exists, err := db.UserExists("blocked")
require.NoError(t, err)
@@ -110,8 +115,8 @@ func TestProcessRegister(t *testing.T) {
t.Run("RegisterWithInvitationCode", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "POST", "/admin-panel/invitations", url.Values{
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200)
s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{
"nbMax": {"10"},
"expiredAtUnix": {""},
}, 302)
@@ -124,7 +129,7 @@ func TestProcessRegister(t *testing.T) {
s.Logout()
s.Request(t, "POST", "/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302)
s.Request(t, "POST", "/-/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302)
user, err := db.GetUserByUsername("inviteduser")
require.NoError(t, err)
@@ -142,7 +147,7 @@ func TestLoginPage(t *testing.T) {
s.Register(t, "thomas")
t.Run("Form", func(t *testing.T) {
s.Request(t, "GET", "/login", nil, 200)
s.Request(t, "GET", "/-/login", nil, 200)
s.TestCtxData(t, echo.Map{
"isLoginPage": true,
"disableForm": false,
@@ -151,10 +156,10 @@ func TestLoginPage(t *testing.T) {
t.Run("FormDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "GET", "/login", nil, 200)
s.Request(t, "GET", "/-/login", nil, 200)
s.TestCtxData(t, echo.Map{
"disableForm": true,
})
@@ -168,7 +173,7 @@ func TestProcessLogin(t *testing.T) {
s.Register(t, "thomas")
t.Run("ValidCredentials", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302)
resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302)
require.Equal(t, "/", resp.Header.Get("Location"))
require.NotEmpty(t, s.SessionCookie)
require.Equal(t, "thomas", s.User().Username)
@@ -177,28 +182,28 @@ func TestProcessLogin(t *testing.T) {
})
t.Run("InvalidPassword", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302)
require.Equal(t, "/login", resp.Header.Get("Location"))
resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302)
require.Equal(t, "/-/login", resp.Header.Get("Location"))
require.Nil(t, s.User())
})
t.Run("NonExistentUser", func(t *testing.T) {
resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302)
require.Equal(t, "/login", resp.Header.Get("Location"))
resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302)
require.Equal(t, "/-/login", resp.Header.Get("Location"))
require.Nil(t, s.User())
})
t.Run("EmptyCredentials", func(t *testing.T) {
s.Request(t, "POST", "/login", db.UserDTO{Username: "", Password: ""}, 302)
s.Request(t, "POST", "/-/login", db.UserDTO{Username: "", Password: ""}, 302)
require.Nil(t, s.User())
})
t.Run("LoginFormDisabled", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200)
s.Logout()
s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403)
s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403)
require.Nil(t, s.User())
})
}
@@ -213,8 +218,8 @@ func TestLogout(t *testing.T) {
s.Login(t, "thomas")
require.Equal(t, "thomas", s.User().Username)
resp := s.Request(t, "GET", "/logout", nil, 302)
require.Equal(t, "/all", resp.Header.Get("Location"))
resp := s.Request(t, "GET", "/-/logout", nil, 302)
require.Equal(t, "/-/all", resp.Header.Get("Location"))
require.Nil(t, s.User())
s.Request(t, "GET", "/", nil, 302)
})
+9 -6
View File
@@ -15,7 +15,7 @@ func BeginTotp(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
} else if hasTotp {
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
return ctx.RedirectTo("/settings/mfa")
return ctx.RedirectTo("/-/settings/authentication")
}
ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string))
@@ -36,6 +36,7 @@ func BeginTotp(ctx *context.Context) error {
ctx.SetData("totpSecret", totpSecret)
ctx.SetData("totpQrcode", qrcode)
ctx.SetData("settingsHeaderPage", "authentication")
return ctx.Html("totp.html")
@@ -48,7 +49,7 @@ func FinishTotp(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot check for user MFA", err)
} else if hasTotp {
ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error")
return ctx.RedirectTo("/settings/mfa")
return ctx.RedirectTo("/-/settings/authentication")
}
dto := &db.TOTPDTO{}
@@ -58,7 +59,7 @@ func FinishTotp(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil {
ctx.AddFlash("Invalid secret", "error")
return ctx.RedirectTo("/settings/totp/generate")
return ctx.RedirectTo("/-/settings/totp/generate")
}
sess := ctx.GetSession()
@@ -70,7 +71,7 @@ func FinishTotp(ctx *context.Context) error {
if !totp.Validate(dto.Code, secret) {
ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error")
return ctx.RedirectTo("/settings/totp/generate")
return ctx.RedirectTo("/-/settings/totp/generate")
}
userTotp := &db.TOTP{
@@ -95,6 +96,7 @@ func FinishTotp(ctx *context.Context) error {
ctx.SaveSession(sess)
ctx.SetData("recoveryCodes", codes)
ctx.SetData("settingsHeaderPage", "authentication")
return ctx.Html("totp.html")
}
@@ -135,7 +137,7 @@ func AssertTotp(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning")
redirectUrl = "/settings/mfa"
redirectUrl = "/-/settings/authentication"
}
sess.Values["user"] = userId
@@ -158,7 +160,7 @@ func DisableTotp(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success")
return ctx.RedirectTo("/settings/mfa")
return ctx.RedirectTo("/-/settings/authentication")
}
func RegenerateTotpRecoveryCodes(ctx *context.Context) error {
@@ -174,5 +176,6 @@ func RegenerateTotpRecoveryCodes(ctx *context.Context) error {
}
ctx.SetData("recoveryCodes", codes)
ctx.SetData("settingsHeaderPage", "authentication")
return ctx.Html("totp.html")
}
+97 -45
View File
@@ -21,31 +21,41 @@ func AllGists(ctx *context.Context) error {
fromUserStr := ctx.Param("user")
userLogged := ctx.User
pageInt := handlers.GetPage(ctx)
mode := ctx.GetData("mode")
sort := "created"
order := "desc"
if userLogged != nil {
if style := userLogged.GetStyle(); style != nil {
if style.DefaultSort == "updated" {
sort = "updated"
}
if style.DefaultOrder == "asc" {
order = "asc"
// Some feeds (e.g. "recently liked" / "recently forked") have a fixed order:
// creation time descending, ignoring the user's default-sort style and the
// sort/order query params. The template hides the sort dropdown when sortable
// is false.
sortable := mode != "all-liked" && mode != "all-forked"
ctx.SetData("sortable", sortable)
if sortable {
if userLogged != nil {
if style := userLogged.GetStyle(); style != nil {
if style.DefaultSort == "updated" {
sort = "updated"
}
if style.DefaultOrder == "asc" {
order = "asc"
}
}
}
}
if ctx.QueryParam("sort") == "updated" {
sort = "updated"
} else if ctx.QueryParam("sort") == "created" {
sort = "created"
}
if ctx.QueryParam("sort") == "updated" {
sort = "updated"
} else if ctx.QueryParam("sort") == "created" {
sort = "created"
}
if ctx.QueryParam("order") == "asc" {
order = "asc"
} else if ctx.QueryParam("order") == "desc" {
order = "desc"
if ctx.QueryParam("order") == "asc" {
order = "asc"
} else if ctx.QueryParam("order") == "desc" {
order = "desc"
}
}
sortText := ctx.TrH("gist.list.sort-by-" + sort)
@@ -67,24 +77,50 @@ func AllGists(ctx *context.Context) error {
currentUserId = 0
}
mode := ctx.GetData("mode")
if fromUserStr == "" {
switch mode {
case "search":
ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results"))
ctx.SetData("searchQuery", ctx.QueryParam("q"))
pagination.Query = ctx.QueryParam("q")
urlPage = "search"
urlPage = "-/search"
gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "")
case "topics":
ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic")))
ctx.SetData("topic", ctx.Param("topic"))
urlPage = "topics/" + ctx.Param("topic")
gists, err = db.GetAllGistsFromSearch(currentUserId, "", pageInt-1, sort, order, ctx.Param("topic"))
urlPage = "-/topics/" + ctx.Param("topic")
if languages, err := db.GetGistLanguagesByTopic(currentUserId, ctx.Param("topic")); err != nil {
return ctx.ErrorRes(500, "Error fetching languages", err)
} else {
ctx.SetData("languages", languages)
}
title, language, visibility, topics := readGistFilters(ctx, pagination)
gists, _, err = db.GetAllGistsByTopicFiltered(currentUserId, ctx.Param("topic"), title, language, visibility, topics, pageInt-1, sort, order)
case "all":
ctx.SetData("currentPage", "all")
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all"))
urlPage = "all"
gists, err = db.GetAllGistsForCurrentUser(currentUserId, nil, pageInt-1, sort, order, 11, 10)
urlPage = "-/all"
if languages, err := db.GetGistLanguages(currentUserId); err != nil {
return ctx.ErrorRes(500, "Error fetching languages", err)
} else {
ctx.SetData("languages", languages)
}
title, language, visibility, topics := readGistFilters(ctx, pagination)
gists, _, err = db.GetAllGistsForCurrentUserFiltered(currentUserId, title, language, visibility, topics, pageInt-1, sort, order)
case "all-liked":
ctx.SetData("currentPage", "recently-liked")
ctx.SetData("htmlTitle", ctx.TrH("gist.list.recently-liked"))
urlPage = "-/liked"
gists, err = db.GetAllGistsLiked(currentUserId, nil, pageInt-1, sort, order, 11, 10)
case "all-forked":
ctx.SetData("currentPage", "recently-forked")
ctx.SetData("htmlTitle", ctx.TrH("gist.list.recently-forked"))
urlPage = "-/forked"
gists, err = db.GetAllGistsForked(currentUserId, nil, pageInt-1, sort, order, 11, 10)
}
} else {
var fromUser *db.User
@@ -99,6 +135,12 @@ func AllGists(ctx *context.Context) error {
}
ctx.SetData("fromUser", fromUser)
// Highlight "My gists" on any of the logged-in user's own tabs
// (gists, liked, forked).
if userLogged != nil && fromUserStr == userLogged.Username {
ctx.SetData("currentPage", "mine")
}
if countFromUser, err := db.CountAllGistsFromUser(fromUser.ID, currentUserId); err != nil {
return ctx.ErrorRes(500, "Error counting gists", err)
} else {
@@ -119,11 +161,11 @@ func AllGists(ctx *context.Context) error {
switch mode {
case "liked":
urlPage = fromUserStr + "/liked"
urlPage = fromUserStr + "/-/liked"
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-liked-by", fromUserStr))
gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, nil, pageInt-1, sort, order, 11, 10)
case "forked":
urlPage = fromUserStr + "/forked"
urlPage = fromUserStr + "/-/forked"
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-forked-by", fromUserStr))
gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, nil, pageInt-1, sort, order, 11, 10)
case "fromUser":
@@ -134,25 +176,7 @@ func AllGists(ctx *context.Context) error {
} else {
ctx.SetData("languages", languages)
}
title := ctx.QueryParam("title")
language := ctx.QueryParam("language")
visibility := ctx.QueryParam("visibility")
topicsStr := ctx.QueryParam("topics")
topics := strings.Fields(topicsStr)
if len(topics) > 10 {
topics = topics[:10]
}
slices.Sort(topics)
topics = slices.Compact(topics)
pagination.Title = title
pagination.Language = language
pagination.Visibility = visibility
pagination.Topics = topicsStr
ctx.SetData("title", title)
ctx.SetData("language", language)
ctx.SetData("visibility", visibility)
ctx.SetData("topics", topicsStr)
title, language, visibility, topics := readGistFilters(ctx, pagination)
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-from", fromUserStr))
gists, count, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, title, language, visibility, topics, pageInt-1, sort, order)
ctx.SetData("countFromUser", count)
@@ -178,6 +202,34 @@ func AllGists(ctx *context.Context) error {
return ctx.Html("all.html")
}
// readGistFilters pulls the faceted filter query params (title, language,
// visibility, topics) shared by the explore "all"/"topics" feeds and the
// per-user gist list, mirrors them into pagination and template data, and
// returns the parsed values.
func readGistFilters(ctx *context.Context, pagination *handlers.PaginationParams) (title, language, visibility string, topics []string) {
title = ctx.QueryParam("title")
language = ctx.QueryParam("language")
visibility = ctx.QueryParam("visibility")
topicsStr := ctx.QueryParam("topics")
topics = strings.Fields(topicsStr)
if len(topics) > 10 {
topics = topics[:10]
}
slices.Sort(topics)
topics = slices.Compact(topics)
pagination.Title = title
pagination.Language = language
pagination.Visibility = visibility
pagination.Topics = topicsStr
ctx.SetData("title", title)
ctx.SetData("language", language)
ctx.SetData("visibility", visibility)
ctx.SetData("topics", topicsStr)
return
}
// Search handles the search page for gists.
//
// It takes a query parameter "q" which is a search query in the format:
@@ -239,7 +291,7 @@ func Search(ctx *context.Context) error {
renderedGists = append(renderedGists, &rendered)
}
if err = handlers.Paginate(ctx, renderedGists, pageInt, 10, "gists", "search", 2, pagination); err != nil {
if err = handlers.Paginate(ctx, renderedGists, pageInt, 10, "gists", "-/search", 2, pagination); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
+5 -1
View File
@@ -17,6 +17,8 @@ import (
)
func Create(ctx *context.Context) error {
ctx.SetData("currentPage", "new")
ctx.SetData("dto", new(db.GistDTO))
ctx.SetData("htmlTitle", ctx.TrH("gist.new.create-a-new-gist"))
return ctx.Html("create.html")
}
@@ -139,7 +141,9 @@ func ProcessCreate(ctx *context.Context) error {
if isCreate {
gist = dto.ToGist()
gist.ExpiresAt = dto.ExpiresAtTimestamp()
} else {
} else if ctx.FormValue("_edit_metadata") == "1" {
// The legacy editor still edits files and metadata in one form. The new
// editor omits this marker because metadata has its own settings page.
gist = dto.ToExistingGist(gist)
}
+38
View File
@@ -139,3 +139,41 @@ func TestArchive(t *testing.T) {
}, 302)
})
}
func TestLegacyEditorUpdatesMetadata(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
_, original, username, identifier := s.CreateGist(t, "0")
s.Login(t, "thomas")
s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{
"_edit_metadata": {"1"},
"title": {"Updated from legacy UI"},
"description": {"Updated description"},
"url": {"legacy-url"},
"topics": {"legacy compatibility"},
"name": {"file.txt"},
"content": {"updated content"},
}, 302)
updated, err := db.GetGist(username, "legacy-url")
require.NoError(t, err)
require.Equal(t, original.ID, updated.ID)
require.Equal(t, "Updated from legacy UI", updated.Title)
require.Equal(t, "Updated description", updated.Description)
require.ElementsMatch(t, []string{"legacy", "compatibility"}, updated.TopicsSlice())
// A file-only edit from the new UI must keep metadata unchanged.
s.Request(t, "POST", "/"+username+"/legacy-url/edit", url.Values{
"name": {"file.txt"},
"content": {"new UI content"},
}, 302)
updated, err = db.GetGist(username, "legacy-url")
require.NoError(t, err)
require.Equal(t, "Updated from legacy UI", updated.Title)
require.Equal(t, "Updated description", updated.Description)
require.ElementsMatch(t, []string{"legacy", "compatibility"}, updated.TopicsSlice())
}
+1
View File
@@ -82,6 +82,7 @@ func Forks(ctx *context.Context) error {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
ctx.SetData("page", "forks")
ctx.SetData("htmlTitle", ctx.TrH("gist.forks.for", gist.Title))
ctx.SetData("revision", "HEAD")
return ctx.Html("forks.html")
+12 -2
View File
@@ -144,6 +144,13 @@ func GistJs(ctx *context.Context) error {
autoMode = false
}
// In auto mode the card design follows the OS via a prefers-color-scheme
// media query (see .theme-auto in embed.css), so it no longer depends on the
// JS class-toggle alone.
if autoMode {
ctx.SetData("themeAuto", true)
}
gist := ctx.GetData("gist").(*db.Gist)
var files []*git.File
@@ -259,7 +266,7 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string, autoMode bool
<style>
@import url(${css1});
@import url(${css2});
:host { display: block; all: initial; font-family: sans-serif; }
:host { display: block; font-family: sans-serif; font-size: 16px; line-height: 1.5; text-align: left; }
</style>
<div class="container">${content}</div>
%s;
@@ -313,7 +320,10 @@ func setGistCSP(ctx *context.Context) {
"default-src 'self'; "+
"script-src 'self' 'nonce-"+nonce+"'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
// https: (not just 'self') so remote avatars — Gravatar, GitHub/GitLab/
// Gitea/OIDC OAuth avatars — and images embedded in rendered gist
// markdown are allowed.
"img-src 'self' data: https:; "+
"font-src 'self' data:; "+
"connect-src 'self'; "+
// 'self' (not 'none') so same-origin PDF previews keep working via
+2 -2
View File
@@ -293,7 +293,7 @@ func TestGistAccess(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas")
for k, v := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
}
t.Run("Owner", func(t *testing.T) {
@@ -321,7 +321,7 @@ func TestGistAccess(t *testing.T) {
s.Login(t, "thomas")
for k := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
}
})
}
+1
View File
@@ -46,6 +46,7 @@ func Likes(ctx *context.Context) error {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
ctx.SetData("page", "likes")
ctx.SetData("htmlTitle", ctx.TrH("gist.likes.for", gist.Title))
ctx.SetData("revision", "HEAD")
return ctx.Html("likes.html")
+43
View File
@@ -0,0 +1,43 @@
package gist
import (
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context"
)
// GistSettings renders the per-gist settings page (visibility, archive, delete).
// It is owner-only (gated by the writePermission middleware). The actions
// themselves POST to the existing /visibility, /archive and /delete routes.
func GistSettings(ctx *context.Context) error {
gist := ctx.GetData("gist").(*db.Gist)
ctx.SetData("page", "settings")
ctx.SetData("htmlTitle", gist.Title)
return ctx.Html("gist_settings.html")
}
// EditMetadata updates a gist's title, URL path, description and topics without
// touching its files. Owner-only and blocked on archived gists (router guards).
func EditMetadata(ctx *context.Context) error {
gist := ctx.GetData("gist").(*db.Gist)
dto := new(db.GistMetadataDTO)
if err := ctx.Bind(dto); err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
}
if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
return ctx.RedirectTo("/" + gist.User.Username + "/" + gist.Identifier() + "/settings")
}
dto.ToExistingGist(gist)
if err := gist.Update(); err != nil {
return ctx.ErrorRes(500, "Error updating this gist", err)
}
gist.AddInIndex()
ctx.AddFlash(ctx.Tr("flash.gist.updated"), "success")
return ctx.RedirectTo("/" + gist.User.Username + "/" + gist.Identifier())
}
+32
View File
@@ -0,0 +1,32 @@
package gist
import (
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
)
// Topics lists topics in use, ordered from most to least used, each linking to
// the gists tagged with it. The list is not sortable and is paginated to 20
// topics per page.
func Topics(ctx *context.Context) error {
var currentUserId uint
if ctx.User != nil {
currentUserId = ctx.User.ID
}
pageInt := handlers.GetPage(ctx)
topics, err := db.GetTopicsWithCount(currentUserId, pageInt-1, 21, 20)
if err != nil {
return ctx.ErrorRes(500, "Error fetching topics", err)
}
if err = handlers.Paginate(ctx, topics, pageInt, 20, "topics", "-/topics", 1, nil); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
ctx.SetData("currentPage", "topics")
ctx.SetData("htmlTitle", ctx.TrH("gist.list.topics"))
return ctx.Html("topics.html")
}
+58
View File
@@ -0,0 +1,58 @@
package gist
import (
"strings"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
)
// Users lists all users for the explore "Users" page, each with their avatar,
// join date and number of visible gists. The list is paginated to 10 users per
// page and sortable by username or join date, ascending or descending.
func Users(ctx *context.Context) error {
var currentUserId uint
if ctx.User != nil {
currentUserId = ctx.User.ID
}
pageInt := handlers.GetPage(ctx)
// Resolve the sort field and order, defaulting to username ascending.
sort := "username"
sortColumn := "username_normalized"
if ctx.QueryParam("sort") == "joined" {
sort = "joined"
sortColumn = "created_at"
}
order := "asc"
if ctx.QueryParam("order") == "desc" {
order = "desc"
}
query := strings.TrimSpace(ctx.QueryParam("q"))
users, err := db.GetUsersWithGistCounts(currentUserId, query, pageInt-1, 11, 10, sortColumn, order)
if err != nil {
return ctx.ErrorRes(500, "Error fetching users", err)
}
pagination := &handlers.PaginationParams{
Sort: sort,
Order: order,
Query: query,
}
if err = handlers.Paginate(ctx, users, pageInt, 10, "users", "-/users", 1, pagination); err != nil {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
ctx.SetData("sort", sort)
ctx.SetData("order", order)
ctx.SetData("searchQuery", query)
ctx.SetData("currentPage", "users")
ctx.SetData("htmlTitle", ctx.TrH("gist.list.users"))
return ctx.Html("explore_users.html")
}
+21 -3
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
@@ -37,8 +37,13 @@ func idxFile(ctx *context.Context) error {
}
func sendFile(ctx *context.Context, contentType string) error {
gitFile := "/" + strings.Join(strings.Split(ctx.Request().URL.Path, "/")[3:], "/")
gitFile = path.Join(ctx.GetData("repositoryPath").(string), gitFile)
repoPath := ctx.GetData("repositoryPath").(string)
relPath := strings.Join(strings.Split(ctx.Request().URL.Path, "/")[3:], "/")
gitFile, err := containedPath(repoPath, relPath)
if err != nil {
return ctx.ErrorRes(404, "File not found", nil)
}
fi, err := os.Stat(gitFile)
if os.IsNotExist(err) {
return ctx.ErrorRes(404, "File not found", nil)
@@ -49,6 +54,19 @@ func sendFile(ctx *context.Context, contentType string) error {
return ctx.File(gitFile)
}
func containedPath(baseDir, relPath string) (string, error) {
base := filepath.Clean(baseDir)
joined := filepath.Clean(filepath.Join(base, relPath))
if joined == base {
return "", fmt.Errorf("path %q resolves to the repository root", relPath)
}
if !strings.HasPrefix(joined, base+string(os.PathSeparator)) {
return "", fmt.Errorf("path %q escapes repository %q", relPath, base)
}
return joined, nil
}
func noCacheHeaders(ctx *context.Context) {
ctx.Response().Header().Set("Expires", "Thu, 01 Jan 1970 00:00:00 UTC")
ctx.Response().Header().Set("Pragma", "no-cache")
+2 -2
View File
@@ -100,7 +100,7 @@ func TestGitClonePull(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
s.Login(t, "thomas")
for k, v := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200)
}
for _, ct := range tt.creds {
@@ -124,7 +124,7 @@ func TestGitClonePull(t *testing.T) {
// Reset settings
s.Login(t, "thomas")
for k := range tt.settings {
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200)
}
})
}
+12 -1
View File
@@ -1,11 +1,16 @@
package health
import (
"net/http"
"time"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"time"
)
// Healthcheck reports service health. A GET request returns the status as JSON;
// a HEAD request returns only the status code (no body), which is convenient
// for uptime monitors and load balancers probing the endpoint with `curl -I`.
func Healthcheck(ctx *context.Context) error {
// Check database connection
dbOk := "ok"
@@ -17,6 +22,12 @@ func Healthcheck(ctx *context.Context) error {
httpStatus = 503
}
// A HEAD request returns only the status code (no body), so uptime
// monitors and load balancers can probe the endpoint with `curl -I`.
if ctx.Request().Method == http.MethodHead {
return ctx.NoContent(httpStatus)
}
return ctx.JSON(httpStatus, map[string]interface{}{
"opengist": "ok",
"database": dbOk,
@@ -28,3 +28,20 @@ func TestHealthcheck(t *testing.T) {
require.NotEmpty(t, result["time"])
})
}
func TestHealthcheckHead(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
t.Run("Returns 200 without authentication", func(t *testing.T) {
// A HEAD request to "/healthcheck" should return 200 (with no body) so
// that uptime monitors and load balancers using `curl -I` do not see a
// 404.
resp := s.Request(t, "HEAD", "/healthcheck", nil, 200)
// A HEAD response must not carry a body.
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Empty(t, body)
})
}
@@ -31,7 +31,7 @@ func TestMetrics(t *testing.T) {
Topics: "",
}, 302)
s.Request(t, "POST", "/settings/ssh-keys", db.SSHKeyDTO{
s.Request(t, "POST", "/-/settings/ssh-keys", db.SSHKeyDTO{
Title: "Test SSH Key",
Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`,
}, 302)
@@ -36,7 +36,7 @@ func AccessTokensProcess(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
return ctx.RedirectTo("/settings/access-tokens")
return ctx.RedirectTo("/-/settings/access-tokens")
}
token := dto.ToAccessToken()
@@ -54,19 +54,19 @@ func AccessTokensProcess(ctx *context.Context) error {
// Show the token once to the user
ctx.AddFlash(ctx.Tr("settings.token-created"), "success")
ctx.AddFlash(plainToken, "success")
return ctx.RedirectTo("/settings/access-tokens")
return ctx.RedirectTo("/-/settings/access-tokens")
}
func AccessTokensDelete(ctx *context.Context) error {
user := ctx.User
tokenID, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
return ctx.RedirectTo("/settings/access-tokens")
return ctx.RedirectTo("/-/settings/access-tokens")
}
token, err := db.GetAccessTokenByID(uint(tokenID))
if err != nil || token.UserID != user.ID {
return ctx.RedirectTo("/settings/access-tokens")
return ctx.RedirectTo("/-/settings/access-tokens")
}
if err := token.Delete(); err != nil {
@@ -74,5 +74,5 @@ func AccessTokensDelete(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("settings.token-deleted"), "success")
return ctx.RedirectTo("/settings/access-tokens")
return ctx.RedirectTo("/-/settings/access-tokens")
}
@@ -18,17 +18,17 @@ func TestAccessTokensCRUD(t *testing.T) {
t.Run("RequiresAuth", func(t *testing.T) {
s.Logout()
s.Request(t, "GET", "/settings/access-tokens", nil, 302)
s.Request(t, "GET", "/-/settings/access-tokens", nil, 302)
})
t.Run("AccessTokensPage", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "GET", "/settings/access-tokens", nil, 200)
s.Request(t, "GET", "/-/settings/access-tokens", nil, 200)
})
t.Run("CreateReadToken", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
s.Request(t, "POST", "/-/settings/access-tokens", db.AccessTokenDTO{
Name: "test-token",
ScopeGist: db.ReadPermission,
}, 302)
@@ -44,7 +44,7 @@ func TestAccessTokensCRUD(t *testing.T) {
t.Run("CreateExpiringToken", func(t *testing.T) {
s.Login(t, "thomas")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
s.Request(t, "POST", "/-/settings/access-tokens", db.AccessTokenDTO{
Name: "expiring-token",
ScopeGist: db.ReadWritePermission,
ExpiresAt: tomorrow,
@@ -57,7 +57,7 @@ func TestAccessTokensCRUD(t *testing.T) {
t.Run("DeleteToken", func(t *testing.T) {
s.Login(t, "thomas")
s.Request(t, "DELETE", "/settings/access-tokens/1", nil, 302)
s.Request(t, "DELETE", "/-/settings/access-tokens/1", nil, 302)
tokens, err := db.GetAccessTokensByUserID(1)
require.NoError(t, err)
@@ -279,7 +279,7 @@ func TestCreateTokenWithUserScope(t *testing.T) {
s.Register(t, "thomas")
s.Login(t, "thomas")
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
s.Request(t, "POST", "/-/settings/access-tokens", db.AccessTokenDTO{
Name: "with-user",
ScopeGist: db.ReadPermission,
ScopeUser: db.ReadPermission,
@@ -311,7 +311,7 @@ func TestAccessTokenWithRequireLogin(t *testing.T) {
require.NoError(t, err)
require.NoError(t, token.Create())
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingRequireLogin}, "value": {"1"}}, 200)
s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingRequireLogin}, "value": {"1"}}, 200)
s.Logout()
headers := map[string]string{"Authorization": "Token " + plainToken}
+5 -5
View File
@@ -37,7 +37,7 @@ func EmailProcess(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.user.email-updated"), "success")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
func AccountDeleteProcess(ctx *context.Context) error {
@@ -48,7 +48,7 @@ func AccountDeleteProcess(ctx *context.Context) error {
}
opengistssh.SyncAuthorizedKeysLogged()
return ctx.RedirectTo("/all")
return ctx.RedirectTo("/-/all")
}
func UsernameProcess(ctx *context.Context) error {
@@ -61,13 +61,13 @@ func UsernameProcess(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
if !strings.EqualFold(dto.Username, user.Username) {
if exists, err := db.UserExists(dto.Username); err != nil || exists {
ctx.AddFlash(ctx.Tr("flash.auth.username-exists"), "error")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
}
@@ -90,5 +90,5 @@ func UsernameProcess(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.user.username-updated"), "success")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
+8 -5
View File
@@ -13,12 +13,12 @@ func PasskeyDelete(ctx *context.Context) error {
user := ctx.User
keyId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
passkey, err := db.GetCredentialByIDDB(uint(keyId))
if err != nil || passkey.UserID != user.ID {
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
if err := passkey.Delete(); err != nil {
@@ -26,7 +26,7 @@ func PasskeyDelete(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.auth.passkey-deleted"), "success")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
func PasswordProcess(ctx *context.Context) error {
@@ -40,7 +40,10 @@ func PasswordProcess(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
return ctx.Html("settings.html")
if ctx.FormValue("_legacy_account") == "1" {
return ctx.RedirectTo("/-/settings")
}
return ctx.RedirectTo("/-/settings/authentication")
}
password, err := passwordpkg.HashPassword(dto.Password)
@@ -54,5 +57,5 @@ func PasswordProcess(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.user.password-updated"), "success")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
+6 -6
View File
@@ -32,12 +32,12 @@ func AvatarProcess(ctx *context.Context) error {
header, err := ctx.FormFile("avatar")
if err != nil {
ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
if header.Size > maxAvatarSize {
ctx.AddFlash(ctx.Tr("flash.user.avatar-too-large"), "error")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
src, err := header.Open()
@@ -54,7 +54,7 @@ func AvatarProcess(ctx *context.Context) error {
ext, ok := allowedAvatarTypes[contentType]
if !ok {
ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
if _, err = src.Seek(0, io.SeekStart); err != nil {
@@ -85,14 +85,14 @@ func AvatarProcess(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.user.avatar-updated"), "success")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
func AvatarDelete(ctx *context.Context) error {
user := ctx.User
if !user.HasUploadedAvatar() {
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
removeAvatarFile(user)
@@ -103,7 +103,7 @@ func AvatarDelete(ctx *context.Context) error {
}
ctx.AddFlash(ctx.Tr("flash.user.avatar-deleted"), "success")
return ctx.RedirectTo("/settings")
return ctx.RedirectTo("/-/settings")
}
func removeAvatarFile(user *db.User) {
+11 -4
View File
@@ -2,6 +2,7 @@ package settings
import (
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context"
)
@@ -9,6 +10,9 @@ func UserAccount(ctx *context.Context) error {
user := ctx.User
ctx.SetData("email", user.Email)
// The legacy account page still contains the password form. The new UI
// moved it to Authentication, but keeping this data preserves old-UI
// behavior while both interfaces are available.
ctx.SetData("hasPassword", user.Password != "")
ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
ctx.SetData("settingsHeaderPage", "account")
@@ -16,7 +20,7 @@ func UserAccount(ctx *context.Context) error {
return ctx.Html("settings_account.html")
}
func UserMFA(ctx *context.Context) error {
func UserAuthentication(ctx *context.Context) error {
user := ctx.User
passkeys, err := db.GetAllCredentialsForUser(user.ID)
@@ -31,9 +35,11 @@ func UserMFA(ctx *context.Context) error {
ctx.SetData("passkeys", passkeys)
ctx.SetData("hasTotp", hasTotp)
ctx.SetData("settingsHeaderPage", "mfa")
ctx.SetData("hasPassword", user.Password != "")
ctx.SetData("disableForm", ctx.GetData("DisableLoginForm"))
ctx.SetData("settingsHeaderPage", "authentication")
ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_mfa.html")
return ctx.Html("settings_authentication.html")
}
func UserSSHKeys(ctx *context.Context) error {
@@ -52,6 +58,7 @@ func UserSSHKeys(ctx *context.Context) error {
func UserStyle(ctx *context.Context) error {
ctx.SetData("settingsHeaderPage", "style")
ctx.SetData("themeColors", validator.ThemeColors)
ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_style.html")
}
@@ -72,5 +79,5 @@ func ProcessUserStyle(ctx *context.Context) error {
}
ctx.AddFlash("Updated style", "success")
return ctx.RedirectTo("/settings/style")
return ctx.RedirectTo("/-/settings/style")
}
+7 -7
View File
@@ -21,7 +21,7 @@ func SshKeysProcess(ctx *context.Context) error {
if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
return ctx.RedirectTo("/settings/ssh")
return ctx.RedirectTo("/-/settings/ssh")
}
key := dto.ToSSHKey()
@@ -30,7 +30,7 @@ func SshKeysProcess(ctx *context.Context) error {
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
if err != nil {
ctx.AddFlash(ctx.Tr("flash.user.invalid-ssh-key"), "error")
return ctx.RedirectTo("/settings/ssh")
return ctx.RedirectTo("/-/settings/ssh")
}
key.Content = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey)))
@@ -39,7 +39,7 @@ func SshKeysProcess(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot check if SSH key exists", err)
}
ctx.AddFlash(ctx.Tr("settings.ssh-key-exists"), "error")
return ctx.RedirectTo("/settings/ssh")
return ctx.RedirectTo("/-/settings/ssh")
}
if err := key.Create(); err != nil {
@@ -48,20 +48,20 @@ func SshKeysProcess(ctx *context.Context) error {
opengistssh.SyncAuthorizedKeysLogged()
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success")
return ctx.RedirectTo("/settings/ssh")
return ctx.RedirectTo("/-/settings/ssh")
}
func SshKeysDelete(ctx *context.Context) error {
user := ctx.User
keyId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
return ctx.RedirectTo("/settings/ssh")
return ctx.RedirectTo("/-/settings/ssh")
}
key, err := db.GetSSHKeyByID(uint(keyId))
if err != nil || key.UserID != user.ID {
return ctx.RedirectTo("/settings/ssh")
return ctx.RedirectTo("/-/settings/ssh")
}
if err := key.Delete(); err != nil {
@@ -70,5 +70,5 @@ func SshKeysDelete(ctx *context.Context) error {
opengistssh.SyncAuthorizedKeysLogged()
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success")
return ctx.RedirectTo("/settings/ssh")
return ctx.RedirectTo("/-/settings/ssh")
}
+6 -6
View File
@@ -121,7 +121,7 @@ func (s *Server) errorHandler(err error, ctx echo.Context) {
if acceptJson || data["err_render"] == "json" {
renderErr = ctx.JSON(httpErr.Code, httpErr)
} else {
renderErr = ctx.Render(httpErr.Code, "error", data)
renderErr = ctx.Render(httpErr.Code, "error.html", data)
}
if renderErr != nil && !isClientGone(renderErr) {
@@ -209,7 +209,7 @@ func logged(next Handler) Handler {
if user != nil {
return next(ctx)
}
return ctx.RedirectTo("/all")
return ctx.RedirectTo("/-/all")
}
}
@@ -229,7 +229,7 @@ func inOAuthRegisterSession(next Handler) Handler {
sess := ctx.GetSession()
_, ok := sess.Values["oauthProvider"].(string)
if !ok {
return ctx.RedirectTo("/login")
return ctx.RedirectTo("/-/login")
}
return next(ctx)
}
@@ -252,7 +252,7 @@ func makeCheckRequireLogin(isSingleGistAccess bool) Middleware {
if !allow {
ctx.AddFlash(ctx.Tr("flash.auth.must-be-logged-in"), "error")
return ctx.RedirectTo("/login")
return ctx.RedirectTo("/-/login")
}
return next(ctx)
}
@@ -275,7 +275,7 @@ func checkFileUploadEnabled(next Handler) Handler {
// makeApiCheckRequireLogin is the /api/v1 counterpart of makeCheckRequireLogin:
// it enforces the instance's RequireLogin / AllowGistsWithoutLogin settings on
// anonymous gist reads, but responds with a JSON 401 instead of redirecting to
// /login. ctx.User is already resolved from the Authorization header by
// /-/login. ctx.User is already resolved from the Authorization header by
// apiBindAuth, so there is no token fallback to do here.
func makeApiCheckRequireLogin(isSingleGistAccess bool) Middleware {
return func(next Handler) Handler {
@@ -360,7 +360,7 @@ func sessionInit(next Handler) Handler {
ctx.SaveSession(sess)
ctx.User = nil
ctx.SetData("userLogged", nil)
return ctx.RedirectTo("/all")
return ctx.RedirectTo("/-/all")
}
if user != nil {
ctx.User = user
+41 -8
View File
@@ -7,6 +7,7 @@ import (
htmlpkg "html"
"html/template"
"io"
"io/fs"
"net/http"
"net/url"
"path/filepath"
@@ -29,9 +30,15 @@ import (
type Template struct {
templates *template.Template
// pages holds layout-based templates, keyed by file name (e.g. "all.html").
// Each entry is a clone of the base layout with that page's "content" block parsed in.
pages map[string]*template.Template
}
func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error {
if tmpl, ok := t.pages[name]; ok {
return tmpl.ExecuteTemplate(w, "base", data)
}
return t.templates.ExecuteTemplate(w, name, data)
}
@@ -230,33 +237,59 @@ func (s *Server) setFuncMap() {
},
}
t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html"))
base := template.Must(template.New("base").Funcs(fm).ParseFS(templates.Files, "layouts/*.html", "partials/*.html"))
pagePaths, err := fs.Glob(templates.Files, "pages/*.html")
if err != nil {
log.Fatal().Err(err).Msg("Failed to glob new page templates")
}
pages := make(map[string]*template.Template, len(pagePaths))
for _, p := range pagePaths {
cloned := template.Must(base.Clone())
pages[filepath.Base(p)] = template.Must(cloned.ParseFS(templates.Files, p))
}
customTemplates := template.Must(base.Clone())
customPattern := filepath.Join(config.GetHomeDir(), "custom", "*.html")
matches, err := filepath.Glob(customPattern)
if err != nil {
log.Fatal().Err(err).Msg("Failed to check for custom templates")
}
if len(matches) > 0 {
t, err = t.ParseGlob(customPattern)
customTemplates, err = customTemplates.ParseGlob(customPattern)
if err != nil {
log.Fatal().Err(err).Msg("Failed to parse custom templates")
}
}
s.echo.Renderer = &Template{
templates: t,
templates: customTemplates,
pages: pages,
}
}
func (s *Server) parseManifestEntries() {
file, err := public.Files.Open(".vite/manifest.json")
entries, err := parseManifest(public.Files)
if err != nil {
log.Fatal().Err(err).Msg("Failed to open manifest.json")
log.Fatal().Err(err).Msg("Failed to load manifest.json")
}
context.ManifestEntries = entries
}
func parseManifest(files fs.FS) (map[string]context.Asset, error) {
file, err := files.Open(".vite/manifest.json")
if err != nil {
return nil, err
}
defer file.Close()
byteValue, err := io.ReadAll(file)
if err != nil {
log.Fatal().Err(err).Msg("Failed to read manifest.json")
return nil, err
}
if err = gojson.Unmarshal(byteValue, &context.ManifestEntries); err != nil {
log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json")
entries := make(map[string]context.Asset)
if err = gojson.Unmarshal(byteValue, &entries); err != nil {
return nil, err
}
return entries, nil
}
+24 -14
View File
@@ -35,15 +35,18 @@ func (s *Server) registerRoutes() {
r.POST("/upload", gist.Upload, logged, checkFileUploadEnabled)
r.DELETE("/upload/:uuid", gist.DeleteUpload, logged, checkFileUploadEnabled)
// HEAD "/healthcheck" returns the health status code without a body, for
// uptime monitors and load balancers probing the endpoint (e.g. `curl -I`).
r.HEAD("/healthcheck", health.Healthcheck)
r.GET("/healthcheck", health.Healthcheck)
r.Static("/avatar", settings.AvatarsDir())
r.GET("/register", auth.Register)
r.POST("/register", auth.ProcessRegister)
r.GET("/login", auth.Login)
r.POST("/login", auth.ProcessLogin)
r.GET("/logout", auth.Logout)
r.GET("/-/register", auth.Register)
r.POST("/-/register", auth.ProcessRegister)
r.GET("/-/login", auth.Login)
r.POST("/-/login", auth.ProcessLogin)
r.GET("/-/logout", auth.Logout)
r.GET("/oauth/register", auth.OauthRegister, inOAuthRegisterSession)
r.POST("/oauth/register", auth.ProcessOauthRegister, inOAuthRegisterSession)
r.GET("/oauth/:provider", auth.Oauth)
@@ -58,11 +61,11 @@ func (s *Server) registerRoutes() {
r.GET("/mfa", auth.Mfa, inMFASession)
r.POST("/mfa/totp/assertion", auth.AssertTotp, inMFASession)
sA := r.SubGroup("/settings")
sA := r.SubGroup("/-/settings")
{
sA.Use(logged)
sA.GET("", settings.UserAccount)
sA.GET("/mfa", settings.UserMFA)
sA.GET("/authentication", settings.UserAuthentication)
sA.GET("/ssh", settings.UserSSHKeys)
sA.GET("/style", settings.UserStyle)
sA.POST("/style", settings.ProcessUserStyle)
@@ -84,7 +87,7 @@ func (s *Server) registerRoutes() {
sA.POST("/totp/regenerate", auth.RegenerateTotpRecoveryCodes)
}
sB := r.SubGroup("/admin-panel")
sB := r.SubGroup("/-/admin-panel")
{
sB.Use(adminPermission)
sB.GET("", admin.AdminIndex)
@@ -95,6 +98,7 @@ func (s *Server) registerRoutes() {
sB.GET("/invitations", admin.AdminInvitations)
sB.POST("/invitations", admin.AdminInvitationsCreate)
sB.POST("/invitations/:id/delete", admin.AdminInvitationsDelete)
sB.GET("/actions", admin.AdminActions)
sB.POST("/sync-fs", admin.AdminSyncReposFromFS)
sB.POST("/sync-db", admin.AdminSyncReposFromDB)
sB.POST("/gc-repos", admin.AdminGcRepos)
@@ -169,19 +173,23 @@ func (s *Server) registerRoutes() {
r.Any("/api/*", noRouteFoundApi)
r.GET("/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all"))
r.GET("/-/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all"))
r.GET("/-/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("all-liked"))
r.GET("/-/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("all-forked"))
r.GET("/-/topics", gist.Topics, checkRequireLogin)
r.GET("/-/users", gist.Users, checkRequireLogin)
if index.IndexEnabled() {
r.GET("/search", gist.Search, checkRequireLogin)
r.GET("/-/search", gist.Search, checkRequireLogin)
} else {
r.GET("/search", gist.AllGists, checkRequireLogin, setAllGistsMode("search"))
r.GET("/-/search", gist.AllGists, checkRequireLogin, setAllGistsMode("search"))
}
r.GET("/:user", gist.AllGists, checkRequireLogin, setAllGistsMode("fromUser"))
r.GET("/:user/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("liked"))
r.GET("/:user/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("forked"))
r.GET("/:user/-/liked", gist.AllGists, checkRequireLogin, setAllGistsMode("liked"))
r.GET("/:user/-/forked", gist.AllGists, checkRequireLogin, setAllGistsMode("forked"))
r.GET("/topics/:topic", gist.AllGists, checkRequireLogin, setAllGistsMode("topics"))
r.GET("/-/topics/:topic", gist.AllGists, checkRequireLogin, setAllGistsMode("topics"))
sC := r.SubGroup("/:user/:gistname")
{
@@ -197,6 +205,8 @@ func (s *Server) registerRoutes() {
sC.HEAD("/raw/:revision/:file", gist.RawFile)
sC.GET("/download/:revision/:file", gist.DownloadFile)
sC.HEAD("/download/:revision/:file", gist.DownloadFile)
sC.GET("/settings", gist.GistSettings, logged, writePermission)
sC.POST("/metadata", gist.EditMetadata, logged, writePermission, notArchived)
sC.GET("/edit", gist.Edit, logged, writePermission, notArchived)
sC.POST("/edit", gist.ProcessCreate, logged, writePermission, notArchived)
sC.POST("/like", gist.Like, logged)
+1 -1
View File
@@ -37,12 +37,12 @@ func NewServer(isDev bool) *Server {
}
s.registerMiddlewares()
s.setFuncMap()
s.echo.HTTPErrorHandler = s.errorHandler
if !s.dev {
s.parseManifestEntries()
}
s.setFuncMap()
s.registerRoutes()
+3 -3
View File
@@ -154,11 +154,11 @@ func (s *Server) TestCtxData(t *testing.T, expected echo.Map) {
}
func (s *Server) Register(t *testing.T, user string) {
s.Request(t, "POST", "/register", db.UserDTO{Username: user, Password: user}, 302)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: user, Password: user}, 302)
}
func (s *Server) Login(t *testing.T, user string) {
s.Request(t, "POST", "/login", db.UserDTO{Username: user, Password: user}, 302)
s.Request(t, "POST", "/-/login", db.UserDTO{Username: user, Password: user}, 302)
}
func (s *Server) Logout() {
@@ -166,7 +166,7 @@ func (s *Server) Logout() {
}
func (s *Server) CreateGist(t *testing.T, visibility string) (gistPath string, gist *db.Gist, username, identifier string) {
s.Request(t, "POST", "/register", db.UserDTO{Username: "thomas", Password: "thomas"}, 0)
s.Request(t, "POST", "/-/register", db.UserDTO{Username: "thomas", Password: "thomas"}, 0)
s.Login(t, "thomas")
resp := s.Request(t, "POST", "/", url.Values{
+31 -4
View File
@@ -18,10 +18,13 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"basecoat-css": "^0.3.11",
"codemirror": "^6.0.2",
"dompurify": "^3.4.12",
"github-markdown-css": "^5.9.0",
"highlight.js": "^11.11.1",
"htmx.org": "^2.0.10",
"hyperscript.org": "^0.9.93",
"jdenticon": "^3.3.0",
"katex": "^0.18.1",
"marked": "^18.0.6",
@@ -1451,6 +1454,13 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/basecoat-css": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/basecoat-css/-/basecoat-css-0.3.11.tgz",
"integrity": "sha512-cU91Egcg9AhKJ1HswpKFItHC//FWDt1T8AlL5Yk9Fh5I2WYe+QhN3MtiVJ2vemqFF4iAM1vfbikhhjc+vkr2zA==",
"dev": true,
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1465,16 +1475,16 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/braces": {
@@ -1694,6 +1704,23 @@
"node": ">=12.0.0"
}
},
"node_modules/htmx.org": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
"integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
"dev": true,
"license": "0BSD"
},
"node_modules/hyperscript.org": {
"version": "0.9.93",
"resolved": "https://registry.npmjs.org/hyperscript.org/-/hyperscript.org-0.9.93.tgz",
"integrity": "sha512-9Lr9SisBgUW5AjeTekwXErZJLlnFFfq8BiXDzaMGpmX8MA64XdSM3MFYDaWsSFj/ALxeWwtteVgWobKn8DYIkA==",
"dev": true,
"license": "BSD 2-Clause",
"bin": {
"hyperscript.org": "dist/platform/node-hyperscript.js"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+4 -1
View File
@@ -13,17 +13,20 @@
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
"@codemirror/language-data": "^6.5.2",
"@lezer/highlight": "^1.2.3",
"@codemirror/state": "^6.7.1",
"@codemirror/text": "^0.19.6",
"@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"basecoat-css": "^0.3.11",
"codemirror": "^6.0.2",
"dompurify": "^3.4.12",
"github-markdown-css": "^5.9.0",
"highlight.js": "^11.11.1",
"htmx.org": "^2.0.10",
"hyperscript.org": "^0.9.93",
"jdenticon": "^3.3.0",
"katex": "^0.18.1",
"marked": "^18.0.6",
+17
View File
@@ -0,0 +1,17 @@
@import "katex/dist/katex.min.css";
.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));
}
+24 -4
View File
@@ -1,9 +1,29 @@
/* Self-contained stylesheet for the embeddable gist widget
(templates/partials/gist_embed.html). It is loaded inside the
<opengist-embed> web component's shadow DOM and deliberately does NOT depend
on the app's new token system — it ships its own scoped Tailwind + palette so
the embed renders identically wherever it is included. */
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@custom-variant dark (&:where(.dark, .dark *));
/* Dark styling fires on EITHER:
- the .dark class (explicit ?dark, or the auto-mode JS toggle), OR
- the OS preference, but only when .theme-auto is present (auto mode) so an
explicit ?light embed on a dark-OS page stays light.
The media path means the card background follows the OS even if the JS
class-toggle never runs — matching how the syntax highlighting already works. */
@custom-variant dark {
&:where(.dark, .dark *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:where(.theme-auto, .theme-auto *) {
@slot;
}
}
}
@source "../../templates/pages/gist_embed.html";
@source "../../templates/partials/gist_embed.html";
@theme {
/* Custom gray palette */
@@ -55,7 +75,7 @@
cursor: pointer;
}
@import './ipynb.css';
@import "./style.css";
@import './embed-ipynb.css';
@import "./embed-style.css";
}
}
+402
View File
@@ -0,0 +1,402 @@
/* Expose custom surfaces as Tailwind colors so their utilities exist. */
@theme inline {
--color-content: var(--content);
--color-sidebar-hover: var(--sidebar-hover);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--theme-color: var(--color-indigo-500);
/* Primary is a tuned derivation of the accent (darker + less saturated than
the raw Tailwind -500). The factors are variables so the theme-picker
swatches can preview the exact button color with the same formula and
adapt per light/dark. */
--primary-l: 0.82;
--primary-c: 0.5;
--primary: oklch(from var(--theme-color) calc(l * var(--primary-l)) calc(c * var(--primary-c)) h);
--primary-foreground: oklch(0.969 0.016 293.756);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--radius: 0.45rem;
/* Tints of the seed: keep its hue, scale chroma down, pin lightness.
<main> is a faint tint; the sidebar is a touch deeper. */
--content: oklch(from var(--theme-color) 0.985 calc(c * 0.04) h);
--sidebar: oklch(from var(--theme-color) 0.97 calc(c * 0.10) h);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
/* Active/current item: same hue/chroma as the sidebar, a touch darker. */
--sidebar-accent: oklch(from var(--theme-color) 0.93 calc(c * 0.18) h);
/* Hover: same hue but mostly desaturated (grayer). */
--sidebar-hover: oklch(from var(--theme-color) 0.94 calc(c * 0.04) h);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--theme-color: var(--color-indigo-500);
--primary-l: 0.62;
--primary-c: 0.5;
--primary: oklch(from var(--theme-color) calc(l * var(--primary-l)) calc(c * var(--primary-c)) h);
--primary-foreground: oklch(0.969 0.016 293.756);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--content: oklch(from var(--theme-color) 0.17 calc(c * 0.04) h);
--sidebar: oklch(from var(--theme-color) 0.23 calc(c * 0.10) h);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
--sidebar-accent: oklch(from var(--theme-color) 0.30 calc(c * 0.18) h);
--sidebar-hover: oklch(from var(--theme-color) 0.30 calc(c * 0.04) h);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
:root {
--red-diff: rgba(239, 68, 68, 0.13);
--green-diff: rgba(34, 197, 94, 0.14);
--git-diff: rgba(143, 143, 143, 0.2);
}
.dark .text-primary,
.dark .hover\:text-primary:hover {
color: oklch(from var(--theme-color) 0.8 calc(c * 0.5) h);
}
/* Navbar user-account dropdown: a wider menu with roomier, clickable items.
Scoped by #id so it outranks basecoat's .dropdown-menu [data-popover] rules
(which otherwise pin the width to the trigger and use px-2 py-1.5 / cursor-default). */
#user-menu {
min-width: 13rem;
}
#user-menu [role='menuitem'] {
@apply cursor-pointer px-3 py-2 text-sm;
}
/* Gist visibility dropdown: its trigger is a narrow icon-only button, so
basecoat's anchor-size() min-width leaves the menu too cramped. */
#vis-menu {
min-width: 12rem;
}
#vis-menu [role='menuitem'] {
@apply cursor-pointer;
}
/* Gist header "more actions" dropdown: menu items are links/submit buttons,
so give them a pointer cursor instead of basecoat's default. */
#more-menu [role='menuitem'] {
@apply cursor-pointer;
}
.code {
font-family: ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 0.8em;
@apply px-1 py-2.5;
overflow-x: auto;
}
.code td {
padding-top: 0;
padding-bottom: 0;
}
.code tbody {
line-height: 1.45;
}
.code .line-num {
width: 1%;
white-space: nowrap;
text-align: right;
vertical-align: top;
user-select: none;
color: var(--muted-foreground);
}
.code .line-code {
padding-left: 0.5rem;
background: none !important;
}
/* Line numbers in file views are clickable (permalink to the line). */
.code .line-num[id] {
cursor: pointer;
}
/* Highlighted line (selected via its number / targeted by the URL hash). */
.code .line-code.selected {
background-color: rgb(255, 247, 190) !important;
box-shadow: inset 4px 0 0 rgb(255, 213, 65) !important;
}
.dark .code .line-code.selected {
background-color: rgb(54, 49, 32) !important;
box-shadow: inset 4px 0 0 rgb(161, 128, 21) !important;
}
/* Copy buttons swap the copy icon for a check while [data-copied] is set. */
.copy-btn .icon-check {
display: none;
}
.copy-btn[data-copied] .icon-copy {
display: none;
}
.copy-btn[data-copied] .icon-check {
display: block;
}
/* Diff tables: fixed-width line-number and +/- sign columns. */
.code.diff td.line-num {
width: 2.75rem;
min-width: 2.75rem;
}
.code.diff td.diff-sign {
width: 1.25rem;
min-width: 1.25rem;
text-align: center;
}
.green-diff {
background-color: var(--green-diff);
}
.red-diff {
background-color: var(--red-diff);
}
.gray-diff {
background-color: var(--git-diff);
}
/* basecoat only styles <input> by type; mirror it for <textarea> so they match. */
textarea.input {
@apply border-input dark:bg-input/30 placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50;
}
/* github-markdown-css paints .markdown-body with its own canvas background
(background-color: var(--bgColor-default)), and its stylesheet loads after
this one, so a plain override loses the cascade. Force transparent so the
rendered markdown blends into the surrounding card background with no flash. */
.markdown-body {
background-color: transparent !important;
}
/* Toggled by the gist editor (editor.ts). */
.hidden-important {
display: none !important;
}
/* PDF preview: pdf.ts embeds an <iframe> (via PDFObject) into .pdf-embed, which
PDFObject also tags with .pdfobject-container. Give it a fixed height so the
iframe has something to fill. */
.pdf-embed,
.pdfobject-container {
height: 700px;
}
.pdf-embed iframe {
width: 100%;
height: 100%;
border: 0;
}
/* When a gist has a single file, there's nothing to delete: hide its delete
button and round the filename input's right edge (it normally butts up against
the button). Pure CSS so it never flashes on load. */
#editors > .editor:only-child .delete-file {
display: none;
}
#editors > .editor:only-child .form-filename {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
}
/* CodeMirror editor (gist create/edit). Mirror the gist view's code styling and
drive colors from theme variables so it adapts to dark mode automatically. */
.editor .cm-editor {
font-family: ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
background: transparent;
color: var(--foreground);
/* Outer padding matches the gist viewer's .code (5px 10px). */
padding: 5px 10px;
/* Default height on load is 20rem; can be dragged down to 8rem or taller. */
height: 20rem;
min-height: 8rem;
resize: vertical;
overflow: hidden;
}
.editor .cm-editor.cm-focused {
outline: none;
}
/* The extra .cm-editor in these selectors is deliberate: CodeMirror's base
theme scopes its rules as `.cm-<genId> .cm-content` (same specificity as a
plain `.editor .cm-content`), so we add `.cm-editor` to reliably outrank it. */
.editor .cm-editor .cm-scroller {
line-height: 1.45;
overflow: auto;
}
/* Outer padding lives on .cm-editor; here we only zero CodeMirror's defaults and
keep the 0.5rem gap after the line number (matches .line-code padding-left). */
.editor .cm-editor .cm-content {
padding: 0;
caret-color: var(--foreground);
}
.editor .cm-editor .cm-line {
padding-left: 0.5rem;
padding-right: 0;
}
.editor .cm-editor .cm-cursor,
.editor .cm-editor .cm-cursor-primary {
border-left-color: var(--foreground);
}
.editor .cm-editor .cm-gutters {
/* Opaque, matching the editor's card background, so horizontally-scrolled
code doesn't bleed through behind the sticky line-number gutter. */
background: var(--card);
border-right: none;
color: var(--muted-foreground);
}
.editor .cm-editor .cm-lineNumbers .cm-gutterElement {
@apply px-4;
}
.editor .cm-editor .cm-activeLine,
.editor .cm-editor .cm-activeLineGutter {
background: transparent;
}
.editor .cm-editor .cm-focused .cm-selectionBackground,
.editor .cm-editor .cm-selectionBackground {
background: var(--accent);
}
/* CSV files rendered as tables. */
table.csv-table {
width: 100%;
font-size: 0.8em;
white-space: pre;
}
table.csv-table thead {
text-align: left;
}
table.csv-table th,
table.csv-table td {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
}
table.csv-table thead th {
background: var(--muted);
}
/* Color helpers for badges, banners and alerts. The `& > section` rule is so
the color also wins inside a basecoat `.alert`, whose body lives in a
<section> that basecoat forces to text-muted-foreground. */
.og-orange {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300;
& > section { @apply text-amber-800 dark:text-amber-300; }
}
.og-green {
@apply bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300;
& > section { @apply text-emerald-800 dark:text-emerald-300; }
}
.og-red {
@apply bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300;
& > section { @apply text-red-800 dark:text-red-300; }
}
.og-blue {
@apply bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300;
& > section { @apply text-sky-800 dark:text-sky-300; }
}
/* Success flash: neutral gray surface with a blue accent bar; text follows the
theme (dark in light mode, light in dark mode). */
.og-success {
@apply bg-muted text-foreground border-l-4 border-l-sky-500;
& > section { @apply text-foreground; }
}
/* Top loading bar for boosted navigations. Hidden until JS adds .is-loading,
which drives its width via the --progress custom property. */
#page-progress {
position: fixed;
top: 0;
left: 0;
z-index: 60;
height: 3px;
width: 100%;
transform-origin: 0 50%;
transform: scaleX(var(--progress, 0));
background: var(--primary);
box-shadow: 0 0 8px var(--primary);
opacity: 0;
pointer-events: none;
transition: transform 200ms ease-out, opacity 200ms ease-out;
}
#page-progress.is-loading {
opacity: 1;
}
/* Desktop sidebar collapse into an icon-only rail. Toggled by the
`sidebar-collapsed` class on <html> (restored from localStorage in the head
script + toggled in main.ts). Only applies at md+ where the sidebar is docked;
the mobile drawer keeps its own behavior. The rail keeps icons visible so the
collapse button at the bottom of the sidebar stays reachable to expand again. */
@media (min-width: 768px) {
/* Rail width (matches Tailwind's w-16 = 4rem). */
html.sidebar-collapsed #sidebar {
width: 4rem;
}
html.sidebar-collapsed #content-wrapper {
margin-left: 4rem;
}
/* Hide text: nav labels, badges, section headings, logo name. Icons stay. */
html.sidebar-collapsed #sidebar :is(span, p) {
display: none;
}
/* Center the remaining icons within the narrow rail. */
html.sidebar-collapsed #sidebar :is(a, button) {
justify-content: center;
}
}
+13 -4
View File
@@ -1,17 +1,26 @@
@import "katex/dist/katex.min.css";
.jupyter.notebook {
padding: 0.5rem;
padding: 0.5rem;
}
.jupyter.notebook pre {
font-size: 0.8em !important;
font-size: 0.8em !important;
}
.jupyter.notebook .jupyter-cell {
padding: 0.5rem;
padding: 0.5rem;
}
.jupyter.notebook .jupyter-cell.code-cell {
filter: drop-shadow(0 0 0.1rem rgba(0, 0, 0, 0.5));
filter: drop-shadow(0 0 0.1rem rgba(0, 0, 0, 0.5));
}
/*
* 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;
}
+16
View File
@@ -0,0 +1,16 @@
@import "tailwindcss";
@import "basecoat-css";
@import "globals.css";
@import "ipynb.css";
@source "../../templates/**/*.html";
/* User-selectable accent colors (settings → style). Tailwind tree-shakes color
variables, so --color-<name>-500 only exists for colors referenced by a class.
The theme picker and --theme-color reference the whole -500 palette via var(),
which the scanner can't see, so force-generate the bg-*-500 utilities: that
emits the matching --color-<name>-500 variables we rely on.
Keep in sync with validator.ThemeColors. */
@source inline("bg-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,slate,gray,zinc,neutral,stone}-500");
-57
View File
@@ -1,57 +0,0 @@
@import "tailwindcss";
@import './ipynb.css';
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@custom-variant dark (&:where(.dark, .dark *));
@source "../../templates/**/*.html";
@theme {
/* Custom gray palette */
--color-gray-50: #EEEFF1;
--color-gray-100: #DEDFE3;
--color-gray-200: #BABCC5;
--color-gray-300: #999CA8;
--color-gray-400: #75798A;
--color-gray-500: #585B68;
--color-gray-600: #464853;
--color-gray-700: #363840;
--color-gray-800: #232429;
--color-gray-900: #131316;
/* Primary color palette */
--color-primary-50: #d6e1ff;
--color-primary-100: #d1dfff;
--color-primary-200: #b9d2fe;
--color-primary-300: #84b1fb;
--color-primary-400: #74a4f6;
--color-primary-500: #588fee;
--color-primary-600: #3c79e2;
--color-primary-700: #356fc0;
--color-primary-800: #2b5da3;
--color-primary-900: #1f4b8c;
--color-primary-950: #192b57;
/* Border width extension */
--border-width-1: 1px;
}
@layer base {
ul, ol {
list-style: revert;
}
a {
@apply text-primary-500;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
@import "./style.css";
}
-46
View File
@@ -1,46 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
let elems = Array.from(document.getElementsByClassName("toggle-button"));
for (let elem of elems) {
elem.addEventListener('click', () => {
registerDomSetting(elem as HTMLElement)
})
}
let copyInviteButtons = Array.from(document.getElementsByClassName("copy-invitation-link"));
for (let button of copyInviteButtons) {
button.addEventListener('click', () => {
navigator.clipboard.writeText((button as HTMLElement).dataset.link).catch((err) => {
console.error('Could not copy text: ', err);
});
})
}
});
const setSetting = (key: string, value: string) => {
// @ts-ignore
const baseUrl = window.opengist_base_url || '';
const data = new URLSearchParams();
data.append('key', key);
data.append('value', value);
if (document.getElementsByName('_csrf').length !== 0) {
data.append('_csrf', ((document.getElementsByName('_csrf')[0] as HTMLInputElement).value));
}
return fetch(`${baseUrl}/admin-panel/set-config`, {
method: 'PUT',
credentials: 'same-origin',
body: data,
});
};
const registerDomSetting = (el: HTMLElement) => {
// @ts-ignore
el.dataset["bool"] = !(el.dataset["bool"] === 'true');
setSetting(el.id, el.dataset["bool"] === 'true' ? '1' : '0')
.then(() => {
el.classList.toggle("bg-primary-600");
el.classList.toggle("dark:bg-gray-400");
el.classList.toggle("bg-gray-300");
(el.childNodes.item(1) as HTMLElement).classList.toggle("translate-x-5");
});
};
+35 -55
View File
@@ -5,14 +5,21 @@ import {HighlightStyle, LanguageDescription, syntaxHighlighting} from "@codemirr
import {languages} from "@codemirror/language-data";
import {tags} from "@lezer/highlight";
document.addEventListener("DOMContentLoaded", () => {
function initEditorPage() {
let editorsParentdom = document.getElementById("editors");
// Not on an editor page, or already initialized for this DOM.
if (!editorsParentdom || editorsParentdom.dataset.editorReady) return;
editorsParentdom.dataset.editorReady = "1";
// Clear any editors rendered into a restored hx-boost history snapshot so we
// rebuild fresh CodeMirror instances instead of stacking on dead DOM.
editorsParentdom.querySelectorAll(".cm-editor").forEach((el) => el.remove());
EditorView.theme({}, {dark: true});
let editorsjs: EditorView[] = [];
let editorHighlightCompartments: {editor: EditorView, conf: Compartment}[] = [];
let editorsParentdom = document.getElementById("editors")!;
let allEditorsdom = document.querySelectorAll("#editors > .editor");
let firstEditordom = allEditorsdom[0];
let allEditorsdom = editorsParentdom.querySelectorAll(":scope > .editor");
const txtFacet = Facet.define<string>({
combine(values) {
@@ -101,6 +108,9 @@ document.addEventListener("DOMContentLoaded", () => {
],
});
// CodeMirror is mounted; drop the loading spinner.
dom.querySelector(".editor-loading")?.remove();
let mdpreview = dom.querySelector(".md-preview") as HTMLElement;
let formfilename = dom.querySelector<HTMLInputElement>(".form-filename");
@@ -184,13 +194,7 @@ document.addEventListener("DOMContentLoaded", () => {
setLineWrapping(editor, newWrapMode === "soft");
};
dom.addEventListener("drop", (e) => {
e.preventDefault(); // prevent the browser from opening the dropped file
(e.target as HTMLInputElement)
.closest(".editor")
.querySelector<HTMLInputElement>("input.form-filename")!.value =
e.dataTransfer.files[0].name;
});
// drop-to-set-filename is handled by hyperscript on the .editor element.
// remove editor on delete
let deleteBtns = dom.querySelector<HTMLButtonElement>("button.delete-file");
@@ -205,7 +209,6 @@ document.addEventListener("DOMContentLoaded", () => {
if (hIdx !== -1) editorHighlightCompartments.splice(hIdx, 1);
}
dom.remove();
checkForFirstDeleteButton();
};
}
@@ -295,14 +298,11 @@ document.addEventListener("DOMContentLoaded", () => {
deleteBtn.onclick = () => {
if (!confirm("Are you sure you want to delete this file?")) return;
el.remove();
checkForFirstDeleteButton();
};
}
}
});
checkForFirstDeleteButton();
// Update syntax highlight theme when dark/light mode changes
new MutationObserver(() => {
const hl = currentHighlight();
@@ -318,7 +318,6 @@ document.addEventListener("DOMContentLoaded", () => {
// creating the new codemirror editor and append it in the editor div
editorsjs.push(newEditor(newEditorDom));
editorsParentdom.append(newEditorDom);
showDeleteButton(newEditorDom);
};
document.querySelector<HTMLFormElement>("form#create")!.onsubmit = () => {
@@ -375,45 +374,9 @@ document.addEventListener("DOMContentLoaded", () => {
window.onbeforeunload = null;
};
document.getElementById('gist-metadata-btn')!.onclick = (el) => {
let metadata = document.getElementById('gist-metadata')!;
metadata.classList.toggle('hidden');
let btn = el.target as HTMLButtonElement;
if (btn.innerText.endsWith('▼')) {
btn.innerText = btn.innerText.replace('▼', '▲');
} else {
btn.innerText = btn.innerText.replace('▲', '▼');
}
}
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");
}
});
}
function showDeleteButton(editorDom: HTMLElement) {
let deleteBtn = editorDom.querySelector<HTMLButtonElement>("button.delete-file")!;
deleteBtn.classList.remove("hidden");
deleteBtn.previousElementSibling.classList.add("rounded-l-md");
deleteBtn.previousElementSibling.classList.remove("rounded-md");
checkForFirstDeleteButton();
}
// metadata toggle is handled by hyperscript on the button in create.html.
// Single-file delete-button visibility / input rounding is handled in CSS
// via #editors > .editor:only-child (no flash on load).
// File upload functionality
let uploadedFileUUIDs: {uuid: string, filename: string}[] = [];
@@ -557,4 +520,21 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
}
// The page can be reached either by a full load or via hx-boost, which swaps
// the body content without firing DOMContentLoaded. Initialize for all paths,
// matching how main.ts re-initializes its components.
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initEditorPage);
} else {
initEditorPage();
}
document.body.addEventListener("htmx:afterSwap", initEditorPage);
document.body.addEventListener("htmx:historyRestore", initEditorPage);
// hx-boost saves a DOM snapshot for back/forward navigation. Drop the ready
// marker so the editor rebuilds on restore instead of looking dead.
document.body.addEventListener("htmx:beforeHistorySave", () => {
document.getElementById("editors")?.removeAttribute("data-editor-ready");
});
+1 -1
View File
@@ -1 +1 @@
import "../css/embed.css"
import '../css/embed.css';
+71
View File
@@ -0,0 +1,71 @@
// Interactive markdown task-list checkboxes.
//
// The markdown renderer tags each task-list <li> with data-checkbox-nb="<n>"
// (see internal/render/markdown_checkbox.go). When the gist is owned by the
// logged-in user and not archived (#gist[data-own]), clicking a checkbox
// persists the toggle by PUTting to <gist-url>/checkbox, which rewrites and
// commits the file. Otherwise the checkboxes stay disabled and read-only.
function csrfToken(): string {
const input = document.querySelector<HTMLInputElement>('input[name="_csrf"]');
return input ? input.value : '';
}
function bindFile(article: HTMLElement) {
const filename = article.dataset.file;
if (!filename) return;
const items = article.querySelectorAll<HTMLElement>('li[data-checkbox-nb]');
items.forEach((item) => {
const input = item.querySelector<HTMLInputElement>('input[type=checkbox]');
if (!input || input.dataset.checkboxBound) return;
input.dataset.checkboxBound = 'true';
input.disabled = false;
input.addEventListener('change', () => {
const checkboxNb = item.dataset.checkboxNb;
if (checkboxNb === undefined) return;
// Disable every checkbox on the page while the write is in flight so
// concurrent toggles can't race the file rewrite on the server.
const all = document.querySelectorAll<HTMLInputElement>('li[data-checkbox-nb] input[type=checkbox]');
all.forEach((el) => (el.disabled = true));
const data = new URLSearchParams();
data.append('checkbox', checkboxNb);
data.append('file', filename);
const csrf = csrfToken();
if (csrf) data.append('_csrf', csrf);
fetch(location.href.split('#')[0].split('?')[0] + '/checkbox', {
method: 'PUT',
credentials: 'same-origin',
body: data,
})
.then((response) => {
if (!response.ok) input.checked = !input.checked; // revert on failure
})
.catch(() => {
input.checked = !input.checked;
})
.finally(() => {
all.forEach((el) => (el.disabled = false));
});
});
});
}
export function initGistCheckboxes() {
const gist = document.getElementById('gist');
const editable = !!gist && gist.dataset.own === 'true';
document.querySelectorAll<HTMLElement>('article[data-file]').forEach((article) => {
if (editable) {
bindFile(article);
} else {
article
.querySelectorAll<HTMLInputElement>('li[data-checkbox-nb] input[type=checkbox]')
.forEach((input) => (input.disabled = true));
}
});
}
+260
View File
@@ -0,0 +1,260 @@
// Faceted filter input for gist lists.
//
// A single box drives several backend query params. Free text is the title;
// qualifiers (`visibility:`, `language:`, `topic:`) chosen from the dropdown
// become code-styled chips. The chips + title are mirrored into the hidden
// title/visibility/language/topics inputs the server reads.
type Option = { value: string; label: string };
const KEYS = ['visibility', 'language', 'topic'] as const;
type Key = (typeof KEYS)[number];
interface Suggestion {
label: string;
qualifier?: Key; // completes "key:" in the input
commit?: { key: Key; value: string }; // turns into a chip
}
function readOptions(form: HTMLElement, facet: string): Option[] {
const tpl = form.querySelector<HTMLTemplateElement>(`template[data-filter-options="${facet}"]`);
if (!tpl) return [];
return Array.from(tpl.content.querySelectorAll<HTMLElement>('[data-value]')).map((el) => ({
value: el.dataset.value || '',
label: (el.textContent || '').trim(),
}));
}
function setup(form: HTMLFormElement) {
const box = form.querySelector<HTMLElement>('[data-filter-box]');
const input = form.querySelector<HTMLInputElement>('[data-filter-input]');
const menu = form.querySelector<HTMLElement>('[data-filter-menu]');
if (!box || !input || !menu) return;
const hidden = {
title: form.querySelector<HTMLInputElement>('[data-filter-hidden="title"]'),
visibility: form.querySelector<HTMLInputElement>('[data-filter-hidden="visibility"]'),
language: form.querySelector<HTMLInputElement>('[data-filter-hidden="language"]'),
topics: form.querySelector<HTMLInputElement>('[data-filter-hidden="topics"]'),
};
const options: Record<Key, Option[]> = {
visibility: readOptions(form, 'visibility'),
language: readOptions(form, 'language'),
topic: [],
};
// Chosen qualifiers. visibility/language are single; topic can repeat.
let tokens: { key: Key; value: string }[] = [];
if (form.dataset.initVisibility) tokens.push({ key: 'visibility', value: form.dataset.initVisibility });
if (form.dataset.initLanguage) tokens.push({ key: 'language', value: form.dataset.initLanguage });
(form.dataset.initTopics || '')
.split(/\s+/)
.filter(Boolean)
.forEach((v) => tokens.push({ key: 'topic', value: v }));
input.value = form.dataset.initTitle || '';
let suggestions: Suggestion[] = [];
let active = -1;
const lastToken = () => {
const v = input.value;
const sp = v.lastIndexOf(' ');
return { token: v.slice(sp + 1), start: sp + 1 };
};
function commit(key: Key, value: string) {
if (!value) return;
if (key === 'topic') {
if (!tokens.some((t) => t.key === 'topic' && t.value === value)) tokens.push({ key, value });
} else {
tokens = tokens.filter((t) => t.key !== key);
tokens.push({ key, value });
}
renderChips();
}
function renderChips() {
box.querySelectorAll('[data-chip]').forEach((n) => n.remove());
for (const t of tokens) {
const chip = document.createElement('span');
chip.dataset.chip = '';
chip.className =
'bg-muted text-foreground inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-xs';
const label = document.createElement('span');
label.textContent = `${t.key}:${t.value}`;
const rm = document.createElement('button');
rm.type = 'button';
rm.textContent = '×';
rm.className = 'text-muted-foreground hover:text-foreground -mr-0.5 leading-none';
rm.addEventListener('click', () => {
tokens = tokens.filter((x) => x !== t);
renderChips();
syncHidden();
input.focus();
});
chip.append(label, rm);
box.insertBefore(chip, input);
}
}
function build(): Suggestion[] {
const { token } = lastToken();
const colon = token.indexOf(':');
if (colon === -1) {
return KEYS.filter((k) => k.startsWith(token.toLowerCase())).map((k) => ({
label: `${k}:`,
qualifier: k,
}));
}
const key = token.slice(0, colon).toLowerCase() as Key;
const prefix = token.slice(colon + 1).toLowerCase();
if (key === 'topic') {
return prefix ? [{ label: `topic: ${prefix}`, commit: { key: 'topic', value: token.slice(colon + 1) } }] : [];
}
if (key === 'visibility' || key === 'language') {
return options[key]
.filter((o) => o.value.toLowerCase().includes(prefix))
.slice(0, 30)
.map((o) => ({ label: o.label, commit: { key, value: o.value } }));
}
return [];
}
function render() {
suggestions = build();
active = -1;
if (suggestions.length === 0) {
menu.classList.add('hidden');
menu.innerHTML = '';
return;
}
menu.innerHTML = '';
suggestions.forEach((s, i) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.dataset.index = String(i);
btn.textContent = s.label;
btn.className =
'flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground';
menu.appendChild(btn);
});
menu.classList.remove('hidden');
}
function apply(s: Suggestion) {
const { start } = lastToken();
if (s.commit) {
commit(s.commit.key, s.commit.value);
input.value = input.value.slice(0, start); // drop the typed token; it's a chip now
} else if (s.qualifier) {
input.value = input.value.slice(0, start) + `${s.qualifier}:`;
}
input.focus();
syncHidden();
render();
}
function highlight(next: number) {
const btns = Array.from(menu.querySelectorAll<HTMLButtonElement>('button'));
if (btns.length === 0) return;
active = (next + btns.length) % btns.length;
btns.forEach((b, i) => b.setAttribute('aria-selected', i === active ? 'true' : 'false'));
btns[active]?.scrollIntoView({ block: 'nearest' });
}
// Commit a fully-typed "key:value " token (space-terminated) into a chip.
function commitOnSpace() {
if (!input.value.endsWith(' ')) return;
const trimmed = input.value.slice(0, -1);
const sp = trimmed.lastIndexOf(' ');
const last = trimmed.slice(sp + 1);
const m = last.match(/^(visibility|language|topic):(.+)$/i);
if (m) {
commit(m[1].toLowerCase() as Key, m[2]);
input.value = trimmed.slice(0, sp + 1);
}
}
function syncHidden() {
if (hidden.visibility) hidden.visibility.value = tokens.find((t) => t.key === 'visibility')?.value || '';
if (hidden.language) hidden.language.value = tokens.find((t) => t.key === 'language')?.value || '';
if (hidden.topics)
hidden.topics.value = tokens
.filter((t) => t.key === 'topic')
.map((t) => t.value)
.join(' ');
// Title is the leftover free text, minus any half-typed qualifier token.
if (hidden.title)
hidden.title.value = input.value
.split(/\s+/)
.filter((w) => w && !/^(visibility|language|topic):/i.test(w))
.join(' ');
}
input.addEventListener('focus', render);
input.addEventListener('input', () => {
commitOnSpace();
syncHidden();
render();
});
input.addEventListener('keydown', (e) => {
const open = !menu.classList.contains('hidden');
if (e.key === 'ArrowDown') {
if (!open) render();
highlight(active + 1);
e.preventDefault();
} else if (e.key === 'ArrowUp') {
if (open) {
highlight(active - 1);
e.preventDefault();
}
} else if (e.key === 'Enter') {
if (open && active >= 0 && suggestions[active]) {
apply(suggestions[active]);
e.preventDefault();
}
} else if (e.key === 'Escape') {
menu.classList.add('hidden');
} else if (e.key === 'Backspace' && input.value === '' && tokens.length > 0) {
tokens.pop();
renderChips();
syncHidden();
}
});
menu.addEventListener('mousedown', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-index]');
if (!btn) return;
e.preventDefault(); // keep input focused
const s = suggestions[Number(btn.dataset.index)];
if (s) apply(s);
});
// Clicking anywhere in the box focuses the input.
box.addEventListener('mousedown', (e) => {
if (e.target === box) {
input.focus();
e.preventDefault();
}
});
document.addEventListener('click', (e) => {
if (!form.contains(e.target as Node)) menu.classList.add('hidden');
});
form.addEventListener('submit', syncHidden);
renderChips();
syncHidden();
}
export function initGistFilters() {
document
.querySelectorAll<HTMLFormElement>('form[data-gist-filter]:not([data-filter-ready])')
.forEach((form) => {
form.setAttribute('data-filter-ready', '1');
setup(form);
});
}
+40
View File
@@ -0,0 +1,40 @@
// Line selection + permalinks for gist file views.
//
// Each line-number cell has id="file-<slug>-<n>". Clicking a number highlights
// that line and writes the hash #file-<slug>-<n> to the URL. Loading or
// navigating to such a hash scrolls to and highlights the line.
function clearSelection() {
document.querySelectorAll('.table-code .selected').forEach((el) => el.classList.remove('selected'));
}
function select(numCell: HTMLElement) {
clearSelection();
const code = numCell.nextElementSibling;
if (code) code.classList.add('selected');
}
function highlightFromHash() {
if (!location.hash.startsWith('#file-')) return;
const numCell = document.getElementById(location.hash.slice(1));
if (!numCell || !numCell.classList.contains('line-num')) return;
select(numCell);
numCell.scrollIntoView({ block: 'center' });
}
export function initGistLines() {
highlightFromHash();
const w = window as unknown as { __gistLinesBound?: boolean };
if (w.__gistLinesBound) return; // delegated handlers are global; bind once
w.__gistLinesBound = true;
document.addEventListener('click', (e) => {
const numCell = (e.target as HTMLElement).closest<HTMLElement>('.table-code td.line-num[id]');
if (!numCell) return;
select(numCell);
history.replaceState(null, '', location.pathname + location.search + '#' + numCell.id);
});
window.addEventListener('hashchange', highlightFromHash);
}

Some files were not shown because too many files have changed in this diff Show More