diff --git a/internal/db/gist.go b/internal/db/gist.go index cc5d7a1..d5283b2 100644 --- a/internal/db/gist.go +++ b/internal/db/gist.go @@ -48,6 +48,7 @@ type Gist struct { ID uint `gorm:"primaryKey"` Uuid string Title string + URL string Preview string PreviewFilename string Description string @@ -83,7 +84,7 @@ func (gist *Gist) BeforeDelete(tx *gorm.DB) error { func GetGist(user string, gistUuid string) (*Gist, error) { gist := new(Gist) err := db.Preload("User").Preload("Forked.User"). - Where("gists.uuid = ? AND users.username like ?", gistUuid, user). + Where("(gists.uuid = ? OR gists.url = ?) AND users.username like ?", gistUuid, gistUuid, user). Joins("join users on gists.user_id = users.id"). First(&gist).Error @@ -460,11 +461,19 @@ func (gist *Gist) VisibilityStr() string { } } +func (gist *Gist) Identifier() string { + if gist.URL != "" { + return gist.URL + } + return gist.Uuid +} + // -- DTO -- // type GistDTO struct { Title string `validate:"max=250" form:"title"` Description string `validate:"max=1000" form:"description"` + URL string `validate:"max=32,alphanumdashorempty" form:"url"` Private Visibility `validate:"number,min=0,max=2" form:"private"` Files []FileDTO `validate:"min=1,dive"` Name []string `form:"name"` @@ -481,11 +490,13 @@ func (dto *GistDTO) ToGist() *Gist { Title: dto.Title, Description: dto.Description, Private: dto.Private, + URL: dto.URL, } } func (dto *GistDTO) ToExistingGist(gist *Gist) *Gist { gist.Title = dto.Title gist.Description = dto.Description + gist.URL = dto.URL return gist } diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index 012d161..0c3a51c 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -30,6 +30,7 @@ gist.no-content: No content gist.new.new_gist: New gist gist.new.title: Title gist.new.description: Description +gist.new.url: URL gist.new.filename-with-extension: Filename with extension gist.new.indent-mode: Indent mode gist.new.indent-mode-space: Space diff --git a/internal/web/gist.go b/internal/web/gist.go index b4585b5..c16c299 100644 --- a/internal/web/gist.go +++ b/internal/web/gist.go @@ -255,7 +255,7 @@ func allGists(ctx echo.Context) error { for _, gist := range gists { rendered, err := render.HighlightGistPreview(gist) if err != nil { - log.Warn().Err(err).Msg("Error rendering gist preview for " + gist.Uuid + " - " + gist.PreviewFilename) + log.Warn().Err(err).Msg("Error rendering gist preview for " + gist.Identifier() + " - " + gist.PreviewFilename) } renderedFiles = append(renderedFiles, &rendered) } @@ -329,14 +329,15 @@ func gistJson(ctx echo.Context) error { } _ = w.Flush() - jsUrl, err := url.JoinPath(getData(ctx, "baseHttpUrl").(string), gist.User.Username, gist.Uuid+".js") + jsUrl, err := url.JoinPath(getData(ctx, "baseHttpUrl").(string), gist.User.Username, gist.Identifier()+".js") if err != nil { return errorRes(500, "Error joining url", err) } return ctx.JSON(200, map[string]interface{}{ "owner": gist.User.Username, - "id": gist.Uuid, + "id": gist.Identifier(), + "uuid": gist.Uuid, "title": gist.Title, "description": gist.Description, "created_at": time.Unix(gist.CreatedAt, 0).Format(time.RFC3339), @@ -388,7 +389,7 @@ document.write('%s') func revisions(ctx echo.Context) error { gist := getData(ctx, "gist").(*db.Gist) userName := gist.User.Username - gistName := gist.Uuid + gistName := gist.Identifier() pageInt := getPage(ctx) @@ -546,7 +547,7 @@ func processCreate(ctx echo.Context) error { } } - return redirect(ctx, "/"+user.Username+"/"+gist.Uuid) + return redirect(ctx, "/"+user.Username+"/"+gist.Identifier()) } func toggleVisibility(ctx echo.Context) error { @@ -558,7 +559,7 @@ func toggleVisibility(ctx echo.Context) error { } addFlash(ctx, "Gist visibility has been changed", "success") - return redirect(ctx, "/"+gist.User.Username+"/"+gist.Uuid) + return redirect(ctx, "/"+gist.User.Username+"/"+gist.Identifier()) } func deleteGist(ctx echo.Context) error { @@ -591,7 +592,7 @@ func like(ctx echo.Context) error { return errorRes(500, "Error liking/dislking this gist", err) } - redirectTo := "/" + gist.User.Username + "/" + gist.Uuid + redirectTo := "/" + gist.User.Username + "/" + gist.Identifier() if r := ctx.QueryParam("redirecturl"); r != "" { redirectTo = r } @@ -609,11 +610,11 @@ func fork(ctx echo.Context) error { if gist.User.ID == currentUser.ID { addFlash(ctx, "Unable to fork own gists", "error") - return redirect(ctx, "/"+gist.User.Username+"/"+gist.Uuid) + return redirect(ctx, "/"+gist.User.Username+"/"+gist.Identifier()) } if alreadyForked.ID != 0 { - return redirect(ctx, "/"+alreadyForked.User.Username+"/"+alreadyForked.Uuid) + return redirect(ctx, "/"+alreadyForked.User.Username+"/"+alreadyForked.Identifier()) } uuidGist, err := uuid.NewRandom() @@ -646,7 +647,7 @@ func fork(ctx echo.Context) error { addFlash(ctx, "Gist has been forked", "success") - return redirect(ctx, "/"+currentUser.Username+"/"+newGist.Uuid) + return redirect(ctx, "/"+currentUser.Username+"/"+newGist.Identifier()) } func rawFile(ctx echo.Context) error { @@ -736,7 +737,7 @@ func downloadZip(ctx echo.Context) error { } ctx.Response().Header().Set("Content-Type", "application/zip") - ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+gist.Uuid+".zip") + ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+gist.Identifier()+".zip") ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(zipFile.Bytes()))) _, err = ctx.Response().Write(zipFile.Bytes()) if err != nil { @@ -755,7 +756,7 @@ func likes(ctx echo.Context) error { return errorRes(500, "Error getting users who liked this gist", err) } - if err = paginate(ctx, likers, pageInt, 30, "likers", gist.User.Username+"/"+gist.Uuid+"/likes", 1); err != nil { + if err = paginate(ctx, likers, pageInt, 30, "likers", gist.User.Username+"/"+gist.Identifier()+"/likes", 1); err != nil { return errorRes(404, "Page not found", nil) } @@ -779,7 +780,7 @@ func forks(ctx echo.Context) error { return errorRes(500, "Error getting users who liked this gist", err) } - if err = paginate(ctx, forks, pageInt, 30, "forks", gist.User.Username+"/"+gist.Uuid+"/forks", 2); err != nil { + if err = paginate(ctx, forks, pageInt, 30, "forks", gist.User.Username+"/"+gist.Identifier()+"/forks", 2); err != nil { return errorRes(404, "Page not found", nil) } diff --git a/internal/web/server.go b/internal/web/server.go index 041adcf..3edd7e0 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -398,7 +398,7 @@ func writePermission(next echo.HandlerFunc) echo.HandlerFunc { gist := getData(ctx, "gist") user := getUserLogged(ctx) if !gist.(*db.Gist).CanWrite(user) { - return redirect(ctx, "/"+gist.(*db.Gist).User.Username+"/"+gist.(*db.Gist).Uuid) + return redirect(ctx, "/"+gist.(*db.Gist).User.Username+"/"+gist.(*db.Gist).Identifier()) } return next(ctx) } diff --git a/internal/web/test/gist_test.go b/internal/web/test/gist_test.go index 4dfc50a..6462984 100644 --- a/internal/web/test/gist_test.go +++ b/internal/web/test/gist_test.go @@ -199,3 +199,59 @@ func TestLikeFork(t *testing.T) { require.Equal(t, gist1db.Private, gist2db.Private) require.Equal(t, user2.Username, gist2db.User.Username) } + +func TestCustomUrl(t *testing.T) { + setup(t) + s, err := newTestServer() + require.NoError(t, err, "Failed to create test server") + defer teardown(t, s) + + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + gist1 := db.GistDTO{ + Title: "gist1", + URL: "my-gist", + Description: "my first gist", + Private: 0, + Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, + Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, + } + err = s.request("POST", "/", gist1, 302) + require.NoError(t, err) + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, uint(1), gist1db.ID) + require.Equal(t, gist1.Title, gist1db.Title) + require.Equal(t, gist1.Description, gist1db.Description) + require.Regexp(t, "[a-f0-9]{32}", gist1db.Uuid) + require.Equal(t, gist1.URL, gist1db.URL) + require.Equal(t, user1.Username, gist1db.User.Username) + + gist1dbUuid, err := db.GetGist(user1.Username, gist1db.Uuid) + require.NoError(t, err) + require.Equal(t, gist1db, gist1dbUuid) + + gist1dbUrl, err := db.GetGist(user1.Username, gist1.URL) + require.NoError(t, err) + require.Equal(t, gist1db, gist1dbUrl) + + require.Equal(t, gist1.URL, gist1db.Identifier()) + + gist2 := db.GistDTO{ + Title: "gist2", + Description: "my second gist", + Private: 0, + Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, + Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, + } + err = s.request("POST", "/", gist2, 302) + require.NoError(t, err) + + gist2db, err := db.GetGistByID("2") + require.NoError(t, err) + + require.Equal(t, gist2db.Uuid, gist2db.Identifier()) + require.NotEqual(t, gist2db.URL, gist2db.Identifier()) +} diff --git a/internal/web/util.go b/internal/web/util.go index 5f880dc..5c86455 100644 --- a/internal/web/util.go +++ b/internal/web/util.go @@ -16,6 +16,7 @@ import ( "golang.org/x/crypto/argon2" "html/template" "net/http" + "regexp" "strconv" "strings" ) @@ -135,6 +136,8 @@ type OpengistValidator struct { func NewValidator() *OpengistValidator { v := validator.New() _ = v.RegisterValidation("notreserved", validateReservedKeywords) + _ = v.RegisterValidation("alphanumdash", validateAlphaNumDash) + _ = v.RegisterValidation("alphanumdashorempty", validateAlphaNumDashOrEmpty) return &OpengistValidator{v} } @@ -158,6 +161,9 @@ func validationMessages(err *error) string { messages[i] = e.Field() + " should not include a sub directory" case "alphanum": messages[i] = e.Field() + " should only contain alphanumeric characters" + case "alphanumdash": + case "alphanumdashorempty": + messages[i] = e.Field() + " should only contain alphanumeric characters and dashes" case "min": messages[i] = "Not enough " + e.Field() case "notreserved": @@ -181,6 +187,14 @@ func validateReservedKeywords(fl validator.FieldLevel) bool { return !ok } +func validateAlphaNumDash(fl validator.FieldLevel) bool { + return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(fl.Field().String()) +} + +func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool { + return regexp.MustCompile(`^$|^[a-zA-Z0-9-]+$`).MatchString(fl.Field().String()) +} + func getPage(ctx echo.Context) int { page := ctx.QueryParam("page") if page == "" { diff --git a/public/editor.ts b/public/editor.ts index d072f95..269f224 100644 --- a/public/editor.ts +++ b/public/editor.ts @@ -165,6 +165,19 @@ document.addEventListener("DOMContentLoaded", () => { }); }; + 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('▲', '▼'); + } + + } + document.onsubmit = () => { window.onbeforeunload = null; }; diff --git a/templates/base/gist_header.html b/templates/base/gist_header.html index e3996d4..d1f8d14 100644 --- a/templates/base/gist_header.html +++ b/templates/base/gist_header.html @@ -4,12 +4,12 @@

- {{ .gist.User.Username }} / {{ .gist.Title }} + {{ .gist.User.Username }} / {{ .gist.Title }}

{{ if .userLogged }} -
+ {{ .csrfHtml }} - + {{ .gist.NbLikes }}
{{ if ne .userLogged.ID .gist.User.ID }} -
+ {{ .csrfHtml }} - + {{ .gist.NbForks }}
@@ -50,7 +50,7 @@ {{ .locale.Tr "gist.header.like" }} - + {{ .gist.NbLikes }}
@@ -61,21 +61,21 @@ {{ .locale.Tr "gist.header.fork" }} - + {{ .gist.NbForks }}
{{ end }} {{ if .userLogged }}{{ if eq .gist.User.Username .userLogged.Username }}
- + {{ .locale.Tr "gist.header.edit" }}
-
+ {{ .csrfHtml }} -
+ {{ .csrfHtml }} - {{ .locale.Tr "gist.edit.cancel" }} + {{ .locale.Tr "gist.edit.cancel" }} {{ .csrfHtml }} diff --git a/templates/pages/forks.html b/templates/pages/forks.html index d3f06ee..8978113 100644 --- a/templates/pages/forks.html +++ b/templates/pages/forks.html @@ -15,7 +15,7 @@

{{ $.locale.Tr "gist.list.forked" }} {{ $gist.CreatedAt }}

- + diff --git a/templates/pages/gist.html b/templates/pages/gist.html index b14e442..4095b0c 100644 --- a/templates/pages/gist.html +++ b/templates/pages/gist.html @@ -19,7 +19,7 @@ - + {{ $.locale.Tr "gist.raw" }} - + @@ -38,7 +38,7 @@
{{ if $file.Truncated }}
- {{ $.locale.Tr "gist.file-truncated" }} {{ $.locale.Tr "gist.watch-full-file" }}. + {{ $.locale.Tr "gist.file-truncated" }} {{ $.locale.Tr "gist.watch-full-file" }}.
{{ end }} {{ if and (not $csv) (isCsv $file.Filename) }} diff --git a/templates/pages/gist_embed.html b/templates/pages/gist_embed.html index 2b05b39..e051c76 100644 --- a/templates/pages/gist_embed.html +++ b/templates/pages/gist_embed.html @@ -1,14 +1,14 @@ -
+
{{ range $file := .files }}
{{ if $file.Truncated }}
- {{ $.locale.Tr "gist.file-truncated" }} {{ $.locale.Tr "gist.watch-full-file" }}. + {{ $.locale.Tr "gist.file-truncated" }} {{ $.locale.Tr "gist.watch-full-file" }}.
{{ end }} {{ $csv := csvFile $file.File }} diff --git a/templates/pages/revisions.html b/templates/pages/revisions.html index ee2b020..cb49d2d 100644 --- a/templates/pages/revisions.html +++ b/templates/pages/revisions.html @@ -12,7 +12,7 @@ {{ $user := (index $.emails $commit.AuthorEmail) }} - {{if $user}}{{ $commit.AuthorName }}{{else}}{{ $commit.AuthorName }}{{end}} {{ $.locale.Tr "gist.revision.revised" }} {{ $commit.Timestamp }}. {{ $.locale.Tr "gist.revision.go-to-revision" }} + {{if $user}}{{ $commit.AuthorName }}{{else}}{{ $commit.AuthorName }}{{end}} {{ $.locale.Tr "gist.revision.revised" }} {{ $commit.Timestamp }}. {{ $.locale.Tr "gist.revision.go-to-revision" }} {{ if ne $commit.Changed "" }}