Gist expiration + scheduled actions (#726)

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
This commit is contained in:
Thomas
2026-06-20 00:28:48 +07:00
committed by GitHub
parent 499f9c67b9
commit 28736d6b66
27 changed files with 481 additions and 91 deletions
+11
View File
@@ -36,3 +36,14 @@ git push -o visibility=private
```shell
git push -o topics="golang devops"
```
## Set expiration
Only applies when creating a gist. The value is either a preset
(`1hour`, `12hours`, `1day`, `7days`, `15days`) or a custom date
(RFC3339, e.g. `2026-01-02T15:04:05Z`).
```shell
git push -o expire=1day
git push -o expire=2026-01-02T15:04:05Z
```
+1
View File
@@ -23,6 +23,7 @@ require (
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
+2
View File
@@ -238,6 +238,8 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
+70 -50
View File
@@ -1,21 +1,20 @@
package actions
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/index"
"os"
"path/filepath"
"strings"
"sync"
)
type ActionStatus struct {
Running bool
}
const (
SyncReposFromFS = iota
SyncReposFromDB
@@ -24,63 +23,71 @@ const (
ResetHooks
IndexGists
SyncGistLanguages
DeleteExpiredGists
numActions // keep last — sizes the `running` array
)
var (
mutex sync.Mutex
actions = make(map[int]ActionStatus)
)
// running tracks which actions are in progress in this instance, one slot per
// action type. It dedupes concurrent runs (e.g. a double-clicked admin button)
// and backs IsRunning for the admin panel; cross-instance single-flighting is
// handled separately by the DB action lock.
var running [numActions]atomic.Bool
func updateActionStatus(actionType int, running bool) {
actions[actionType] = ActionStatus{
Running: running,
}
const lockLease = time.Hour
type action struct {
run func()
spec string
}
var registry = map[int]action{
SyncReposFromFS: {run: syncReposFromFS},
SyncReposFromDB: {run: syncReposFromDB},
GitGcRepos: {run: gitGcRepos},
SyncGistPreviews: {run: syncGistPreviews},
ResetHooks: {run: resetHooks},
IndexGists: {run: indexGists},
SyncGistLanguages: {run: syncGistLanguages},
DeleteExpiredGists: {run: deleteExpiredGists, spec: "@every 1m"},
}
func IsRunning(actionType int) bool {
mutex.Lock()
defer mutex.Unlock()
return actions[actionType].Running
return actionType >= 0 && actionType < numActions && running[actionType].Load()
}
func Run(actionType int) {
mutex.Lock()
if actions[actionType].Running {
mutex.Unlock()
func RunOnce(actionType int) {
a, ok := registry[actionType]
if !ok {
log.Error().Msgf("Unknown action type %d", actionType)
return
}
updateActionStatus(actionType, true)
mutex.Unlock()
if !running[actionType].CompareAndSwap(false, true) {
return // already running in this instance
}
defer running[actionType].Store(false)
// Single-flight the action across instances sharing the database so only
// one replica runs it at a time, whether triggered by the scheduler or
// manually.
acquired, err := db.AcquireLock(actionType, lockLease)
if err != nil {
log.Error().Err(err).Msgf("Could not acquire lock for action %d", actionType)
return
}
if !acquired {
return
}
defer func() {
mutex.Lock()
updateActionStatus(actionType, false)
mutex.Unlock()
if err := db.ReleaseLock(actionType); err != nil {
log.Error().Err(err).Msgf("Could not release lock for action %d", actionType)
}
}()
var functionToRun func()
switch actionType {
case SyncReposFromFS:
functionToRun = syncReposFromFS
case SyncReposFromDB:
functionToRun = syncReposFromDB
case GitGcRepos:
functionToRun = gitGcRepos
case SyncGistPreviews:
functionToRun = syncGistPreviews
case ResetHooks:
functionToRun = resetHooks
case IndexGists:
functionToRun = indexGists
case SyncGistLanguages:
functionToRun = syncGistLanguages
default:
log.Error().Msg("Unknown action type")
}
functionToRun()
log.Info().Msgf("Starting running action %d", actionType)
a.run()
log.Info().Msgf("Finished running action %d", actionType)
}
func syncReposFromFS() {
@@ -136,6 +143,7 @@ func syncGistPreviews() {
return
}
for _, gist := range gists {
fmt.Println("Syncing preview for gist", gist.ID)
if err = gist.UpdatePreviewAndCount(false); err != nil {
log.Error().Err(err).Msgf("Cannot update preview and count for gist %d", gist.ID)
}
@@ -188,3 +196,15 @@ func syncGistLanguages() {
gist.UpdateLanguages()
}
}
func deleteExpiredGists() {
gists, err := db.DeleteExpiredGists()
if err != nil {
log.Error().Err(err).Msg("Cannot delete expired gists")
return
}
if len(gists) > 0 {
log.Info().Msgf("Deleted %d expired gist(s)", len(gists))
}
}
+49
View File
@@ -0,0 +1,49 @@
package actions
import (
"time"
"github.com/robfig/cron/v3"
"github.com/rs/zerolog/log"
)
const cronDrainTimeout = 10 * time.Second
// StartCron registers every scheduled action in `registry` (those with a spec)
// and starts the scheduler. It returns a stop function that halts the scheduler
// and waits (up to cronDrainTimeout) for any in-flight job to finish — call it
// before tearing down the DB so a running action can release its lock cleanly.
// Panicking jobs are recovered so a single failed run can't take down the server.
func StartCron() (stop func()) {
c := cron.New(cron.WithChain(cron.Recover(cronLogger{})))
for actionType, a := range registry {
if a.spec == "" {
continue
}
if _, err := c.AddFunc(a.spec, func() { RunOnce(actionType) }); err != nil {
log.Error().Err(err).Msgf("Invalid cron spec %q for action %d", a.spec, actionType)
}
}
c.Start()
return func() {
log.Info().Msg("Stopping crons...")
select {
case <-c.Stop().Done():
case <-time.After(cronDrainTimeout):
log.Warn().Msg("cron: timed out waiting for jobs to finish")
}
}
}
type cronLogger struct{}
func (cronLogger) Info(msg string, _ ...interface{}) {
log.Info().Msgf("cron: %s", msg)
}
func (cronLogger) Error(err error, msg string, _ ...interface{}) {
log.Error().Err(err).Msgf("cron: %s", msg)
}
+3
View File
@@ -9,6 +9,7 @@ import (
"syscall"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/actions"
"github.com/thomiceli/opengist/internal/auth/webauthn"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
@@ -41,6 +42,7 @@ var CmdStart = cli.Command{
httpServer := server.NewServer(os.Getenv("OG_DEV") == "1")
go httpServer.Start()
go ssh.Start()
stopCron := actions.StartCron()
var metricsServer *metrics.Server
if config.C.MetricsEnabled {
@@ -49,6 +51,7 @@ var CmdStart = cli.Command{
}
<-stopCtx.Done()
stopCron()
shutdown(httpServer, metricsServer)
return nil
},
+52
View File
@@ -0,0 +1,52 @@
package db
import (
"time"
"gorm.io/gorm/clause"
)
// ActionLock is a DB-backed lease used to single-flight an action across
// multiple Opengist instances sharing the same database. Each lock is one row
// keyed by Action (the action's identifier); LockedUntil holds the Unix
// timestamp the current lease expires at (0 = free).
type ActionLock struct {
Action int `gorm:"primaryKey"`
LockedUntil int64
}
func (ActionLock) TableName() string {
return "action_lock"
}
// AcquireLock atomically grabs the lock for action when it is free or its lease
// has expired, extending the lease by leaseTTL. It returns true only for the
// single caller that won the row. The conditional UPDATE is what makes this
// safe across SQLite/PostgreSQL/MySQL: concurrent writers serialize on the row
// (SQLite serializes all writes), so at most one re-evaluates the
// `locked_until < now` predicate to true. leaseTTL only needs to outlast a
// normal run; it's a safety net so a crashed holder doesn't block future runs.
func AcquireLock(action int, leaseTTL time.Duration) (bool, error) {
now := time.Now().Unix()
if err := db.Clauses(clause.OnConflict{DoNothing: true}).
Create(&ActionLock{Action: action, LockedUntil: 0}).Error; err != nil {
return false, err
}
res := db.Model(&ActionLock{}).
Where("action = ? AND locked_until < ?", action, now).
Update("locked_until", time.Now().Add(leaseTTL).Unix())
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected == 1, nil
}
// ReleaseLock frees the lock for action so the next run can acquire it
// immediately instead of waiting for the lease to expire.
func ReleaseLock(action int) error {
return db.Model(&ActionLock{}).
Where("action = ?", action).
Update("locked_until", 0).Error
}
+1 -1
View File
@@ -155,7 +155,7 @@ func Setup(dbUri string) error {
return err
}
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}, &ActionLock{}); err != nil {
return err
}
+10 -7
View File
@@ -85,6 +85,7 @@ type Gist struct {
NbForks int
CreatedAt int64
UpdatedAt int64
ExpiresAt int64 // 0: never expires
Likes []User `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Forked *Gist `gorm:"foreignKey:ForkedID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
@@ -106,6 +107,8 @@ func (gist *Gist) BeforeSave(_ *gorm.DB) error {
}
func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
gist.DeleteRepository()
gist.RemoveFromIndex()
// Decrement fork counter if the gist was forked
err := tx.Model(&Gist{}).
Omit("updated_at").
@@ -487,11 +490,6 @@ func (gist *Gist) UpdateNoTimestamps() error {
}
func (gist *Gist) Delete() error {
err := gist.DeleteRepository()
if err != nil {
return err
}
return db.Delete(&gist).Error
}
@@ -578,8 +576,11 @@ func (gist *Gist) InitRepository() error {
return git.InitRepository(gist.User.Username, gist.Uuid)
}
func (gist *Gist) DeleteRepository() error {
return git.DeleteRepository(gist.User.Username, gist.Uuid)
func (gist *Gist) DeleteRepository() {
err := git.DeleteRepository(gist.User.Username, gist.Uuid)
if err != nil {
log.Warn().Err(err).Msgf("Could not delete repository %s/%s", gist.User.Username, gist.Uuid)
}
}
func (gist *Gist) Files(revision string, truncate bool) ([]*git.File, bool, error) {
@@ -981,6 +982,8 @@ type GistDTO struct {
UploadedFilesNames []string `validate:"omitempty,dive,required" form:"uploadedfile_filename"`
BinaryFileOldName []string `form:"binary_old_name"`
BinaryFileNewName []string `form:"binary_new_name"`
Expire ExpirationType `validate:"omitempty,oneof=never 1hour 12hours 1day 7days 15days custom" form:"expire"`
ExpireAt string `validate:"expirationdate" form:"expire_at"`
VisibilityDTO
}
+6
View File
@@ -18,6 +18,11 @@ func (gist *Gist) ToAPISimple(baseURL string) types.GistSimple {
if u, err := url.Parse(baseURL); err == nil {
sshHost = u.Host
}
var expiresAt *time.Time
if gist.ExpiresAt > 0 {
t := time.Unix(gist.ExpiresAt, 0).UTC()
expiresAt = &t
}
return types.GistSimple{
ID: gist.Uuid,
Owner: gist.User.ToSimpleAPI(),
@@ -34,6 +39,7 @@ func (gist *Gist) ToAPISimple(baseURL string) types.GistSimple {
Topics: gist.TopicsSlice(),
CreatedAt: time.Unix(gist.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(gist.UpdatedAt, 0).UTC(),
ExpiresAt: expiresAt,
}
}
+96
View File
@@ -0,0 +1,96 @@
package db
import (
"strings"
"time"
"github.com/thomiceli/opengist/internal/validator"
)
// ExpirationType is the expiration choice a user can pick when creating a
// gist: either a fixed-duration preset, "custom" (paired with an explicit
// date), or "never". The empty value and "never" both mean "never expires".
type ExpirationType string
const (
ExpiryNever ExpirationType = "never"
ExpiryOneHour ExpirationType = "1hour"
ExpiryTwelveHours ExpirationType = "12hours"
ExpiryOneDay ExpirationType = "1day"
ExpirySevenDays ExpirationType = "7days"
ExpiryFifteenDays ExpirationType = "15days"
ExpiryCustom ExpirationType = "custom"
)
// Duration returns the time span of a fixed-duration preset, or 0 for
// "never", "custom", and any unknown value (those resolve their timestamp
// elsewhere).
func (e ExpirationType) Duration() time.Duration {
switch e {
case ExpiryOneHour:
return time.Hour
case ExpiryTwelveHours:
return 12 * time.Hour
case ExpiryOneDay:
return 24 * time.Hour
case ExpirySevenDays:
return 7 * 24 * time.Hour
case ExpiryFifteenDays:
return 15 * 24 * time.Hour
default:
return 0
}
}
// ExpiresAtTimestamp converts a fixed-duration preset into an absolute Unix
// expiration timestamp relative to now, returning 0 for "never"/"custom".
// Use GistDTO.ExpiresAtTimestamp to resolve a custom date.
func (e ExpirationType) ExpiresAtTimestamp() int64 {
d := e.Duration()
if d == 0 {
return 0
}
return time.Now().Add(d).Unix()
}
// ExpiresAtTimestamp resolves the gist's absolute expiration time (Unix
// seconds) from the chosen preset, or from the custom date when Expire is
// "custom". Returns 0 when the gist never expires or the custom date can't be
// parsed - callers validate the DTO (the `expirationdate` rule) beforehand, so
// an unparseable value here only happens on the non-validated push-option path,
// where "never" is the safe fallback.
func (dto *GistDTO) ExpiresAtTimestamp() int64 {
if dto.Expire != ExpiryCustom {
return dto.Expire.ExpiresAtTimestamp()
}
t, err := validator.ParseDateTime(strings.TrimSpace(dto.ExpireAt))
if err != nil {
return 0
}
return t.Unix()
}
func (gist *Gist) IsExpired() bool {
return gist.ExpiresAt > 0 && gist.ExpiresAt <= time.Now().Unix()
}
func DeleteExpiredGists() ([]*Gist, error) {
var gists []*Gist
err := db.Preload("User").
Where("expires_at > 0 AND expires_at <= ?", time.Now().Unix()).
Find(&gists).Error
if err != nil {
return nil, err
}
if len(gists) == 0 {
return nil, nil
}
if err := db.Delete(&gists).Error; err != nil {
return nil, err
}
return gists, nil
}
+24 -3
View File
@@ -3,14 +3,16 @@ package hooks
import (
"bufio"
"fmt"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
validatorpkg "github.com/thomiceli/opengist/internal/validator"
"io"
"os"
"os/exec"
"slices"
"strings"
"time"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
validatorpkg "github.com/thomiceli/opengist/internal/validator"
)
func PostReceive(in io.Reader, out, er io.Writer) error {
@@ -81,6 +83,25 @@ func PostReceive(in io.Reader, out, er io.Writer) error {
}
}
if newGist && opts["expire"] != "" {
value := opts["expire"]
expire := db.ExpirationType(value)
switch {
case expire == db.ExpiryNever:
// no expiration
case expire.Duration() > 0:
gist.ExpiresAt = expire.ExpiresAtTimestamp()
fmt.Fprintf(&outputSb, "Gist expiration set to \"%s\"\n\n", value)
default:
if t, err := validatorpkg.ParseDateTime(value); err == nil && t.After(time.Now()) {
gist.ExpiresAt = t.Unix()
fmt.Fprintf(&outputSb, "Gist expiration set to \"%s\"\n\n", value)
} else {
fmt.Fprintf(&outputSb, "Invalid gist expiration \"%s\", ignored\n\n", value)
}
}
}
if hasNoCommits, err := git.HasNoCommits(gist.User.Username, gist.Uuid); err != nil {
_, _ = fmt.Fprintln(er, "Failed to check if gist has no commits")
return fmt.Errorf("failed to check if gist has no commits: %w", err)
+12
View File
@@ -9,6 +9,7 @@ gist.header.edit: Edit
gist.header.delete: Delete
gist.header.forked-from: Forked from
gist.header.last-active: Last active
gist.header.expires: Expires
gist.header.select-tab: Select a tab
gist.header.code: Code
gist.header.revisions: Revisions
@@ -52,6 +53,14 @@ gist.new.create-a-new-gist: Create a new gist
gist.new.topics: Topics (separate with spaces)
gist.new.drop-files: Drop files here or click to upload
gist.new.any-file-type: Upload any file type
gist.new.expire: Expires
gist.new.expire-never: Never
gist.new.expire-1hour: After 1 hour
gist.new.expire-12hours: After 12 hours
gist.new.expire-1day: After 1 day
gist.new.expire-7days: After 7 days
gist.new.expire-15days: After 15 days
gist.new.expire-custom: Custom date
gist.edit.editing: Editing
gist.edit.edit-gist: Edit %s
@@ -300,6 +309,7 @@ admin.actions.sync-previews: Synchronize all gists previews
admin.actions.reset-hooks: Reset Git server hooks for all repositories
admin.actions.index-gists: Rebuild search index
admin.actions.sync-gist-languages: Synchronize all gists languages
admin.actions.delete-expired-gists: Delete expired gists
admin.id: ID
admin.user: User
admin.delete: Delete
@@ -348,6 +358,7 @@ flash.admin.sync-previews: Syncing Gist previews...
flash.admin.reset-hooks: Resetting Git server hooks for all repositories...
flash.admin.index-gists: Rebuilding search index...
flash.admin.sync-gist-languages: Syncing Gist languages...
flash.admin.delete-expired-gists: Deleting expired gists...
flash.auth.username-exists: Username already exists
flash.auth.invalid-credentials: Invalid credentials
@@ -381,5 +392,6 @@ validation.should-only-contain-alphanumeric-characters-and-dashes: Field %s shou
validation.not-enough: Not enough %s
validation.invalid: Invalid %s
validation.invalid-gist-topics: Invalid gist topics, they must start with a letter or number, consist of 50 characters or less, and can include hyphens
validation.invalid-expiration-date: Invalid expiration date, it must be a valid date in the future
html.title.admin-panel: Admin panel
+41 -2
View File
@@ -1,10 +1,13 @@
package validator
import (
"github.com/go-playground/validator/v10"
"github.com/thomiceli/opengist/internal/i18n"
"fmt"
"regexp"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/thomiceli/opengist/internal/i18n"
)
type OpengistValidator struct {
@@ -17,6 +20,7 @@ func NewValidator() *OpengistValidator {
_ = v.RegisterValidation("alphanumdash", validateAlphaNumDash)
_ = v.RegisterValidation("alphanumdashorempty", validateAlphaNumDashOrEmpty)
_ = v.RegisterValidation("gisttopics", validateGistTopics)
_ = v.RegisterValidation("expirationdate", validateExpirationDate)
return &OpengistValidator{v}
}
@@ -49,6 +53,8 @@ func ValidationMessages(err *error, locale *i18n.Locale) string {
messages[i] = locale.String("validation.invalid", e.Field())
case "gisttopics":
messages[i] = locale.String("validation.invalid-gist-topics")
case "expirationdate":
messages[i] = locale.String("validation.invalid-expiration-date")
}
}
@@ -99,3 +105,36 @@ func validateGistTopics(fl validator.FieldLevel) bool {
return true
}
var dateTimeLayouts = []string{
"2006-01-02T15:04",
"2006-01-02T15:04:05",
time.RFC3339,
}
func validateExpirationDate(fl validator.FieldLevel) bool {
expire := fl.Parent().FieldByName("Expire")
if !expire.IsValid() || expire.String() != "custom" {
return true
}
value := strings.TrimSpace(fl.Field().String())
if value == "" {
return false
}
t, err := ParseDateTime(value)
if err != nil {
return false
}
return t.After(time.Now())
}
func ParseDateTime(value string) (time.Time, error) {
for _, layout := range dateTimeLayouts {
if t, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("invalid datetime: %q", value)
}
+13 -7
View File
@@ -7,42 +7,48 @@ import (
func AdminSyncReposFromFS(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success")
go actions.Run(actions.SyncReposFromFS)
go actions.RunOnce(actions.SyncReposFromFS)
return ctx.RedirectTo("/admin-panel")
}
func AdminSyncReposFromDB(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success")
go actions.Run(actions.SyncReposFromDB)
go actions.RunOnce(actions.SyncReposFromDB)
return ctx.RedirectTo("/admin-panel")
}
func AdminGcRepos(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success")
go actions.Run(actions.GitGcRepos)
go actions.RunOnce(actions.GitGcRepos)
return ctx.RedirectTo("/admin-panel")
}
func AdminSyncGistPreviews(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success")
go actions.Run(actions.SyncGistPreviews)
go actions.RunOnce(actions.SyncGistPreviews)
return ctx.RedirectTo("/admin-panel")
}
func AdminResetHooks(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success")
go actions.Run(actions.ResetHooks)
go actions.RunOnce(actions.ResetHooks)
return ctx.RedirectTo("/admin-panel")
}
func AdminIndexGists(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success")
go actions.Run(actions.IndexGists)
go actions.RunOnce(actions.IndexGists)
return ctx.RedirectTo("/admin-panel")
}
func AdminSyncGistLanguages(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.sync-gist-languages"), "success")
go actions.Run(actions.SyncGistLanguages)
go actions.RunOnce(actions.SyncGistLanguages)
return ctx.RedirectTo("/admin-panel")
}
func AdminDeleteExpiredGists(ctx *context.Context) error {
ctx.AddFlash(ctx.Tr("flash.admin.delete-expired-gists"), "success")
go actions.RunOnce(actions.DeleteExpiredGists)
return ctx.RedirectTo("/admin-panel")
}
+5 -7
View File
@@ -1,15 +1,16 @@
package admin
import (
"runtime"
"strconv"
"time"
"github.com/thomiceli/opengist/internal/actions"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
"runtime"
"strconv"
"time"
)
func AdminIndex(ctx *context.Context) error {
@@ -49,6 +50,7 @@ func AdminIndex(ctx *context.Context) error {
ctx.SetData("resetHooks", actions.IsRunning(actions.ResetHooks))
ctx.SetData("indexGists", actions.IsRunning(actions.IndexGists))
ctx.SetData("syncGistLanguages", actions.IsRunning(actions.SyncGistLanguages))
ctx.SetData("deleteExpiredGists", actions.IsRunning(actions.DeleteExpiredGists))
return ctx.Html("admin_index.html")
}
@@ -111,10 +113,6 @@ func AdminGistDelete(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot retrieve gist", err)
}
if err = gist.DeleteRepository(); err != nil {
return ctx.ErrorRes(500, "Cannot delete the repository", err)
}
if err = gist.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete this gist", err)
}
+16
View File
@@ -901,6 +901,11 @@ components:
items: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
expires_at:
type: string
format: date-time
nullable: true
description: When the gist will be automatically deleted, or null when the gist never expires.
GistFile:
type: object
@@ -989,6 +994,17 @@ components:
type: string
enum: [public, unlisted, private]
default: public
expire:
type: string
enum: [never, 1hour, 12hours, 1day, 7days, 15days]
default: never
description: When set, the gist is automatically deleted after the given delay.
expires_at:
type: string
format: date-time
description: >-
A custom expiration date (RFC3339). When set it takes precedence
over `expire`.
files:
type: object
description: Files keyed by filename. At least one entry must have content.
+10 -2
View File
@@ -47,8 +47,14 @@ func CreateGist(ctx *context.Context) error {
dto := &db.GistDTO{
Title: strOrEmpty(req.Title),
Description: strOrEmpty(req.Description),
Expire: db.ExpirationType(strOrEmpty(req.Expire)),
VisibilityDTO: db.VisibilityDTO{Private: db.ParseVisibility(strOrEmpty(req.Visibility))},
}
// An explicit custom date takes precedence over the preset.
if req.ExpiresAt != nil {
dto.Expire = db.ExpiryCustom
dto.ExpireAt = *req.ExpiresAt
}
for _, rawName := range filenames {
f := req.Files[rawName]
if f == nil || f.Content == nil || *f.Content == "" {
@@ -78,6 +84,8 @@ func CreateGist(ctx *context.Context) error {
gist.User = *user
gist.NbFiles = len(dto.Files)
gist.ExpiresAt = dto.ExpiresAtTimestamp()
id, err := uuid.NewRandom()
if err != nil {
return ctx.ErrorJson(500, "uuid generation failed", err)
@@ -95,11 +103,11 @@ func CreateGist(ctx *context.Context) error {
return ctx.ErrorJson(500, "failed to init repo", err)
}
if err := gist.AddAndCommitFiles(&dto.Files); err != nil {
_ = gist.DeleteRepository()
gist.DeleteRepository()
return ctx.ErrorJson(500, "failed to commit files", err)
}
if err := gist.Create(); err != nil {
_ = gist.DeleteRepository()
gist.DeleteRepository()
return ctx.ErrorJson(500, "failed to create gist", err)
}
gist.AddInIndex()
@@ -34,6 +34,7 @@ type GistSimple struct {
Topics []string `json:"topics"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt *time.Time `json:"expires_at"` // null when the gist never expires
}
type Gist struct {
@@ -31,9 +31,16 @@ type GistFileInput struct {
// content are skipped. PATCH: keys must match existing filenames;
// null entry (or empty content+filename) deletes; unknown key with
// content adds a new file.
// - Expire - CREATE: one of never, 1hour, 12hours, 1day, 7days, 15days.
// nil/empty means the gist never expires. It is ignored on PATCH.
// - ExpiresAt sets a custom expiration date on CREATE (RFC3339, e.g.
// 2026-01-02T15:04:05Z). When set it takes precedence over Expire. Ignored
// on PATCH.
type GistInput struct {
Description *string `json:"description,omitempty"`
Files map[string]*GistFileInput `json:"files,omitempty"`
Title *string `json:"title,omitempty"`
Visibility *string `json:"visibility,omitempty"`
Expire *string `json:"expire,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
}
+1
View File
@@ -138,6 +138,7 @@ func ProcessCreate(ctx *context.Context) error {
if isCreate {
gist = dto.ToGist()
gist.ExpiresAt = dto.ExpiresAtTimestamp()
} else {
gist = dto.ToExistingGist(gist)
}
+6
View File
@@ -443,6 +443,12 @@ func gistInit(next Handler) Handler {
return ctx.NotFound("Gist not found")
}
// Expired gists are removed by a background job, but it may not have run
// yet so hide them in the meantime so they're never served past expiry.
if gist.IsExpired() {
return ctx.NotFound("Gist not found")
}
if gist.Private == db.PrivateVisibility {
if currUser == nil || currUser.ID != gist.UserID {
// Check for token-based auth via Authorization header
+1
View File
@@ -97,6 +97,7 @@ func (s *Server) registerRoutes() {
sB.POST("/reset-hooks", admin.AdminResetHooks)
sB.POST("/index-gists", admin.AdminIndexGists)
sB.POST("/sync-languages", admin.AdminSyncGistLanguages)
sB.POST("/delete-expired-gists", admin.AdminDeleteExpiredGists)
sB.GET("/configuration", admin.AdminConfig)
sB.PUT("/set-config", admin.AdminSetConfig)
}
+10
View File
@@ -179,6 +179,16 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
const expireselect = document.getElementById('expire') as HTMLSelectElement | null;
const expireat = document.getElementById('expire_at') as HTMLInputElement | null;
if (expireselect && expireat) {
const toggleExpireAt = () => {
expireat.classList.toggle('hidden', expireselect.value !== 'custom');
};
expireselect.addEventListener('change', toggleExpireAt);
toggleExpireAt();
}
const searchinput = document.getElementById('search') as HTMLInputElement;
searchinput.addEventListener('focusin', () => {
document.getElementById('search-help').classList.remove('hidden');
+1
View File
@@ -93,6 +93,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.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>
{{ if .gist.Topics }}
+6
View File
@@ -98,6 +98,12 @@
{{ .locale.Tr "admin.actions.sync-gist-languages" }}
</button>
</form>
<form action="{{ $.c.ExternalUrl }}/admin-panel/delete-expired-gists" method="POST">
{{ .csrfHtml }}
<button type="submit" {{ if .deleteExpiredGists }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .deleteExpiredGists }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
{{ .locale.Tr "admin.actions.delete-expired-gists" }}
</button>
</form>
</div>
</div>
</div>
+15 -1
View File
@@ -59,9 +59,23 @@
<div id="uploaded-files" class="space-y-2"></div>
</div>
<div class="flex">
<div class="flex items-center">
<button type="button" id="add-file" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-gray-700 dark:text-white bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">{{ .locale.Tr "gist.new.add-file" }}</button>
<div class="ml-4 inline-flex items-center">
<label for="expire" class="text-sm text-gray-500 dark:text-gray-400 mr-2 whitespace-nowrap">{{ .locale.Tr "gist.new.expire" }}</label>
<select name="expire" id="expire" class="bg-white dark:bg-black shadow-sm focus:ring-primary-500 focus:border-primary-500 block sm:text-sm border-gray-200 dark:border-gray-700 rounded-md">
<option value="never" {{ if or (not .dto.Expire) (eq (toStr .dto.Expire) "never") }}selected{{ end }}>{{ .locale.Tr "gist.new.expire-never" }}</option>
<option value="1hour" {{ if eq (toStr .dto.Expire) "1hour" }}selected{{ end }}>{{ .locale.Tr "gist.new.expire-1hour" }}</option>
<option value="12hours" {{ if eq (toStr .dto.Expire) "12hours" }}selected{{ end }}>{{ .locale.Tr "gist.new.expire-12hours" }}</option>
<option value="1day" {{ if eq (toStr .dto.Expire) "1day" }}selected{{ end }}>{{ .locale.Tr "gist.new.expire-1day" }}</option>
<option value="7days" {{ if eq (toStr .dto.Expire) "7days" }}selected{{ end }}>{{ .locale.Tr "gist.new.expire-7days" }}</option>
<option value="15days" {{ if eq (toStr .dto.Expire) "15days" }}selected{{ end }}>{{ .locale.Tr "gist.new.expire-15days" }}</option>
<option value="custom" {{ if eq (toStr .dto.Expire) "custom" }}selected{{ end }}>{{ .locale.Tr "gist.new.expire-custom" }}</option>
</select>
<input type="datetime-local" name="expire_at" id="expire_at" value="{{ .dto.ExpireAt }}" class="ml-2 bg-white dark:bg-black shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm border-gray-200 dark:border-gray-700 rounded-md {{ if ne (toStr .dto.Expire) "custom" }}hidden{{ end }}">
</div>
<div class="ml-auto inline-flex ">
<button id="submit-gist" type="submit" name="private" value="0" class="ml-2 items-center px-4 py-2 border border-transparent border-primary-200 dark:border-primary-700 text-sm font-medium rounded-l-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 z-20">{{ .locale.Tr "gist.new.create-public-button" }}</button>
<div class="relative -ml-px block">