Feature: Allow embedding Gists for a certain file only (#709)

* Feature: Allow embedding Gists for a certain file only

* Move from URL to param approach

* Switch gist.Files to gist.File

* Satisfy linting
This commit is contained in:
Marcel Herrguth
2026-06-08 21:44:39 +02:00
committed by GitHub
parent 2946de2505
commit bf3257faa8
3 changed files with 190 additions and 11 deletions
+8
View File
@@ -9,3 +9,11 @@ To embed a Gist to your webpage, you can add a script tag with the URL of your g
<script src="http://opengist.url/user/gist-url.js?dark"></script>
```
If you have a Gist that holds several different files, you can also explicitely call a specific file by its filename:
```html
<script src="http://opengist.url/user/gist-url.js?file=filename"></script>
<!-- Dark mode: -->
<script src="http://opengist.url/user/gist-url.js?file=filename&dark"></script>
```
+53 -11
View File
@@ -48,9 +48,26 @@ func GistIndex(ctx *context.Context) error {
func GistJson(ctx *context.Context) error {
gist := ctx.GetData("gist").(*db.Gist)
files, hasMoreFiles, err := gist.Files("HEAD", true)
if err != nil {
return ctx.ErrorRes(500, "Error fetching files", err)
var files []*git.File
hasMoreFiles := false
embedFile := ctx.QueryParam("file")
if embedFile != "" {
file, err := gist.File("HEAD", embedFile, true)
if err != nil {
return ctx.ErrorRes(500, "Error fetching file", err)
}
if file == nil {
return ctx.NotFound("File not found")
}
files = []*git.File{file}
} else {
var err error
files, hasMoreFiles, err = gist.Files("HEAD", true)
if err != nil {
return ctx.ErrorRes(500, "Error fetching files", err)
}
}
renderedFiles := render.RenderFiles(files)
@@ -69,11 +86,19 @@ func GistJson(ctx *context.Context) error {
}
_ = w.Flush()
jsUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), gist.User.Username, gist.Identifier()+".js")
jsBaseUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), gist.User.Username, gist.Identifier()+".js")
if err != nil {
return ctx.ErrorRes(500, "Error joining js url", err)
}
// Build per-file and per-theme URL variants.
fileQuery, themeSep := "", "?"
if embedFile != "" {
fileQuery = "?file=" + url.QueryEscape(embedFile)
themeSep = "&"
}
jsUrl := jsBaseUrl + fileQuery
cssUrl, err := url.JoinPath(ctx.GetData("baseHttpUrl").(string), context.ManifestEntries["embed.css"].File)
if err != nil {
return ctx.ErrorRes(500, "Error joining css url", err)
@@ -93,7 +118,7 @@ func GistJson(ctx *context.Context) error {
"html": htmlbuf.String(),
"css": cssUrl,
"js": jsUrl,
"js_dark": jsUrl + "?dark",
"js_dark": jsUrl + themeSep + "dark",
},
})
}
@@ -106,9 +131,26 @@ func GistJs(ctx *context.Context) error {
}
gist := ctx.GetData("gist").(*db.Gist)
files, hasMoreFiles, err := gist.Files("HEAD", true)
if err != nil {
return ctx.ErrorRes(500, "Error fetching files", err)
var files []*git.File
hasMoreFiles := false
embedFile := ctx.QueryParam("file")
if embedFile != "" {
file, err := gist.File("HEAD", embedFile, true)
if err != nil {
return ctx.ErrorRes(500, "Error fetching file", err)
}
if file == nil {
return ctx.NotFound("File not found")
}
files = []*git.File{file}
} else {
var err error
files, hasMoreFiles, err = gist.Files("HEAD", true)
if err != nil {
return ctx.ErrorRes(500, "Error fetching files", err)
}
}
renderedFiles := render.RenderFiles(files)
@@ -117,7 +159,7 @@ func GistJs(ctx *context.Context) error {
htmlbuf := bytes.Buffer{}
w := bufio.NewWriter(&htmlbuf)
if err = ctx.Echo().Renderer.Render(w, "gist_embed.html", ctx.DataMap(), ctx); err != nil {
if err := ctx.Echo().Renderer.Render(w, "gist_embed.html", ctx.DataMap(), ctx); err != nil {
return err
}
_ = w.Flush()
@@ -175,7 +217,7 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string) (string, erro
super();
this.attachShadow({ mode: 'open' });
}
init(css1, css2, content) {
this.shadowRoot.innerHTML = %s
<style>
@@ -196,7 +238,7 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string) (string, erro
const instance = document.createElement('opengist-embed');
instance.init(%s, %s, %s);
currentScript.parentNode.insertBefore(instance, currentScript.nextSibling);
currentScript.parentNode.insertBefore(instance, currentScript.nextSibling);
})();
`,
"`",
+129
View File
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
@@ -325,3 +326,131 @@ func TestGetGistCaseInsensitive(t *testing.T) {
s.Request(t, "GET", "/THOMAS/"+strings.ToUpper(gist.Uuid), nil, 200)
})
}
func TestGistJsSingleFile(t *testing.T) {
setupManifestEntries()
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Register(t, "alice")
t.Run("RendersOnlyRequestedFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+".js?file=file.txt", nil, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
js := string(body)
assert.Contains(t, js, "opengist-embed")
assert.Contains(t, js, "hello world")
assert.NotContains(t, js, "other content")
})
t.Run("NonExistentFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier+".js?file=nonexistent.txt", nil, 404)
})
t.Run("DarkTheme", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+".js?dark", nil, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "dark.css")
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
// Anonymous — no token
s.Request(t, "GET", "/"+username+"/"+identifier+".js?file=file.txt", nil, 404)
// Invalid token
s.RequestWithHeaders(t, "GET", "/"+username+"/"+identifier+".js?file=file.txt", nil, 404,
map[string]string{"Authorization": "Token invalidtoken"})
// Other user's valid token
s.Login(t, "alice")
aliceTok := s.CreateAccessToken(t, "alice-tok", db.ReadPermission, db.ReadPermission)
s.Logout()
s.RequestWithHeaders(t, "GET", "/"+username+"/"+identifier+".js?file=file.txt", nil, 404,
map[string]string{"Authorization": "Token " + aliceTok})
// Owner's valid token
s.Login(t, "thomas")
ownerTok := s.CreateAccessToken(t, "owner-tok", db.ReadPermission, db.ReadPermission)
s.Logout()
resp := s.RequestWithHeaders(t, "GET", "/"+username+"/"+identifier+".js?file=file.txt", nil, 200,
map[string]string{"Authorization": "Token " + ownerTok})
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "hello world")
})
}
func TestGistJsonSingleFile(t *testing.T) {
setupManifestEntries()
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
t.Run("RendersOnlyRequestedFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
resp := s.Request(t, "GET", "/"+username+"/"+identifier+".json?file=file.txt", nil, 200)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var result map[string]interface{}
require.NoError(t, json.Unmarshal(body, &result))
files, ok := result["files"].([]interface{})
require.True(t, ok)
require.Len(t, files, 1)
assert.Equal(t, "file.txt", files[0].(map[string]interface{})["filename"])
embed, ok := result["embed"].(map[string]interface{})
require.True(t, ok)
assert.Contains(t, embed["js"], identifier+".js?file=file.txt")
assert.Contains(t, embed["js_dark"], identifier+".js?file=file.txt&dark")
assert.NotEmpty(t, embed["html"])
})
t.Run("NonExistentFile", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "0")
s.Request(t, "GET", "/"+username+"/"+identifier+".json?file=nonexistent.txt", nil, 404)
})
t.Run("PrivateGist", func(t *testing.T) {
_, _, username, identifier := s.CreateGist(t, "2")
// Anonymous — no token
s.Request(t, "GET", "/"+username+"/"+identifier+".json?file=file.txt", nil, 404)
// Invalid token
s.RequestWithHeaders(t, "GET", "/"+username+"/"+identifier+".json?file=file.txt", nil, 404,
map[string]string{"Authorization": "Token invalidtoken"})
// Owner's valid token
s.Login(t, "thomas")
ownerTok := s.CreateAccessToken(t, "owner-tok", db.ReadPermission, db.ReadPermission)
s.Logout()
resp := s.RequestWithHeaders(t, "GET", "/"+username+"/"+identifier+".json?file=file.txt", nil, 200,
map[string]string{"Authorization": "Token " + ownerTok})
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var result map[string]interface{}
require.NoError(t, json.Unmarshal(body, &result))
files, ok := result["files"].([]interface{})
require.True(t, ok)
require.Len(t, files, 1)
})
}