mirror of
https://github.com/thomiceli/opengist.git
synced 2026-06-23 04:10:18 +00:00
Gist expiration + scheduled actions (#726)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
This commit is contained in:
@@ -36,3 +36,14 @@ git push -o visibility=private
|
|||||||
```shell
|
```shell
|
||||||
git push -o topics="golang devops"
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ require (
|
|||||||
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
|
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
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/rs/zerolog v1.35.1
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/urfave/cli/v2 v2.27.7
|
github.com/urfave/cli/v2 v2.27.7
|
||||||
|
|||||||
@@ -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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
|
|||||||
+70
-50
@@ -1,21 +1,20 @@
|
|||||||
package actions
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/thomiceli/opengist/internal/config"
|
"github.com/thomiceli/opengist/internal/config"
|
||||||
"github.com/thomiceli/opengist/internal/db"
|
"github.com/thomiceli/opengist/internal/db"
|
||||||
"github.com/thomiceli/opengist/internal/git"
|
"github.com/thomiceli/opengist/internal/git"
|
||||||
"github.com/thomiceli/opengist/internal/index"
|
"github.com/thomiceli/opengist/internal/index"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ActionStatus struct {
|
|
||||||
Running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SyncReposFromFS = iota
|
SyncReposFromFS = iota
|
||||||
SyncReposFromDB
|
SyncReposFromDB
|
||||||
@@ -24,63 +23,71 @@ const (
|
|||||||
ResetHooks
|
ResetHooks
|
||||||
IndexGists
|
IndexGists
|
||||||
SyncGistLanguages
|
SyncGistLanguages
|
||||||
|
DeleteExpiredGists
|
||||||
|
|
||||||
|
numActions // keep last — sizes the `running` array
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// running tracks which actions are in progress in this instance, one slot per
|
||||||
mutex sync.Mutex
|
// action type. It dedupes concurrent runs (e.g. a double-clicked admin button)
|
||||||
actions = make(map[int]ActionStatus)
|
// 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) {
|
const lockLease = time.Hour
|
||||||
actions[actionType] = ActionStatus{
|
|
||||||
Running: running,
|
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 {
|
func IsRunning(actionType int) bool {
|
||||||
mutex.Lock()
|
return actionType >= 0 && actionType < numActions && running[actionType].Load()
|
||||||
defer mutex.Unlock()
|
|
||||||
return actions[actionType].Running
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(actionType int) {
|
func RunOnce(actionType int) {
|
||||||
mutex.Lock()
|
a, ok := registry[actionType]
|
||||||
|
if !ok {
|
||||||
if actions[actionType].Running {
|
log.Error().Msgf("Unknown action type %d", actionType)
|
||||||
mutex.Unlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateActionStatus(actionType, true)
|
if !running[actionType].CompareAndSwap(false, true) {
|
||||||
mutex.Unlock()
|
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() {
|
defer func() {
|
||||||
mutex.Lock()
|
if err := db.ReleaseLock(actionType); err != nil {
|
||||||
updateActionStatus(actionType, false)
|
log.Error().Err(err).Msgf("Could not release lock for action %d", actionType)
|
||||||
mutex.Unlock()
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var functionToRun func()
|
log.Info().Msgf("Starting running action %d", actionType)
|
||||||
switch actionType {
|
a.run()
|
||||||
case SyncReposFromFS:
|
log.Info().Msgf("Finished running action %d", actionType)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncReposFromFS() {
|
func syncReposFromFS() {
|
||||||
@@ -136,6 +143,7 @@ func syncGistPreviews() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, gist := range gists {
|
for _, gist := range gists {
|
||||||
|
fmt.Println("Syncing preview for gist", gist.ID)
|
||||||
if err = gist.UpdatePreviewAndCount(false); err != nil {
|
if err = gist.UpdatePreviewAndCount(false); err != nil {
|
||||||
log.Error().Err(err).Msgf("Cannot update preview and count for gist %d", gist.ID)
|
log.Error().Err(err).Msgf("Cannot update preview and count for gist %d", gist.ID)
|
||||||
}
|
}
|
||||||
@@ -188,3 +196,15 @@ func syncGistLanguages() {
|
|||||||
gist.UpdateLanguages()
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/thomiceli/opengist/internal/actions"
|
||||||
"github.com/thomiceli/opengist/internal/auth/webauthn"
|
"github.com/thomiceli/opengist/internal/auth/webauthn"
|
||||||
"github.com/thomiceli/opengist/internal/config"
|
"github.com/thomiceli/opengist/internal/config"
|
||||||
"github.com/thomiceli/opengist/internal/db"
|
"github.com/thomiceli/opengist/internal/db"
|
||||||
@@ -41,6 +42,7 @@ var CmdStart = cli.Command{
|
|||||||
httpServer := server.NewServer(os.Getenv("OG_DEV") == "1")
|
httpServer := server.NewServer(os.Getenv("OG_DEV") == "1")
|
||||||
go httpServer.Start()
|
go httpServer.Start()
|
||||||
go ssh.Start()
|
go ssh.Start()
|
||||||
|
stopCron := actions.StartCron()
|
||||||
|
|
||||||
var metricsServer *metrics.Server
|
var metricsServer *metrics.Server
|
||||||
if config.C.MetricsEnabled {
|
if config.C.MetricsEnabled {
|
||||||
@@ -49,6 +51,7 @@ var CmdStart = cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
<-stopCtx.Done()
|
<-stopCtx.Done()
|
||||||
|
stopCron()
|
||||||
shutdown(httpServer, metricsServer)
|
shutdown(httpServer, metricsServer)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -155,7 +155,7 @@ func Setup(dbUri string) error {
|
|||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-18
@@ -85,6 +85,7 @@ type Gist struct {
|
|||||||
NbForks int
|
NbForks int
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
|
ExpiresAt int64 // 0: never expires
|
||||||
|
|
||||||
Likes []User `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Likes []User `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
Forked *Gist `gorm:"foreignKey:ForkedID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
|
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 {
|
func (gist *Gist) BeforeDelete(tx *gorm.DB) error {
|
||||||
|
gist.DeleteRepository()
|
||||||
|
gist.RemoveFromIndex()
|
||||||
// Decrement fork counter if the gist was forked
|
// Decrement fork counter if the gist was forked
|
||||||
err := tx.Model(&Gist{}).
|
err := tx.Model(&Gist{}).
|
||||||
Omit("updated_at").
|
Omit("updated_at").
|
||||||
@@ -487,11 +490,6 @@ func (gist *Gist) UpdateNoTimestamps() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (gist *Gist) Delete() error {
|
func (gist *Gist) Delete() error {
|
||||||
err := gist.DeleteRepository()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return db.Delete(&gist).Error
|
return db.Delete(&gist).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,8 +576,11 @@ func (gist *Gist) InitRepository() error {
|
|||||||
return git.InitRepository(gist.User.Username, gist.Uuid)
|
return git.InitRepository(gist.User.Username, gist.Uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gist *Gist) DeleteRepository() error {
|
func (gist *Gist) DeleteRepository() {
|
||||||
return git.DeleteRepository(gist.User.Username, gist.Uuid)
|
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) {
|
func (gist *Gist) Files(revision string, truncate bool) ([]*git.File, bool, error) {
|
||||||
@@ -970,17 +971,19 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
|
|||||||
// -- DTO -- //
|
// -- DTO -- //
|
||||||
|
|
||||||
type GistDTO struct {
|
type GistDTO struct {
|
||||||
Title string `validate:"max=250" form:"title"`
|
Title string `validate:"max=250" form:"title"`
|
||||||
Description string `validate:"max=1000" form:"description"`
|
Description string `validate:"max=1000" form:"description"`
|
||||||
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
|
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
|
||||||
Files []FileDTO `validate:"min=1,dive"`
|
Files []FileDTO `validate:"min=1,dive"`
|
||||||
Name []string `form:"name"`
|
Name []string `form:"name"`
|
||||||
Content []string `form:"content"`
|
Content []string `form:"content"`
|
||||||
Topics string `validate:"gisttopics" form:"topics"`
|
Topics string `validate:"gisttopics" form:"topics"`
|
||||||
UploadedFilesUUID []string `validate:"omitempty,dive,required,uuid" form:"uploadedfile_uuid"`
|
UploadedFilesUUID []string `validate:"omitempty,dive,required,uuid" form:"uploadedfile_uuid"`
|
||||||
UploadedFilesNames []string `validate:"omitempty,dive,required" form:"uploadedfile_filename"`
|
UploadedFilesNames []string `validate:"omitempty,dive,required" form:"uploadedfile_filename"`
|
||||||
BinaryFileOldName []string `form:"binary_old_name"`
|
BinaryFileOldName []string `form:"binary_old_name"`
|
||||||
BinaryFileNewName []string `form:"binary_new_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
|
VisibilityDTO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ func (gist *Gist) ToAPISimple(baseURL string) types.GistSimple {
|
|||||||
if u, err := url.Parse(baseURL); err == nil {
|
if u, err := url.Parse(baseURL); err == nil {
|
||||||
sshHost = u.Host
|
sshHost = u.Host
|
||||||
}
|
}
|
||||||
|
var expiresAt *time.Time
|
||||||
|
if gist.ExpiresAt > 0 {
|
||||||
|
t := time.Unix(gist.ExpiresAt, 0).UTC()
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
return types.GistSimple{
|
return types.GistSimple{
|
||||||
ID: gist.Uuid,
|
ID: gist.Uuid,
|
||||||
Owner: gist.User.ToSimpleAPI(),
|
Owner: gist.User.ToSimpleAPI(),
|
||||||
@@ -34,6 +39,7 @@ func (gist *Gist) ToAPISimple(baseURL string) types.GistSimple {
|
|||||||
Topics: gist.TopicsSlice(),
|
Topics: gist.TopicsSlice(),
|
||||||
CreatedAt: time.Unix(gist.CreatedAt, 0).UTC(),
|
CreatedAt: time.Unix(gist.CreatedAt, 0).UTC(),
|
||||||
UpdatedAt: time.Unix(gist.UpdatedAt, 0).UTC(),
|
UpdatedAt: time.Unix(gist.UpdatedAt, 0).UTC(),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,14 +3,16 @@ package hooks
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/thomiceli/opengist/internal/db"
|
|
||||||
"github.com/thomiceli/opengist/internal/git"
|
|
||||||
validatorpkg "github.com/thomiceli/opengist/internal/validator"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"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 {
|
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 {
|
if hasNoCommits, err := git.HasNoCommits(gist.User.Username, gist.Uuid); err != nil {
|
||||||
_, _ = fmt.Fprintln(er, "Failed to check if gist has no commits")
|
_, _ = fmt.Fprintln(er, "Failed to check if gist has no commits")
|
||||||
return fmt.Errorf("failed to check if gist has no commits: %w", err)
|
return fmt.Errorf("failed to check if gist has no commits: %w", err)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ gist.header.edit: Edit
|
|||||||
gist.header.delete: Delete
|
gist.header.delete: Delete
|
||||||
gist.header.forked-from: Forked from
|
gist.header.forked-from: Forked from
|
||||||
gist.header.last-active: Last active
|
gist.header.last-active: Last active
|
||||||
|
gist.header.expires: Expires
|
||||||
gist.header.select-tab: Select a tab
|
gist.header.select-tab: Select a tab
|
||||||
gist.header.code: Code
|
gist.header.code: Code
|
||||||
gist.header.revisions: Revisions
|
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.topics: Topics (separate with spaces)
|
||||||
gist.new.drop-files: Drop files here or click to upload
|
gist.new.drop-files: Drop files here or click to upload
|
||||||
gist.new.any-file-type: Upload any file type
|
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.editing: Editing
|
||||||
gist.edit.edit-gist: Edit %s
|
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.reset-hooks: Reset Git server hooks for all repositories
|
||||||
admin.actions.index-gists: Rebuild search index
|
admin.actions.index-gists: Rebuild search index
|
||||||
admin.actions.sync-gist-languages: Synchronize all gists languages
|
admin.actions.sync-gist-languages: Synchronize all gists languages
|
||||||
|
admin.actions.delete-expired-gists: Delete expired gists
|
||||||
admin.id: ID
|
admin.id: ID
|
||||||
admin.user: User
|
admin.user: User
|
||||||
admin.delete: Delete
|
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.reset-hooks: Resetting Git server hooks for all repositories...
|
||||||
flash.admin.index-gists: Rebuilding search index...
|
flash.admin.index-gists: Rebuilding search index...
|
||||||
flash.admin.sync-gist-languages: Syncing Gist languages...
|
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.username-exists: Username already exists
|
||||||
flash.auth.invalid-credentials: Invalid credentials
|
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.not-enough: Not enough %s
|
||||||
validation.invalid: Invalid %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-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
|
html.title.admin-panel: Admin panel
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package validator
|
package validator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/go-playground/validator/v10"
|
"fmt"
|
||||||
"github.com/thomiceli/opengist/internal/i18n"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/thomiceli/opengist/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OpengistValidator struct {
|
type OpengistValidator struct {
|
||||||
@@ -17,6 +20,7 @@ func NewValidator() *OpengistValidator {
|
|||||||
_ = v.RegisterValidation("alphanumdash", validateAlphaNumDash)
|
_ = v.RegisterValidation("alphanumdash", validateAlphaNumDash)
|
||||||
_ = v.RegisterValidation("alphanumdashorempty", validateAlphaNumDashOrEmpty)
|
_ = v.RegisterValidation("alphanumdashorempty", validateAlphaNumDashOrEmpty)
|
||||||
_ = v.RegisterValidation("gisttopics", validateGistTopics)
|
_ = v.RegisterValidation("gisttopics", validateGistTopics)
|
||||||
|
_ = v.RegisterValidation("expirationdate", validateExpirationDate)
|
||||||
return &OpengistValidator{v}
|
return &OpengistValidator{v}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +53,8 @@ func ValidationMessages(err *error, locale *i18n.Locale) string {
|
|||||||
messages[i] = locale.String("validation.invalid", e.Field())
|
messages[i] = locale.String("validation.invalid", e.Field())
|
||||||
case "gisttopics":
|
case "gisttopics":
|
||||||
messages[i] = locale.String("validation.invalid-gist-topics")
|
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
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,42 +7,48 @@ import (
|
|||||||
|
|
||||||
func AdminSyncReposFromFS(ctx *context.Context) error {
|
func AdminSyncReposFromFS(ctx *context.Context) error {
|
||||||
ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success")
|
ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success")
|
||||||
go actions.Run(actions.SyncReposFromFS)
|
go actions.RunOnce(actions.SyncReposFromFS)
|
||||||
return ctx.RedirectTo("/admin-panel")
|
return ctx.RedirectTo("/admin-panel")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminSyncReposFromDB(ctx *context.Context) error {
|
func AdminSyncReposFromDB(ctx *context.Context) error {
|
||||||
ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success")
|
ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success")
|
||||||
go actions.Run(actions.SyncReposFromDB)
|
go actions.RunOnce(actions.SyncReposFromDB)
|
||||||
return ctx.RedirectTo("/admin-panel")
|
return ctx.RedirectTo("/admin-panel")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminGcRepos(ctx *context.Context) error {
|
func AdminGcRepos(ctx *context.Context) error {
|
||||||
ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success")
|
ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success")
|
||||||
go actions.Run(actions.GitGcRepos)
|
go actions.RunOnce(actions.GitGcRepos)
|
||||||
return ctx.RedirectTo("/admin-panel")
|
return ctx.RedirectTo("/admin-panel")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminSyncGistPreviews(ctx *context.Context) error {
|
func AdminSyncGistPreviews(ctx *context.Context) error {
|
||||||
ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success")
|
ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success")
|
||||||
go actions.Run(actions.SyncGistPreviews)
|
go actions.RunOnce(actions.SyncGistPreviews)
|
||||||
return ctx.RedirectTo("/admin-panel")
|
return ctx.RedirectTo("/admin-panel")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminResetHooks(ctx *context.Context) error {
|
func AdminResetHooks(ctx *context.Context) error {
|
||||||
ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success")
|
ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success")
|
||||||
go actions.Run(actions.ResetHooks)
|
go actions.RunOnce(actions.ResetHooks)
|
||||||
return ctx.RedirectTo("/admin-panel")
|
return ctx.RedirectTo("/admin-panel")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminIndexGists(ctx *context.Context) error {
|
func AdminIndexGists(ctx *context.Context) error {
|
||||||
ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success")
|
ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success")
|
||||||
go actions.Run(actions.IndexGists)
|
go actions.RunOnce(actions.IndexGists)
|
||||||
return ctx.RedirectTo("/admin-panel")
|
return ctx.RedirectTo("/admin-panel")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminSyncGistLanguages(ctx *context.Context) error {
|
func AdminSyncGistLanguages(ctx *context.Context) error {
|
||||||
ctx.AddFlash(ctx.Tr("flash.admin.sync-gist-languages"), "success")
|
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")
|
return ctx.RedirectTo("/admin-panel")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/thomiceli/opengist/internal/actions"
|
"github.com/thomiceli/opengist/internal/actions"
|
||||||
"github.com/thomiceli/opengist/internal/config"
|
"github.com/thomiceli/opengist/internal/config"
|
||||||
"github.com/thomiceli/opengist/internal/db"
|
"github.com/thomiceli/opengist/internal/db"
|
||||||
"github.com/thomiceli/opengist/internal/git"
|
"github.com/thomiceli/opengist/internal/git"
|
||||||
"github.com/thomiceli/opengist/internal/web/context"
|
"github.com/thomiceli/opengist/internal/web/context"
|
||||||
"github.com/thomiceli/opengist/internal/web/handlers"
|
"github.com/thomiceli/opengist/internal/web/handlers"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func AdminIndex(ctx *context.Context) error {
|
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("resetHooks", actions.IsRunning(actions.ResetHooks))
|
||||||
ctx.SetData("indexGists", actions.IsRunning(actions.IndexGists))
|
ctx.SetData("indexGists", actions.IsRunning(actions.IndexGists))
|
||||||
ctx.SetData("syncGistLanguages", actions.IsRunning(actions.SyncGistLanguages))
|
ctx.SetData("syncGistLanguages", actions.IsRunning(actions.SyncGistLanguages))
|
||||||
|
ctx.SetData("deleteExpiredGists", actions.IsRunning(actions.DeleteExpiredGists))
|
||||||
return ctx.Html("admin_index.html")
|
return ctx.Html("admin_index.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,10 +113,6 @@ func AdminGistDelete(ctx *context.Context) error {
|
|||||||
return ctx.ErrorRes(500, "Cannot retrieve gist", err)
|
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 {
|
if err = gist.Delete(); err != nil {
|
||||||
return ctx.ErrorRes(500, "Cannot delete this gist", err)
|
return ctx.ErrorRes(500, "Cannot delete this gist", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -901,6 +901,11 @@ components:
|
|||||||
items: { type: string }
|
items: { type: string }
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
updated_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:
|
GistFile:
|
||||||
type: object
|
type: object
|
||||||
@@ -989,6 +994,17 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [public, unlisted, private]
|
enum: [public, unlisted, private]
|
||||||
default: public
|
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:
|
files:
|
||||||
type: object
|
type: object
|
||||||
description: Files keyed by filename. At least one entry must have content.
|
description: Files keyed by filename. At least one entry must have content.
|
||||||
|
|||||||
@@ -47,8 +47,14 @@ func CreateGist(ctx *context.Context) error {
|
|||||||
dto := &db.GistDTO{
|
dto := &db.GistDTO{
|
||||||
Title: strOrEmpty(req.Title),
|
Title: strOrEmpty(req.Title),
|
||||||
Description: strOrEmpty(req.Description),
|
Description: strOrEmpty(req.Description),
|
||||||
|
Expire: db.ExpirationType(strOrEmpty(req.Expire)),
|
||||||
VisibilityDTO: db.VisibilityDTO{Private: db.ParseVisibility(strOrEmpty(req.Visibility))},
|
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 {
|
for _, rawName := range filenames {
|
||||||
f := req.Files[rawName]
|
f := req.Files[rawName]
|
||||||
if f == nil || f.Content == nil || *f.Content == "" {
|
if f == nil || f.Content == nil || *f.Content == "" {
|
||||||
@@ -78,6 +84,8 @@ func CreateGist(ctx *context.Context) error {
|
|||||||
gist.User = *user
|
gist.User = *user
|
||||||
gist.NbFiles = len(dto.Files)
|
gist.NbFiles = len(dto.Files)
|
||||||
|
|
||||||
|
gist.ExpiresAt = dto.ExpiresAtTimestamp()
|
||||||
|
|
||||||
id, err := uuid.NewRandom()
|
id, err := uuid.NewRandom()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctx.ErrorJson(500, "uuid generation failed", err)
|
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)
|
return ctx.ErrorJson(500, "failed to init repo", err)
|
||||||
}
|
}
|
||||||
if err := gist.AddAndCommitFiles(&dto.Files); err != nil {
|
if err := gist.AddAndCommitFiles(&dto.Files); err != nil {
|
||||||
_ = gist.DeleteRepository()
|
gist.DeleteRepository()
|
||||||
return ctx.ErrorJson(500, "failed to commit files", err)
|
return ctx.ErrorJson(500, "failed to commit files", err)
|
||||||
}
|
}
|
||||||
if err := gist.Create(); err != nil {
|
if err := gist.Create(); err != nil {
|
||||||
_ = gist.DeleteRepository()
|
gist.DeleteRepository()
|
||||||
return ctx.ErrorJson(500, "failed to create gist", err)
|
return ctx.ErrorJson(500, "failed to create gist", err)
|
||||||
}
|
}
|
||||||
gist.AddInIndex()
|
gist.AddInIndex()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type GistSimple struct {
|
|||||||
Topics []string `json:"topics"`
|
Topics []string `json:"topics"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"` // null when the gist never expires
|
||||||
}
|
}
|
||||||
|
|
||||||
type Gist struct {
|
type Gist struct {
|
||||||
|
|||||||
@@ -31,9 +31,16 @@ type GistFileInput struct {
|
|||||||
// content are skipped. PATCH: keys must match existing filenames;
|
// content are skipped. PATCH: keys must match existing filenames;
|
||||||
// null entry (or empty content+filename) deletes; unknown key with
|
// null entry (or empty content+filename) deletes; unknown key with
|
||||||
// content adds a new file.
|
// 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 {
|
type GistInput struct {
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
Files map[string]*GistFileInput `json:"files,omitempty"`
|
Files map[string]*GistFileInput `json:"files,omitempty"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
Visibility *string `json:"visibility,omitempty"`
|
Visibility *string `json:"visibility,omitempty"`
|
||||||
|
Expire *string `json:"expire,omitempty"`
|
||||||
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ func ProcessCreate(ctx *context.Context) error {
|
|||||||
|
|
||||||
if isCreate {
|
if isCreate {
|
||||||
gist = dto.ToGist()
|
gist = dto.ToGist()
|
||||||
|
gist.ExpiresAt = dto.ExpiresAtTimestamp()
|
||||||
} else {
|
} else {
|
||||||
gist = dto.ToExistingGist(gist)
|
gist = dto.ToExistingGist(gist)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,6 +443,12 @@ func gistInit(next Handler) Handler {
|
|||||||
return ctx.NotFound("Gist not found")
|
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 gist.Private == db.PrivateVisibility {
|
||||||
if currUser == nil || currUser.ID != gist.UserID {
|
if currUser == nil || currUser.ID != gist.UserID {
|
||||||
// Check for token-based auth via Authorization header
|
// Check for token-based auth via Authorization header
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ func (s *Server) registerRoutes() {
|
|||||||
sB.POST("/reset-hooks", admin.AdminResetHooks)
|
sB.POST("/reset-hooks", admin.AdminResetHooks)
|
||||||
sB.POST("/index-gists", admin.AdminIndexGists)
|
sB.POST("/index-gists", admin.AdminIndexGists)
|
||||||
sB.POST("/sync-languages", admin.AdminSyncGistLanguages)
|
sB.POST("/sync-languages", admin.AdminSyncGistLanguages)
|
||||||
|
sB.POST("/delete-expired-gists", admin.AdminDeleteExpiredGists)
|
||||||
sB.GET("/configuration", admin.AdminConfig)
|
sB.GET("/configuration", admin.AdminConfig)
|
||||||
sB.PUT("/set-config", admin.AdminSetConfig)
|
sB.PUT("/set-config", admin.AdminSetConfig)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
const searchinput = document.getElementById('search') as HTMLInputElement;
|
||||||
searchinput.addEventListener('focusin', () => {
|
searchinput.addEventListener('focusin', () => {
|
||||||
document.getElementById('search-help').classList.remove('hidden');
|
document.getElementById('search-help').classList.remove('hidden');
|
||||||
|
|||||||
Vendored
+1
@@ -93,6 +93,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
<p class="mt-1 max-w-2xl text-sm text-slate-500">{{ .locale.Tr "gist.header.last-active" }} <span> {{ .gist.UpdatedAt | humanTimeDiff }} </span>
|
<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.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>
|
||||||
<p class="mt-1 text-sm max-w-2xl text-slate-600 dark:text-slate-400">{{ .gist.Description }}</p>
|
<p class="mt-1 text-sm max-w-2xl text-slate-600 dark:text-slate-400">{{ .gist.Description }}</p>
|
||||||
{{ if .gist.Topics }}
|
{{ if .gist.Topics }}
|
||||||
|
|||||||
Vendored
+6
@@ -98,6 +98,12 @@
|
|||||||
{{ .locale.Tr "admin.actions.sync-gist-languages" }}
|
{{ .locale.Tr "admin.actions.sync-gist-languages" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+15
-1
@@ -59,9 +59,23 @@
|
|||||||
<div id="uploaded-files" class="space-y-2"></div>
|
<div id="uploaded-files" class="space-y-2"></div>
|
||||||
</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>
|
<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 ">
|
<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>
|
<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">
|
<div class="relative -ml-px block">
|
||||||
|
|||||||
Reference in New Issue
Block a user