Introduce new environment variables to control the behavior of one-time download boxes: - `WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS`: Sets the lifetime of a one-time box after uploads are complete. - `WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE`: Determines whether a box remains available if the ZIP creation or transfer fails. To support these settings, the ZIP delivery process was refactored to use a temporary file. This ensures that a one-time box is only marked as consumed after the file has been successfully transferred to the client, preventing data loss on network interruptions. Additionally, added a `DecorateFiles` helper in the box store to reduce code duplication.
609 lines
17 KiB
Go
609 lines
17 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"warpbox/lib/boxstore"
|
|
"warpbox/lib/config"
|
|
"warpbox/lib/helpers"
|
|
"warpbox/lib/metastore"
|
|
)
|
|
|
|
const adminSessionCookie = "warpbox_admin_session"
|
|
|
|
type adminUserRow struct {
|
|
ID string
|
|
Username string
|
|
Email string
|
|
Tags string
|
|
CreatedAt string
|
|
Disabled bool
|
|
IsCurrent bool
|
|
}
|
|
|
|
type adminTagRow struct {
|
|
ID string
|
|
Name string
|
|
Description string
|
|
Protected bool
|
|
AdminAccess bool
|
|
UploadAllowed bool
|
|
ZipDownloadAllowed bool
|
|
OneTimeDownloadAllowed bool
|
|
RenewableAllowed bool
|
|
MaxFileSizeBytes string
|
|
MaxBoxSizeBytes string
|
|
AllowedExpirySeconds string
|
|
}
|
|
|
|
type adminBoxRow struct {
|
|
ID string
|
|
FileCount int
|
|
TotalSizeLabel string
|
|
CreatedAt string
|
|
ExpiresAt string
|
|
Expired bool
|
|
OneTimeDownload bool
|
|
PasswordProtected bool
|
|
}
|
|
|
|
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
|
admin := router.Group("/admin")
|
|
admin.Use(noStoreAdminHeaders)
|
|
admin.GET("/login", app.handleAdminLogin)
|
|
admin.POST("/login", app.handleAdminLoginPost)
|
|
|
|
protected := admin.Group("")
|
|
protected.Use(app.requireAdminSession)
|
|
protected.POST("/logout", app.handleAdminLogout)
|
|
protected.GET("", app.handleAdminDashboard)
|
|
protected.GET("/", app.handleAdminDashboard)
|
|
protected.GET("/boxes", app.handleAdminBoxes)
|
|
protected.GET("/users", app.handleAdminUsers)
|
|
protected.POST("/users", app.handleAdminUsersPost)
|
|
protected.GET("/tags", app.handleAdminTags)
|
|
protected.POST("/tags", app.handleAdminTagsPost)
|
|
protected.GET("/settings", app.handleAdminSettings)
|
|
protected.POST("/settings", app.handleAdminSettingsPost)
|
|
}
|
|
|
|
func (app *App) handleAdminLogin(ctx *gin.Context) {
|
|
if app.isAdminSessionValid(ctx) {
|
|
ctx.Redirect(http.StatusSeeOther, "/admin")
|
|
return
|
|
}
|
|
app.renderAdminLogin(ctx, "")
|
|
}
|
|
|
|
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
|
if !app.adminLoginEnabled {
|
|
app.renderAdminLogin(ctx, "Administrator login is disabled.")
|
|
return
|
|
}
|
|
|
|
username := strings.TrimSpace(ctx.PostForm("username"))
|
|
password := ctx.PostForm("password")
|
|
user, ok, err := app.store.GetUserByUsername(username)
|
|
if err != nil {
|
|
ctx.String(http.StatusInternalServerError, "Could not load user")
|
|
return
|
|
}
|
|
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
|
|
app.renderAdminLogin(ctx, "The username or password was not accepted.")
|
|
return
|
|
}
|
|
|
|
perms, err := app.permissionsForUser(user)
|
|
if err != nil {
|
|
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
|
return
|
|
}
|
|
if !perms.AdminAccess {
|
|
app.renderAdminLogin(ctx, "This user does not have administrator access.")
|
|
return
|
|
}
|
|
|
|
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
|
|
if err != nil {
|
|
ctx.String(http.StatusInternalServerError, "Could not create session")
|
|
return
|
|
}
|
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
|
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
|
|
ctx.Redirect(http.StatusSeeOther, "/admin")
|
|
}
|
|
|
|
func (app *App) handleAdminLogout(ctx *gin.Context) {
|
|
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
|
|
_ = app.store.DeleteSession(token)
|
|
}
|
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
|
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
|
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
}
|
|
|
|
func (app *App) handleAdminDashboard(ctx *gin.Context) {
|
|
ctx.HTML(http.StatusOK, "admin.html", gin.H{
|
|
"CurrentUser": app.currentAdminUsername(ctx),
|
|
"CSRFToken": app.currentCSRFToken(ctx),
|
|
})
|
|
}
|
|
|
|
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
|
|
return
|
|
}
|
|
|
|
summaries, err := boxstore.ListBoxSummaries()
|
|
if err != nil {
|
|
ctx.String(http.StatusInternalServerError, "Could not list boxes")
|
|
return
|
|
}
|
|
|
|
rows := make([]adminBoxRow, 0, len(summaries))
|
|
totalSize := int64(0)
|
|
expiredCount := 0
|
|
for _, summary := range summaries {
|
|
totalSize += summary.TotalSize
|
|
if summary.Expired {
|
|
expiredCount++
|
|
}
|
|
rows = append(rows, adminBoxRow{
|
|
ID: summary.ID,
|
|
FileCount: summary.FileCount,
|
|
TotalSizeLabel: summary.TotalSizeLabel,
|
|
CreatedAt: formatAdminTime(summary.CreatedAt),
|
|
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
|
Expired: summary.Expired,
|
|
OneTimeDownload: summary.OneTimeDownload,
|
|
PasswordProtected: summary.PasswordProtected,
|
|
})
|
|
}
|
|
|
|
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
|
|
"CurrentUser": app.currentAdminUsername(ctx),
|
|
"Boxes": rows,
|
|
"TotalBoxes": len(rows),
|
|
"TotalStorage": helpers.FormatBytes(totalSize),
|
|
"ExpiredBoxes": expiredCount,
|
|
})
|
|
}
|
|
|
|
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
|
return
|
|
}
|
|
app.renderAdminUsers(ctx, "")
|
|
}
|
|
|
|
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
|
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
|
return
|
|
}
|
|
|
|
if ctx.PostForm("action") == "toggle_disabled" {
|
|
userID := strings.TrimSpace(ctx.PostForm("user_id"))
|
|
user, ok, err := app.store.GetUser(userID)
|
|
if err != nil || !ok {
|
|
app.renderAdminUsers(ctx, "User not found.")
|
|
return
|
|
}
|
|
if current, ok := ctx.Get("adminUser"); ok {
|
|
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
|
|
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
|
|
return
|
|
}
|
|
}
|
|
user.Disabled = !user.Disabled
|
|
if err := app.store.UpdateUser(user); err != nil {
|
|
app.renderAdminUsers(ctx, err.Error())
|
|
return
|
|
}
|
|
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
|
return
|
|
}
|
|
|
|
username := ctx.PostForm("username")
|
|
email := ctx.PostForm("email")
|
|
password := ctx.PostForm("password")
|
|
tagIDs := ctx.PostFormArray("tag_ids")
|
|
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
|
|
app.renderAdminUsers(ctx, err.Error())
|
|
return
|
|
}
|
|
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
|
}
|
|
|
|
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
|
users, err := app.store.ListUsers()
|
|
if err != nil {
|
|
ctx.String(http.StatusInternalServerError, "Could not list users")
|
|
return
|
|
}
|
|
tags, err := app.store.ListTags()
|
|
if err != nil {
|
|
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
|
return
|
|
}
|
|
tagNames := make(map[string]string, len(tags))
|
|
for _, tag := range tags {
|
|
tagNames[tag.ID] = tag.Name
|
|
}
|
|
sort.Slice(users, func(i int, j int) bool {
|
|
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
|
|
})
|
|
|
|
currentID := ""
|
|
if current, ok := ctx.Get("adminUser"); ok {
|
|
if currentUser, ok := current.(metastore.User); ok {
|
|
currentID = currentUser.ID
|
|
}
|
|
}
|
|
|
|
rows := make([]adminUserRow, 0, len(users))
|
|
for _, user := range users {
|
|
names := make([]string, 0, len(user.TagIDs))
|
|
for _, tagID := range user.TagIDs {
|
|
if name := tagNames[tagID]; name != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
rows = append(rows, adminUserRow{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
Tags: strings.Join(names, ", "),
|
|
CreatedAt: formatAdminTime(user.CreatedAt),
|
|
Disabled: user.Disabled,
|
|
IsCurrent: user.ID == currentID,
|
|
})
|
|
}
|
|
|
|
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
|
"CurrentUser": app.currentAdminUsername(ctx),
|
|
"CSRFToken": app.currentCSRFToken(ctx),
|
|
"Users": rows,
|
|
"Tags": tags,
|
|
"Error": errorMessage,
|
|
})
|
|
}
|
|
|
|
func (app *App) handleAdminTags(ctx *gin.Context) {
|
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
|
return
|
|
}
|
|
app.renderAdminTags(ctx, "")
|
|
}
|
|
|
|
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
|
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
|
return
|
|
}
|
|
|
|
perms, err := parseTagPermissions(ctx)
|
|
if err != nil {
|
|
app.renderAdminTags(ctx, err.Error())
|
|
return
|
|
}
|
|
tag := metastore.Tag{
|
|
Name: ctx.PostForm("name"),
|
|
Description: ctx.PostForm("description"),
|
|
Permissions: perms,
|
|
}
|
|
if err := app.store.CreateTag(&tag); err != nil {
|
|
app.renderAdminTags(ctx, err.Error())
|
|
return
|
|
}
|
|
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
|
|
}
|
|
|
|
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
|
|
tags, err := app.store.ListTags()
|
|
if err != nil {
|
|
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
|
return
|
|
}
|
|
sort.Slice(tags, func(i int, j int) bool {
|
|
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
|
|
})
|
|
rows := make([]adminTagRow, 0, len(tags))
|
|
for _, tag := range tags {
|
|
rows = append(rows, adminTagRow{
|
|
ID: tag.ID,
|
|
Name: tag.Name,
|
|
Description: tag.Description,
|
|
Protected: tag.Protected,
|
|
AdminAccess: tag.Permissions.AdminAccess,
|
|
UploadAllowed: tag.Permissions.UploadAllowed,
|
|
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
|
|
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
|
|
RenewableAllowed: tag.Permissions.RenewableAllowed,
|
|
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
|
|
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
|
|
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
|
|
})
|
|
}
|
|
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
|
|
"CurrentUser": app.currentAdminUsername(ctx),
|
|
"CSRFToken": app.currentCSRFToken(ctx),
|
|
"Tags": rows,
|
|
"Error": errorMessage,
|
|
})
|
|
}
|
|
|
|
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
|
return
|
|
}
|
|
app.renderAdminSettings(ctx, "")
|
|
}
|
|
|
|
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
|
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
|
return
|
|
}
|
|
if !app.config.AllowAdminSettingsOverride {
|
|
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
|
|
return
|
|
}
|
|
|
|
for _, def := range config.EditableDefinitions() {
|
|
value := ctx.PostForm(def.Key)
|
|
if def.Type == config.SettingTypeBool {
|
|
value = "false"
|
|
if ctx.PostForm(def.Key) == "true" {
|
|
value = "true"
|
|
}
|
|
}
|
|
if err := app.config.ApplyOverride(def.Key, value); err != nil {
|
|
app.renderAdminSettings(ctx, err.Error())
|
|
return
|
|
}
|
|
if err := app.store.SetSetting(def.Key, value); err != nil {
|
|
app.renderAdminSettings(ctx, err.Error())
|
|
return
|
|
}
|
|
}
|
|
applyBoxstoreRuntimeConfig(app.config)
|
|
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
|
|
}
|
|
|
|
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
|
|
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
|
|
"CurrentUser": app.currentAdminUsername(ctx),
|
|
"CSRFToken": app.currentCSRFToken(ctx),
|
|
"Rows": app.config.SettingRows(),
|
|
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
|
|
"Error": errorMessage,
|
|
})
|
|
}
|
|
|
|
func (app *App) requireAdminSession(ctx *gin.Context) {
|
|
token, err := ctx.Cookie(adminSessionCookie)
|
|
if err != nil {
|
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
session, ok, err := app.store.GetSession(token)
|
|
if err != nil || !ok {
|
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
if !validAdminCSRF(ctx, session) {
|
|
ctx.String(http.StatusForbidden, "Permission denied")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
user, ok, err := app.store.GetUser(session.UserID)
|
|
if err != nil || !ok || user.Disabled {
|
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
perms, err := app.permissionsForUser(user)
|
|
if err != nil || !perms.AdminAccess {
|
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
ctx.Set("adminUser", user)
|
|
ctx.Set("adminPerms", perms)
|
|
ctx.Set("adminCSRFToken", session.CSRFToken)
|
|
ctx.Next()
|
|
}
|
|
|
|
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
|
|
token, err := ctx.Cookie(adminSessionCookie)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
session, ok, err := app.store.GetSession(token)
|
|
if err != nil || !ok {
|
|
return false
|
|
}
|
|
user, ok, err := app.store.GetUser(session.UserID)
|
|
if err != nil || !ok || user.Disabled {
|
|
return false
|
|
}
|
|
perms, err := app.permissionsForUser(user)
|
|
return err == nil && perms.AdminAccess
|
|
}
|
|
|
|
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
|
|
tags, err := app.store.TagsByID(user.TagIDs)
|
|
if err != nil {
|
|
return metastore.EffectivePermissions{}, err
|
|
}
|
|
return metastore.ResolveUserPermissions(app.config, user, tags), nil
|
|
}
|
|
|
|
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
|
|
value, ok := ctx.Get("adminPerms")
|
|
if !ok {
|
|
ctx.String(http.StatusForbidden, "Permission denied")
|
|
return false
|
|
}
|
|
perms, ok := value.(metastore.EffectivePermissions)
|
|
if !ok || !allowed(perms) {
|
|
ctx.String(http.StatusForbidden, "Permission denied")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (app *App) currentAdminUsername(ctx *gin.Context) string {
|
|
if current, ok := ctx.Get("adminUser"); ok {
|
|
if user, ok := current.(metastore.User); ok {
|
|
return user.Username
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (app *App) currentCSRFToken(ctx *gin.Context) string {
|
|
if value, ok := ctx.Get("adminCSRFToken"); ok {
|
|
if token, ok := value.(string); ok {
|
|
return token
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
|
|
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
|
|
"AdminLoginEnabled": app.adminLoginEnabled,
|
|
"Error": errorMessage,
|
|
})
|
|
}
|
|
|
|
func noStoreAdminHeaders(ctx *gin.Context) {
|
|
ctx.Header("Cache-Control", "no-store")
|
|
ctx.Header("Pragma", "no-cache")
|
|
ctx.Header("X-Content-Type-Options", "nosniff")
|
|
ctx.Next()
|
|
}
|
|
|
|
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
|
|
switch ctx.Request.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
return true
|
|
}
|
|
|
|
token := ctx.PostForm("csrf_token")
|
|
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
|
|
}
|
|
|
|
func subtleConstantTimeEqual(a string, b string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
}
|
|
|
|
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
|
|
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
|
|
if err != nil {
|
|
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
|
|
}
|
|
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
|
|
if err != nil {
|
|
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
|
|
}
|
|
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
|
|
if err != nil {
|
|
return metastore.TagPermissions{}, err
|
|
}
|
|
return metastore.TagPermissions{
|
|
UploadAllowed: checkbox(ctx, "upload_allowed"),
|
|
AllowedExpirySeconds: expirySeconds,
|
|
MaxFileSizeBytes: maxFileSize,
|
|
MaxBoxSizeBytes: maxBoxSize,
|
|
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
|
|
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
|
|
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
|
|
AdminAccess: checkbox(ctx, "admin_access"),
|
|
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
|
|
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
|
|
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
|
|
}, nil
|
|
}
|
|
|
|
func checkbox(ctx *gin.Context, name string) bool {
|
|
return ctx.PostForm(name) == "true"
|
|
}
|
|
|
|
func parseOptionalInt64(raw string) (*int64, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
value, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
return nil, errors.New("must be an integer")
|
|
}
|
|
if value < 0 {
|
|
return nil, errors.New("must be at least 0")
|
|
}
|
|
return &value, nil
|
|
}
|
|
|
|
func parseCSVInt64(raw string) ([]int64, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
values := make([]int64, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
value, err := strconv.ParseInt(part, 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
|
|
}
|
|
if value < 0 {
|
|
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
|
|
}
|
|
values = append(values, value)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func optionalInt64Label(value *int64) string {
|
|
if value == nil {
|
|
return "-"
|
|
}
|
|
return strconv.FormatInt(*value, 10)
|
|
}
|
|
|
|
func joinInt64s(values []int64) string {
|
|
if len(values) == 0 {
|
|
return "-"
|
|
}
|
|
parts := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
parts = append(parts, strconv.FormatInt(value, 10))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func formatAdminTime(value time.Time) string {
|
|
if value.IsZero() {
|
|
return "-"
|
|
}
|
|
return value.Local().Format("2006-01-02 15:04:05")
|
|
}
|