diff --git a/internal/db/gist.go b/internal/db/gist.go index 0e8621a..96fbdd6 100644 --- a/internal/db/gist.go +++ b/internal/db/gist.go @@ -86,6 +86,7 @@ type Gist struct { CreatedAt int64 UpdatedAt int64 ExpiresAt int64 // 0: never expires + Archived bool Likes []User `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Forked *Gist `gorm:"foreignKey:ForkedID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"` @@ -493,6 +494,11 @@ func (gist *Gist) Delete() error { return db.Delete(&gist).Error } +func (gist *Gist) SetArchived(archived bool) error { + gist.Archived = archived + return db.Model(&gist).Omit("updated_at").Update("archived", archived).Error +} + func (gist *Gist) SetLastActiveNow() error { return db.Model(&Gist{}). Where("id = ?", gist.ID). diff --git a/internal/db/gist_api.go b/internal/db/gist_api.go index 6bd83eb..7e081c8 100644 --- a/internal/db/gist_api.go +++ b/internal/db/gist_api.go @@ -37,6 +37,7 @@ func (gist *Gist) ToAPISimple(baseURL string) types.GistSimple { CloneUrl: gist.HTTPCloneURL(baseURL), SSHUrl: gist.SSHCloneURL(sshHost), Topics: gist.TopicsSlice(), + Archived: gist.Archived, CreatedAt: time.Unix(gist.CreatedAt, 0).UTC(), UpdatedAt: time.Unix(gist.UpdatedAt, 0).UTC(), ExpiresAt: expiresAt, diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index fbd7813..b2f76b9 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -7,6 +7,10 @@ gist.header.unlike: Unlike gist.header.fork: Fork gist.header.edit: Edit gist.header.delete: Delete +gist.header.archive: Archive +gist.header.unarchive: Unarchive +gist.header.archived: Archived +gist.header.archived-help: This gist is archived and is now read-only. gist.header.forked-from: Forked from gist.header.last-active: Last active gist.header.expires: Expires @@ -379,6 +383,8 @@ flash.auth.oauth-session-expired: OAuth2 session expired, please try again flash.auth.oauth-already-linked: This %s account is already linked to another user 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.fork-own-gist: Unable to fork own gists flash.gist.forked: Gist has been forked diff --git a/internal/ssh/git_ssh.go b/internal/ssh/git_ssh.go index 8b22970..9b5a281 100644 --- a/internal/ssh/git_ssh.go +++ b/internal/ssh/git_ssh.go @@ -78,6 +78,12 @@ func AuthorizeGitCommand(gitCmd string, key string, ip string) (*db.Gist, string _ = db.SSHKeyLastUsedNow(pubKey.Content) } + // Refuse pushes to an archived gist only after the key has been validated + // against the owner above, so we don't disclose the gist's existence. + if verb == "receive-pack" && gist.Archived { + return nil, "", errors.New("this gist is archived and is read-only") + } + return gist, verb, nil } diff --git a/internal/web/handlers/api/openapi.yaml b/internal/web/handlers/api/openapi.yaml index 406d070..8f93016 100644 --- a/internal/web/handlers/api/openapi.yaml +++ b/internal/web/handlers/api/openapi.yaml @@ -899,6 +899,9 @@ components: topics: type: array items: { type: string } + archived: + type: boolean + description: True when the gist is archived (read-only). created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } expires_at: diff --git a/internal/web/handlers/api/v1/gist_edit.go b/internal/web/handlers/api/v1/gist_edit.go index 938e127..f2da719 100644 --- a/internal/web/handlers/api/v1/gist_edit.go +++ b/internal/web/handlers/api/v1/gist_edit.go @@ -154,6 +154,9 @@ func UpdateGist(ctx *context.Context) error { // 404'd above) - existence is already disclosed, so a 403 is honest. return ctx.ErrorJson(403, "You are not the owner of this gist", nil) } + if g.Archived { + return ctx.ErrorJson(403, "This gist is archived and is read-only", nil) + } var req types.GistInput if err := ctx.Bind(&req); err != nil { diff --git a/internal/web/handlers/api/v1/types/gist.go b/internal/web/handlers/api/v1/types/gist.go index da5e15e..0b88037 100644 --- a/internal/web/handlers/api/v1/types/gist.go +++ b/internal/web/handlers/api/v1/types/gist.go @@ -32,6 +32,7 @@ type GistSimple struct { CloneUrl string `json:"clone_url"` SSHUrl string `json:"ssh_url"` Topics []string `json:"topics"` + Archived bool `json:"archived"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ExpiresAt *time.Time `json:"expires_at"` // null when the gist never expires diff --git a/internal/web/handlers/gist/edit.go b/internal/web/handlers/gist/edit.go index b95aa3d..efd9518 100644 --- a/internal/web/handlers/gist/edit.go +++ b/internal/web/handlers/gist/edit.go @@ -58,6 +58,21 @@ func Checkbox(ctx *context.Context) error { return ctx.PlainText(200, "ok") } +func ToggleArchive(ctx *context.Context) error { + gist := ctx.GetData("gist").(*db.Gist) + + if err := gist.SetArchived(!gist.Archived); err != nil { + return ctx.ErrorRes(500, "Error updating this gist", err) + } + + if gist.Archived { + ctx.AddFlash(ctx.Tr("flash.gist.archived"), "success") + } else { + ctx.AddFlash(ctx.Tr("flash.gist.unarchived"), "success") + } + return ctx.RedirectTo("/" + gist.User.Username + "/" + gist.Identifier()) +} + func EditVisibility(ctx *context.Context) error { gist := ctx.GetData("gist").(*db.Gist) diff --git a/internal/web/handlers/gist/edit_test.go b/internal/web/handlers/gist/edit_test.go index defdab2..b139644 100644 --- a/internal/web/handlers/gist/edit_test.go +++ b/internal/web/handlers/gist/edit_test.go @@ -64,3 +64,78 @@ func TestVisibility(t *testing.T) { require.Equal(t, db.PublicVisibility, gist.Private) }) } + +func TestArchive(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("OwnerCanArchiveAndUnarchive", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/archive", nil, 302) + + gist, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.True(t, gist.Archived) + + // Toggling again unarchives the gist. + s.Request(t, "POST", "/"+username+"/"+identifier+"/archive", nil, 302) + + gist, err = db.GetGist(username, identifier) + require.NoError(t, err) + require.False(t, gist.Archived) + }) + + t.Run("OtherUserCannotArchive", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "alice") + s.Request(t, "POST", "/"+username+"/"+identifier+"/archive", nil, 403) + + gist, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.False(t, gist.Archived) + }) + + t.Run("CannotEditArchivedGist", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/archive", nil, 302) + + // Both the edit page and the edit submission are blocked while archived. + s.Request(t, "GET", "/"+username+"/"+identifier+"/edit", nil, 403) + s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{ + "title": {"Changed"}, + "name": {"file.txt"}, + "content": {"changed content"}, + }, 403) + + // The checkbox toggle is a write path too, so it must be blocked. + s.Request(t, "PUT", "/"+username+"/"+identifier+"/checkbox", url.Values{ + "file": {"file.txt"}, + "checkbox": {"0"}, + }, 403) + }) + + t.Run("CanEditAfterUnarchive", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + // Archive then unarchive. + s.Request(t, "POST", "/"+username+"/"+identifier+"/archive", nil, 302) + s.Request(t, "POST", "/"+username+"/"+identifier+"/archive", nil, 302) + + // Editing works again once the gist is no longer archived. + s.Request(t, "GET", "/"+username+"/"+identifier+"/edit", nil, 200) + s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{ + "title": {"Changed"}, + "name": {"file.txt"}, + "content": {"changed content"}, + }, 302) + }) +} diff --git a/internal/web/handlers/git/http.go b/internal/web/handlers/git/http.go index f40b1a7..9ac6262 100644 --- a/internal/web/handlers/git/http.go +++ b/internal/web/handlers/git/http.go @@ -168,6 +168,11 @@ func GitHttp(ctx *context.Context) error { } return ctx.ErrorRes(500, "Authentication system error", nil) } + + if gist.Archived { + log.Debug().Str("authUsername", authUsername).Msg("Pushing to archived gist") + return ctx.PlainText(403, "This gist is archived and is read-only") + } log.Debug().Str("authUsername", authUsername).Msg("Pushing gist") return route.handler(ctx) diff --git a/internal/web/handlers/git/http_test.go b/internal/web/handlers/git/http_test.go index b822ed8..b4ec8d1 100644 --- a/internal/web/handlers/git/http_test.go +++ b/internal/web/handlers/git/http_test.go @@ -182,6 +182,36 @@ func TestGitPush(t *testing.T) { } } +func TestGitPushArchived(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + baseUrl := s.StartHttpServer(t) + + s.Register(t, "thomas") + + _, _, user, gistId := s.CreateGist(t, "0") + + dest := t.TempDir() + require.NoError(t, gitClone(baseUrl, "thomas:thomas", user, gistId, dest)) + + // Pushing works before the gist is archived. + require.NoError(t, gitPush(dest, "before.txt", "content")) + + // Archive the gist. + s.Login(t, "thomas") + s.Request(t, "POST", "/"+user+"/"+gistId+"/archive", nil, 302) + + // Pushing to an archived gist is rejected, even by the owner. + require.Error(t, gitPush(dest, "after.txt", "content")) + + // Unarchive the gist. + s.Request(t, "POST", "/"+user+"/"+gistId+"/archive", nil, 302) + + // Pushing works again once unarchived. + require.NoError(t, gitPush(dest, "afterunarchive.txt", "content")) +} + func TestGitCreatePush(t *testing.T) { s := webtest.Setup(t) defer webtest.Teardown(t) diff --git a/internal/web/server/middlewares.go b/internal/web/server/middlewares.go index 902d952..4876bcc 100644 --- a/internal/web/server/middlewares.go +++ b/internal/web/server/middlewares.go @@ -181,6 +181,18 @@ func writePermission(next Handler) Handler { } } +// notArchived blocks write operations on an archived (read-only) gist. It must +// run after gistInit so the gist is available in the context. +func notArchived(next Handler) Handler { + return func(ctx *context.Context) error { + gist := ctx.GetData("gist").(*db.Gist) + if gist.Archived { + return ctx.ErrorRes(403, "This gist is archived and is read-only", nil) + } + return next(ctx) + } +} + func adminPermission(next Handler) Handler { return func(ctx *context.Context) error { user := ctx.User diff --git a/internal/web/server/router.go b/internal/web/server/router.go index 6f03a93..0fa6d34 100644 --- a/internal/web/server/router.go +++ b/internal/web/server/router.go @@ -191,16 +191,17 @@ func (s *Server) registerRoutes() { sC.GET("/revisions", gist.Revisions) sC.GET("/archive/:revision", gist.DownloadZip) sC.POST("/visibility", gist.EditVisibility, logged, writePermission) + sC.POST("/archive", gist.ToggleArchive, logged, writePermission) sC.POST("/delete", gist.DeleteGist, logged, writePermission) sC.GET("/raw/:revision/:file", gist.RawFile) sC.GET("/download/:revision/:file", gist.DownloadFile) - sC.GET("/edit", gist.Edit, logged, writePermission) - sC.POST("/edit", gist.ProcessCreate, logged, writePermission) + sC.GET("/edit", gist.Edit, logged, writePermission, notArchived) + sC.POST("/edit", gist.ProcessCreate, logged, writePermission, notArchived) sC.POST("/like", gist.Like, logged) sC.GET("/likes", gist.Likes, checkRequireLogin) sC.POST("/fork", gist.Fork, logged) sC.GET("/forks", gist.Forks, checkRequireLogin) - sC.PUT("/checkbox", gist.Checkbox, logged, writePermission) + sC.PUT("/checkbox", gist.Checkbox, logged, writePermission, notArchived) } } diff --git a/templates/base/gist_header.html b/templates/base/gist_header.html index ccdf82b..f8cccb6 100644 --- a/templates/base/gist_header.html +++ b/templates/base/gist_header.html @@ -1,5 +1,5 @@ {{ define "gist_header" }} -