mirror of
https://github.com/thomiceli/opengist.git
synced 2026-06-22 20:00:14 +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
|
||||
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/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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
"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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+21
-18
@@ -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) {
|
||||
@@ -970,17 +971,19 @@ func (gist *Gist) ToDTO() (*GistDTO, error) {
|
||||
// -- DTO -- //
|
||||
|
||||
type GistDTO struct {
|
||||
Title string `validate:"max=250" form:"title"`
|
||||
Description string `validate:"max=1000" form:"description"`
|
||||
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
|
||||
Files []FileDTO `validate:"min=1,dive"`
|
||||
Name []string `form:"name"`
|
||||
Content []string `form:"content"`
|
||||
Topics string `validate:"gisttopics" form:"topics"`
|
||||
UploadedFilesUUID []string `validate:"omitempty,dive,required,uuid" form:"uploadedfile_uuid"`
|
||||
UploadedFilesNames []string `validate:"omitempty,dive,required" form:"uploadedfile_filename"`
|
||||
BinaryFileOldName []string `form:"binary_old_name"`
|
||||
BinaryFileNewName []string `form:"binary_new_name"`
|
||||
Title string `validate:"max=250" form:"title"`
|
||||
Description string `validate:"max=1000" form:"description"`
|
||||
URL string `validate:"max=32,alphanumdashorempty" form:"url"`
|
||||
Files []FileDTO `validate:"min=1,dive"`
|
||||
Name []string `form:"name"`
|
||||
Content []string `form:"content"`
|
||||
Topics string `validate:"gisttopics" form:"topics"`
|
||||
UploadedFilesUUID []string `validate:"omitempty,dive,required,uuid" form:"uploadedfile_uuid"`
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
"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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
|
||||
if isCreate {
|
||||
gist = dto.ToGist()
|
||||
gist.ExpiresAt = dto.ExpiresAtTimestamp()
|
||||
} else {
|
||||
gist = dto.ToExistingGist(gist)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Vendored
+1
@@ -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 }}
|
||||
|
||||
Vendored
+6
@@ -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>
|
||||
|
||||
Vendored
+15
-1
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user