Archive gists (#739)

This commit is contained in:
Thomas
2026-06-27 22:45:14 +07:00
committed by GitHub
parent 7d9448571c
commit dd6b7803ab
15 changed files with 191 additions and 5 deletions
+6
View File
@@ -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).
+1
View File
@@ -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,
+6
View File
@@ -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
+6
View File
@@ -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
}
+3
View File
@@ -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:
@@ -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 {
@@ -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
+15
View File
@@ -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)
+75
View File
@@ -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)
})
}
+5
View File
@@ -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)
+30
View File
@@ -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)
+12
View File
@@ -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
+4 -3
View File
@@ -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)
}
}
+22 -1
View File
@@ -1,5 +1,5 @@
{{ define "gist_header" }}
<div class="py-10" id="gist" data-own="{{ if .userLogged }}{{ if eq .gist.User.Username .userLogged.Username }}true{{ end }}{{ end }}">
<div class="py-10" id="gist" data-own="{{ if not .gist.Archived }}{{ if .userLogged }}{{ if eq .gist.User.Username .userLogged.Username }}true{{ end }}{{ end }}{{ end }}">
<header>
<div class="flex flex-col lg:flex-row">
<div>
@@ -67,6 +67,7 @@
</div>
{{ end }}
{{ if .userLogged }}{{ if eq .gist.User.Username .userLogged.Username }}
{{ if not .gist.Archived }}
<div class="ml-2 flex items-center">
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/edit" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -75,6 +76,16 @@
{{ .locale.Tr "gist.header.edit" }}
</a>
</div>
{{ end }}
<form id="archive" class="ml-2 flex items-center" method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/archive">
{{ .csrfHtml }}
<button type="submit" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
{{ if .gist.Archived }}{{ .locale.Tr "gist.header.unarchive" }}{{ else }}{{ .locale.Tr "gist.header.archive" }}{{ end }}
</button>
</form>
<form id="delete" class="ml-2 flex items-center" method="post" action="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}/delete">
{{ .csrfHtml }}
<button type="submit" onclick="return confirm('{{ .locale.Tr "gist.delete.confirm" }}')" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2 py-1.5 text-xs font-medium text-rose-600 dark:text-rose-400 hover:bg-rose-500 hover:text-white dark:hover:bg-rose-600 hover:border-rose-600 dark:hover:border-rose-700 dark:hover:text-white focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
@@ -93,6 +104,7 @@
{{ end }}
<p class="mt-1 max-w-2xl text-sm text-slate-500">{{ .locale.Tr "gist.header.last-active" }} <span> {{ .gist.UpdatedAt | humanTimeDiff }} </span>
{{ if .gist.Private }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300"> {{ visibilityStr .gist.Private false }} </span>{{ end }}
{{ if .gist.Archived }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200"> {{ .locale.Tr "gist.header.archived" }} </span>{{ end }}
{{ if .gist.ExpiresAt }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-200" title="{{ humanDate .gist.ExpiresAt }}"> {{ .locale.Tr "gist.header.expires" }} {{ .gist.ExpiresAt | humanTimeDiff }} </span>{{ end }}
</p>
<p class="mt-1 text-sm max-w-2xl text-slate-600 dark:text-slate-400">{{ .gist.Description }}</p>
@@ -106,6 +118,15 @@
</header>
<main class="mt-4">
{{ if .gist.Archived }}
<div class="rounded-md border border-1 border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/30 px-4 py-2 text-sm text-amber-900 dark:text-amber-200 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 flex-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
{{ .locale.Tr "gist.header.archived-help" }}
</div>
{{ end }}
<div class="my-4">
<div class="sm:hidden">
<label for="gist-tabs" class="sr-only">{{ .locale.Tr "gist.header.select-tab" }}</label>
+2 -1
View File
@@ -41,7 +41,8 @@
</div>
<h5 class="text-sm text-slate-500 pb-1">{{ .locale.Tr "gist.list.last-active" }} <span>{{ .gist.UpdatedAt | humanTimeDiff }}</span>
{{ if .gist.Forked }} • {{ .locale.Tr "gist.list.forked-from" }} <a href="{{ .c.ExternalUrl }}/{{ .gist.Forked.User.Username }}/{{ .gist.Forked.Identifier }}">{{ .gist.Forked.User.Username }}/{{ .gist.Forked.Title }}</a> {{ end }}
{{ if .gist.Private }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300"> {{ visibilityStr .gist.Private false }} </span>{{ end }}</h5>
{{ if .gist.Private }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300"> {{ visibilityStr .gist.Private false }} </span>{{ end }}
{{ if .gist.Archived }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200"> {{ .locale.Tr "gist.header.archived" }} </span>{{ end }}</h5>
<div class="flex items-center gap-2 mb-2">
{{ if len .gist.Description }}
<h6 class="text-xs text-slate-700 dark:text-slate-300">{{ .gist.Description }}</h6>