mirror of
https://github.com/thomiceli/opengist.git
synced 2026-08-07 07:14:49 +00:00
Archive gists (#739)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+22
-1
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user