package server import ( "encoding/json" "fmt" "net/http" "os" "strings" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "warpbox/lib/boxstore" "warpbox/lib/helpers" "warpbox/lib/metastore" "warpbox/lib/models" ) type BoxManagerView struct { PageTitle string WindowTitle string WindowIcon string AccountNav AccountNavView CSRFToken string Box BoxManagerSummary Files []BoxManagerFileRow Policy BoxActionPolicy PolicyJSON string Activity []BoxManagerActivityRow Error string } type BoxManagerSummary struct { ID string Owner string Status string Storage string CreatedAt string ExpiresAt string Flags string OpenURL string DisableZip bool OneTimeDownload bool } type BoxManagerFileRow struct { ID string Name string Size string Status string Download string } type BoxManagerActivityRow struct { At string Message string Actor string } type BoxActionPolicy struct { CanViewManager bool `json:"can_view_manager"` CanEditMetadata bool `json:"can_edit_metadata"` CanEditSharingRules bool `json:"can_edit_sharing_rules"` CanEditPassword bool `json:"can_edit_password"` CanDeleteBox bool `json:"can_delete_box"` CanDeleteFiles bool `json:"can_delete_files"` CanExtendExpiry bool `json:"can_extend_expiry"` MaxExtensionSeconds int64 `json:"max_extension_seconds"` MaxRefreshCount int `json:"max_refresh_count"` MaxTotalLifetimeSecs int64 `json:"max_total_lifetime_seconds"` Reasons map[string]string `json:"reasons,omitempty"` } type BoxRulesInput struct { DisableZip bool OneTimeDownload bool } type BoxPasswordInput struct { Password string } func (app *App) handleAccountBoxManager(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } view, err := app.GetBoxManager(ctx, actor, ctx.Param("id")) if err != nil { ctx.String(http.StatusForbidden, err.Error()) return } ctx.HTML(http.StatusOK, "account_box_manager.html", view) } func (app *App) handleAccountBoxUpdate(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } input := BoxRulesInput{ DisableZip: ctx.PostForm("disable_zip") == "true", OneTimeDownload: ctx.PostForm("one_time_download") == "true", } if err := app.UpdateBoxRules(ctx, actor, ctx.Param("id"), input); err != nil { app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err) return } ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id")) } func (app *App) handleAccountBoxExtend(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } seconds := parsePositiveInt64Default(ctx.PostForm("extend_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds) if err := app.ExtendBoxExpiry(ctx, actor, ctx.Param("id"), seconds); err != nil { app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err) return } ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id")) } func (app *App) handleAccountBoxExpire(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } if err := app.ExpireBoxNow(ctx, actor, ctx.Param("id")); err != nil { app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err) return } ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id")) } func (app *App) handleAccountBoxDelete(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } if err := app.DeleteBox(ctx, actor, ctx.Param("id")); err != nil { app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err) return } ctx.Redirect(http.StatusSeeOther, "/account/boxes") } func (app *App) handleAccountBoxPassword(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } if err := app.SetBoxPassword(ctx, actor, ctx.Param("id"), BoxPasswordInput{Password: ctx.PostForm("password")}); err != nil { app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err) return } ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id")) } func (app *App) handleAccountBoxPasswordRemove(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } if err := app.RemoveBoxPassword(ctx, actor, ctx.Param("id")); err != nil { app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err) return } ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id")) } func (app *App) handleAccountBoxFilesDelete(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } if err := app.DeleteBoxFiles(ctx, actor, ctx.Param("id"), ctx.PostFormArray("file_ids")); err != nil { app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err) return } ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id")) } func (app *App) GetBoxManager(ctx *gin.Context, actor metastore.User, boxID string) (BoxManagerView, error) { record, manifest, err := app.loadBoxForManager(boxID) if err != nil { return BoxManagerView{}, err } policy := app.resolveBoxPolicy(ctx, actor, record, manifest) if !policy.CanViewManager { return BoxManagerView{}, fmt.Errorf(policyReason(policy, "view", "permission denied")) } files := make([]BoxManagerFileRow, 0, len(manifest.Files)) for _, file := range boxstore.DecorateFiles(boxID, manifest.Files) { files = append(files, BoxManagerFileRow{ID: file.ID, Name: file.Name, Size: file.SizeLabel, Status: file.StatusLabel, Download: file.DownloadPath}) } policyJSON, _ := json.MarshalIndent(policy, "", " ") nav := app.accountNavView(ctx, "boxes") nav.AlertCount, nav.AlertSeverity = app.openAlertSummary() return BoxManagerView{ PageTitle: "WarpBox Box Manager", WindowTitle: "WarpBox Box Manager", WindowIcon: "B", AccountNav: nav, CSRFToken: app.currentCSRFToken(ctx), Box: BoxManagerSummary{ ID: record.ID, Owner: boxOwnerLabel(record), Status: boxStatus(record), Storage: helpers.FormatBytes(record.TotalSize), CreatedAt: formatAdminTime(record.CreatedAt), ExpiresAt: formatAdminTime(record.ExpiresAt), Flags: boxFlags(record), OpenURL: "/box/" + record.ID, DisableZip: record.DisableZip, OneTimeDownload: record.OneTimeDownload, }, Files: files, Policy: policy, PolicyJSON: string(policyJSON), Activity: boxActivityRows(manifest.Activity), }, nil } func (app *App) UpdateBoxRules(ctx *gin.Context, actor metastore.User, boxID string, input BoxRulesInput) error { record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID) if err != nil { return err } if !policy.CanEditSharingRules { return fmt.Errorf(policyReason(policy, "sharing", "sharing edits disabled")) } manifest.DisableZip = input.DisableZip manifest.OneTimeDownload = input.OneTimeDownload appendBoxActivity(&manifest, actor.Username, "sharing rules updated") return app.saveManagedBox(record, manifest) } func (app *App) ExtendBoxExpiry(ctx *gin.Context, actor metastore.User, boxID string, amount int64) error { record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID) if err != nil { return err } if !policy.CanExtendExpiry { return fmt.Errorf(policyReason(policy, "extend", "expiry refresh disabled")) } if amount <= 0 { return fmt.Errorf("extension amount must be positive") } if policy.MaxExtensionSeconds > 0 && amount > policy.MaxExtensionSeconds { return fmt.Errorf("extension exceeds maximum single extension") } if policy.MaxRefreshCount > 0 && record.RefreshCount >= policy.MaxRefreshCount { return fmt.Errorf("refresh count limit reached") } base := manifest.ExpiresAt if base.IsZero() || time.Now().UTC().After(base) { base = time.Now().UTC() } next := base.Add(time.Duration(amount) * time.Second) if policy.MaxTotalLifetimeSecs > 0 && next.After(manifest.CreatedAt.Add(time.Duration(policy.MaxTotalLifetimeSecs)*time.Second)) { return fmt.Errorf("extension exceeds maximum total lifetime") } manifest.ExpiresAt = next record.RefreshCount++ appendBoxActivity(&manifest, actor.Username, "expiry extended") return app.saveManagedBox(record, manifest) } func (app *App) ExpireBoxNow(ctx *gin.Context, actor metastore.User, boxID string) error { record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID) if err != nil { return err } if !policy.CanEditMetadata { return fmt.Errorf(policyReason(policy, "edit", "edit disabled")) } manifest.ExpiresAt = time.Now().UTC().Add(-time.Second) appendBoxActivity(&manifest, actor.Username, "box expired") return app.saveManagedBox(record, manifest) } func (app *App) DeleteBox(ctx *gin.Context, actor metastore.User, boxID string) error { record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID) if err != nil { return err } _ = manifest if !policy.CanDeleteBox { return fmt.Errorf(policyReason(policy, "delete", "delete disabled")) } if err := boxstore.DeleteBox(record.ID); err != nil { return err } return app.store.DeleteBoxRecord(record.ID) } func (app *App) SetBoxPassword(ctx *gin.Context, actor metastore.User, boxID string, input BoxPasswordInput) error { record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID) if err != nil { return err } if !policy.CanEditPassword { return fmt.Errorf(policyReason(policy, "password", "password edits disabled")) } password := strings.TrimSpace(input.Password) if password == "" { return fmt.Errorf("password cannot be empty") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } token, err := helpers.RandomHexID(16) if err != nil { return err } manifest.PasswordHash = string(hash) manifest.PasswordHashAlg = "bcrypt" manifest.AuthToken = token appendBoxActivity(&manifest, actor.Username, "password set") return app.saveManagedBox(record, manifest) } func (app *App) RemoveBoxPassword(ctx *gin.Context, actor metastore.User, boxID string) error { record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID) if err != nil { return err } if !policy.CanEditPassword { return fmt.Errorf(policyReason(policy, "password", "password edits disabled")) } manifest.PasswordHash = "" manifest.PasswordHashAlg = "" manifest.PasswordSalt = "" manifest.AuthToken = "" appendBoxActivity(&manifest, actor.Username, "password removed") return app.saveManagedBox(record, manifest) } func (app *App) DeleteBoxFiles(ctx *gin.Context, actor metastore.User, boxID string, fileIDs []string) error { record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID) if err != nil { return err } if !policy.CanDeleteFiles { return fmt.Errorf(policyReason(policy, "files", "file deletion disabled")) } fileIDs = uniqueNonEmpty(fileIDs) if len(fileIDs) == 0 { return fmt.Errorf("no files selected") } remove := map[string]bool{} for _, id := range fileIDs { remove[id] = true } kept := make([]models.BoxFile, 0, len(manifest.Files)) for _, file := range manifest.Files { if remove[file.ID] { if path, ok := boxstore.SafeBoxFilePath(boxID, file.Name); ok { _ = os.Remove(path) } continue } kept = append(kept, file) } manifest.Files = kept appendBoxActivity(&manifest, actor.Username, "files deleted") return app.saveManagedBox(record, manifest) } func (app *App) renderBoxManagerError(ctx *gin.Context, actor metastore.User, boxID string, actionErr error) { view, err := app.GetBoxManager(ctx, actor, boxID) if err != nil { ctx.String(http.StatusForbidden, actionErr.Error()) return } view.Error = actionErr.Error() ctx.HTML(http.StatusOK, "account_box_manager.html", view) } func (app *App) boxMutationContext(ctx *gin.Context, actor metastore.User, boxID string) (metastore.BoxRecord, models.BoxManifest, BoxActionPolicy, error) { record, manifest, err := app.loadBoxForManager(boxID) if err != nil { return record, manifest, BoxActionPolicy{}, err } policy := app.resolveBoxPolicy(ctx, actor, record, manifest) if !policy.CanViewManager { return record, manifest, policy, fmt.Errorf(policyReason(policy, "view", "permission denied")) } return record, manifest, policy, nil } func (app *App) loadBoxForManager(boxID string) (metastore.BoxRecord, models.BoxManifest, error) { if !boxstore.ValidBoxID(boxID) { return metastore.BoxRecord{}, models.BoxManifest{}, fmt.Errorf("invalid box id") } record, ok, err := app.store.GetBoxRecord(boxID) if err != nil { return record, models.BoxManifest{}, err } if !ok { return record, models.BoxManifest{}, fmt.Errorf("box not found") } manifest, err := boxstore.ReadManifest(boxID) if err != nil { return record, manifest, err } return record, manifest, nil } func (app *App) resolveBoxPolicy(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord, manifest models.BoxManifest) BoxActionPolicy { perms := currentAccountPermissions(ctx) isAdmin := perms.AdminBoxesView isOwner := record.OwnerID != "" && record.OwnerID == actor.ID policy := BoxActionPolicy{ MaxExtensionSeconds: app.config.BoxOwnerMaxRefreshAmountSeconds, MaxRefreshCount: app.config.BoxOwnerMaxRefreshCount, MaxTotalLifetimeSecs: app.config.BoxOwnerMaxTotalExpirySeconds, Reasons: map[string]string{}, } if isAdmin { policy.CanViewManager = true policy.CanEditMetadata = true policy.CanEditSharingRules = true policy.CanEditPassword = true policy.CanDeleteBox = true policy.CanDeleteFiles = true policy.CanExtendExpiry = !manifest.OneTimeDownload return policy } if !isOwner { policy.Reasons["view"] = "not box owner" return policy } if !app.config.BoxOwnerEditEnabled { policy.Reasons["view"] = "box owner editing disabled" return policy } policy.CanViewManager = true policy.CanEditMetadata = true policy.CanEditSharingRules = true policy.CanDeleteBox = true policy.CanDeleteFiles = true if app.config.BoxOwnerPasswordEditEnabled { policy.CanEditPassword = true } else { policy.Reasons["password"] = "password editing disabled by policy" } if !app.config.BoxOwnerRefreshEnabled { policy.Reasons["extend"] = "refresh disabled by policy" } else if manifest.OneTimeDownload { policy.Reasons["extend"] = "one-time boxes cannot be refreshed" } else if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount { policy.Reasons["extend"] = "refresh count limit reached" } else { policy.CanExtendExpiry = true } return policy } func (app *App) saveManagedBox(record metastore.BoxRecord, manifest models.BoxManifest) error { if err := boxstore.WriteManifest(record.ID, manifest); err != nil { return err } next := boxRecordFromManifest(record.ID, manifest) next.RefreshCount = record.RefreshCount return app.store.UpsertBoxRecord(next) } func appendBoxActivity(manifest *models.BoxManifest, actor string, message string) { manifest.Activity = append([]models.BoxActivity{{ At: time.Now().UTC(), Actor: actor, Message: message, }}, manifest.Activity...) if len(manifest.Activity) > 12 { manifest.Activity = manifest.Activity[:12] } } func boxActivityRows(activity []models.BoxActivity) []BoxManagerActivityRow { rows := make([]BoxManagerActivityRow, 0, len(activity)) for _, item := range activity { rows = append(rows, BoxManagerActivityRow{At: formatAdminTime(item.At), Message: item.Message, Actor: item.Actor}) } if len(rows) == 0 { rows = append(rows, BoxManagerActivityRow{At: "-", Message: "No box activity yet.", Actor: "system"}) } return rows } func policyReason(policy BoxActionPolicy, key string, fallback string) string { if policy.Reasons != nil && policy.Reasons[key] != "" { return policy.Reasons[key] } return fallback } func boxOwnerLabel(record metastore.BoxRecord) string { if record.OwnerUsername != "" { return record.OwnerUsername } return "guest" }