Adds comprehensive data structures for Alert and Box functionality across models.
455 lines
13 KiB
Go
455 lines
13 KiB
Go
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
|
|
}
|