package server import ( "bytes" "encoding/csv" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "warpbox/lib/boxstore" "warpbox/lib/helpers" "warpbox/lib/metastore" "warpbox/lib/models" ) type BoxIndexView struct { PageTitle string WindowTitle string WindowIcon string AccountNav AccountNavView CSRFToken string Filters BoxFiltersView Rows []BoxRowView Stats BoxIndexStats Page int PageSize int Total int TotalPages int HasPrev bool HasNext bool PrevURL string NextURL string CanManage bool PolicySummary string Error string } type BoxFiltersView struct { Query string Owner string Status string Flag string Sort string PageSize int } type BoxIndexStats struct { Visible int Total int Expired int Storage string } type BoxRowView struct { ID string Owner string Status string FileCount int Size string CreatedAt string ExpiresAt string Flags string Policy string CanManage bool ManageURL string OpenURL string } func (app *App) handleAccountBoxes(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } view, err := app.ListBoxes(ctx, actor, boxFiltersFromRequest(ctx), boxPageFromRequest(ctx)) if err != nil { ctx.String(http.StatusForbidden, "Permission denied") return } ctx.HTML(http.StatusOK, "account_boxes.html", view) } func (app *App) handleAccountBoxesBulkExpire(ctx *gin.Context) { app.handleAccountBoxesBulkAction(ctx, app.ExpireBoxes) } func (app *App) handleAccountBoxesBulkDelete(ctx *gin.Context) { app.handleAccountBoxesBulkAction(ctx, app.DeleteBoxes) } func (app *App) handleAccountBoxesBulkBumpExpiry(ctx *gin.Context) { app.handleAccountBoxesBulkAction(ctx, func(ctx *gin.Context, actor metastore.User, ids []string) error { seconds := parsePositiveInt64Default(ctx.PostForm("bump_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds) return app.BumpBoxExpiries(ctx, actor, ids, seconds) }) } func (app *App) handleAccountBoxesDeleteLargest(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } filters := boxFiltersFromRequest(ctx) filters.Sort = "largest" page := metastore.BoxPageRequest{Page: 1, PageSize: 25} boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page) if err != nil { ctx.String(http.StatusForbidden, err.Error()) return } ids := make([]string, 0, 10) for _, row := range boxPage.Rows { if len(ids) == 10 { break } ids = append(ids, row.ID) } if err := app.DeleteBoxes(ctx, actor, ids); err != nil { ctx.String(http.StatusForbidden, err.Error()) return } ctx.Redirect(http.StatusSeeOther, "/account/boxes") } func (app *App) handleAccountBoxesExport(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } page, err := app.visibleBoxRecords(ctx, actor, boxFiltersFromRequest(ctx), metastore.BoxPageRequest{Page: 1, PageSize: 100}) if err != nil { ctx.String(http.StatusForbidden, err.Error()) return } var buffer bytes.Buffer writer := csv.NewWriter(&buffer) _ = writer.Write([]string{"id", "owner", "status", "file_count", "total_size", "created_at", "expires_at", "flags"}) for _, record := range page.Rows { _ = writer.Write([]string{record.ID, record.OwnerUsername, boxStatus(record), strconv.Itoa(record.FileCount), strconv.FormatInt(record.TotalSize, 10), record.CreatedAt.Format(time.RFC3339), record.ExpiresAt.Format(time.RFC3339), boxFlags(record)}) } writer.Flush() ctx.Header("Content-Disposition", `attachment; filename="warpbox-boxes.csv"`) ctx.Data(http.StatusOK, "text/csv; charset=utf-8", buffer.Bytes()) } func (app *App) handleAccountBoxesBulkAction(ctx *gin.Context, action func(*gin.Context, metastore.User, []string) error) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } if err := action(ctx, actor, ctx.PostFormArray("box_ids")); err != nil { ctx.String(http.StatusForbidden, err.Error()) return } ctx.Redirect(http.StatusSeeOther, "/account/boxes") } func (app *App) ListBoxes(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (BoxIndexView, error) { boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page) if err != nil { return BoxIndexView{}, err } rows := make([]BoxRowView, 0, len(boxPage.Rows)) stats := BoxIndexStats{Visible: len(boxPage.Rows), Total: boxPage.Total} totalSize := int64(0) for _, record := range boxPage.Rows { totalSize += record.TotalSize if boxExpired(record) { stats.Expired++ } rows = append(rows, app.boxRowView(ctx, actor, record)) } stats.Storage = helpers.FormatBytes(totalSize) nav := app.accountNavView(ctx, "boxes") nav.AlertCount, nav.AlertSeverity = app.openAlertSummary() return BoxIndexView{ PageTitle: "WarpBox Boxes", WindowTitle: "WarpBox Boxes", WindowIcon: "B", AccountNav: nav, CSRFToken: app.currentCSRFToken(ctx), Filters: BoxFiltersView{Query: filters.Query, Owner: filters.Owner, Status: filters.Status, Flag: filters.Flag, Sort: filters.Sort, PageSize: boxPage.PageSize}, Rows: rows, Stats: stats, Page: boxPage.Page, PageSize: boxPage.PageSize, Total: boxPage.Total, TotalPages: boxPage.TotalPages, HasPrev: boxPage.HasPrev, HasNext: boxPage.HasNext, PrevURL: boxPageURL(ctx, boxPage.PrevPage), NextURL: boxPageURL(ctx, boxPage.NextPage), CanManage: currentAccountPermissions(ctx).AdminBoxesView, PolicySummary: app.boxPolicySummary(), }, nil } func (app *App) ExpireBoxes(ctx *gin.Context, actor metastore.User, ids []string) error { records, err := app.authorizedBoxRecords(ctx, actor, ids) if err != nil { return err } now := time.Now().UTC().Add(-time.Second) for _, record := range records { manifest, err := boxstore.ReadManifest(record.ID) if err == nil { manifest.ExpiresAt = now _ = boxstore.WriteManifest(record.ID, manifest) } record.ExpiresAt = now if err := app.store.UpsertBoxRecord(record); err != nil { return err } } return nil } func (app *App) DeleteBoxes(ctx *gin.Context, actor metastore.User, ids []string) error { records, err := app.authorizedBoxRecords(ctx, actor, ids) if err != nil { return err } for _, record := range records { if err := boxstore.DeleteBox(record.ID); err != nil { return err } if err := app.store.DeleteBoxRecord(record.ID); err != nil { return err } } return nil } func (app *App) BumpBoxExpiries(ctx *gin.Context, actor metastore.User, ids []string, seconds int64) error { if seconds <= 0 { return fmt.Errorf("bump expiry requires a positive duration") } if !app.config.BoxOwnerRefreshEnabled { return fmt.Errorf("box owner refresh policy is disabled") } if app.config.BoxOwnerMaxRefreshAmountSeconds > 0 && seconds > app.config.BoxOwnerMaxRefreshAmountSeconds { return fmt.Errorf("bump expiry exceeds maximum refresh amount") } records, err := app.authorizedBoxRecords(ctx, actor, ids) if err != nil { return err } for _, record := range records { if record.OneTimeDownload { return fmt.Errorf("one-time boxes cannot be refreshed") } if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount { return fmt.Errorf("box refresh count limit reached") } base := record.ExpiresAt if base.IsZero() || time.Now().UTC().After(base) { base = time.Now().UTC() } newExpiry := base.Add(time.Duration(seconds) * time.Second) if app.config.BoxOwnerMaxTotalExpirySeconds > 0 && !record.CreatedAt.IsZero() && newExpiry.After(record.CreatedAt.Add(time.Duration(app.config.BoxOwnerMaxTotalExpirySeconds)*time.Second)) { return fmt.Errorf("bump expiry exceeds maximum total expiry") } manifest, err := boxstore.ReadManifest(record.ID) if err == nil { manifest.ExpiresAt = newExpiry _ = boxstore.WriteManifest(record.ID, manifest) } record.ExpiresAt = newExpiry record.RefreshCount++ if err := app.store.UpsertBoxRecord(record); err != nil { return err } } return nil } func (app *App) visibleBoxRecords(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (metastore.BoxRecordPage, error) { perms := currentAccountPermissions(ctx) if !perms.AdminBoxesView { filters.Owner = actor.ID } return app.store.ListBoxRecords(filters, page) } func (app *App) authorizedBoxRecords(ctx *gin.Context, actor metastore.User, ids []string) ([]metastore.BoxRecord, error) { ids = uniqueNonEmpty(ids) if len(ids) == 0 { return nil, fmt.Errorf("no boxes selected") } perms := currentAccountPermissions(ctx) records := make([]metastore.BoxRecord, 0, len(ids)) for _, id := range ids { record, ok, err := app.store.GetBoxRecord(id) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("box %s not found", id) } if !perms.AdminBoxesView && record.OwnerID != actor.ID { return nil, fmt.Errorf("permission denied") } if !perms.AdminBoxesView && !app.config.BoxOwnerEditEnabled { return nil, fmt.Errorf("box owner edit policy is disabled") } records = append(records, record) } return records, nil } func (app *App) boxRowView(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord) BoxRowView { owner := record.OwnerUsername if owner == "" { owner = "guest" } return BoxRowView{ ID: record.ID, Owner: owner, Status: boxStatus(record), FileCount: record.FileCount, Size: helpers.FormatBytes(record.TotalSize), CreatedAt: formatAdminTime(record.CreatedAt), ExpiresAt: formatAdminTime(record.ExpiresAt), Flags: boxFlags(record), Policy: app.boxRecordPolicy(record), CanManage: currentAccountPermissions(ctx).AdminBoxesView || record.OwnerID == actor.ID, ManageURL: "/account/boxes/" + record.ID, OpenURL: "/box/" + record.ID, } } func (app *App) indexBoxFromManifest(boxID string) { manifest, err := boxstore.ReadManifest(boxID) if err != nil { return } _ = app.store.UpsertBoxRecord(boxRecordFromManifest(boxID, manifest)) } func boxRecordFromManifest(boxID string, manifest models.BoxManifest) metastore.BoxRecord { total := int64(0) names := make([]string, 0, len(manifest.Files)) for _, file := range manifest.Files { total += file.Size names = append(names, file.Name) } return metastore.BoxRecord{ ID: boxID, OwnerID: manifest.OwnerID, OwnerUsername: manifest.OwnerUsername, FileNames: names, FileCount: len(manifest.Files), TotalSize: total, CreatedAt: manifest.CreatedAt, ExpiresAt: manifest.ExpiresAt, PasswordProtected: boxstore.IsPasswordProtected(manifest), OneTimeDownload: manifest.OneTimeDownload, DisableZip: manifest.DisableZip, } } func boxFiltersFromRequest(ctx *gin.Context) metastore.BoxFilters { return metastore.BoxFilters{ Query: strings.TrimSpace(ctx.Query("q")), Owner: emptyAsAll(ctx.Query("owner")), Status: emptyAsAll(ctx.Query("status")), Flag: emptyAsAll(ctx.Query("flag")), Sort: emptyAsNewest(ctx.Query("sort")), } } func boxPageFromRequest(ctx *gin.Context) metastore.BoxPageRequest { page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "25")) return metastore.BoxPageRequest{Page: page, PageSize: pageSize} } func boxStatus(record metastore.BoxRecord) string { if boxExpired(record) { return "expired" } if record.ExpiresAt.IsZero() { return "pending" } return "active" } func boxExpired(record metastore.BoxRecord) bool { return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt) } func boxFlags(record metastore.BoxRecord) string { flags := []string{} if record.PasswordProtected { flags = append(flags, "password") } if record.OneTimeDownload { flags = append(flags, "one-time") } if record.DisableZip { flags = append(flags, "zip disabled") } if boxExpired(record) { flags = append(flags, "expired") } if len(flags) == 0 { return "normal" } return strings.Join(flags, ", ") } func (app *App) boxRecordPolicy(record metastore.BoxRecord) string { if record.OneTimeDownload { return "one-time: no refresh" } if !app.config.BoxOwnerEditEnabled { return "owner edits disabled" } if !app.config.BoxOwnerRefreshEnabled { return "editable, no refresh" } return fmt.Sprintf("editable, refresh %d/%d", record.RefreshCount, app.config.BoxOwnerMaxRefreshCount) } func (app *App) boxPolicySummary() string { if !app.config.BoxOwnerEditEnabled { return "Owners cannot edit boxes by default." } if !app.config.BoxOwnerRefreshEnabled { return "Owners can edit boxes but cannot refresh expiry." } return fmt.Sprintf("Owners can edit and refresh up to %d times by %s.", app.config.BoxOwnerMaxRefreshCount, formatDurationForSettings(app.config.BoxOwnerMaxRefreshAmountSeconds)) } func boxPageURL(ctx *gin.Context, page int) string { query := ctx.Request.URL.Query() query.Set("page", strconv.Itoa(page)) return "/account/boxes?" + query.Encode() } func parsePositiveInt64Default(raw string, fallback int64) int64 { value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil || value <= 0 { return fallback } return value }