Adds BoxActivity model to track actions taken on a box. Updates related endpoints and UI for activity feed.
516 lines
16 KiB
Go
516 lines
16 KiB
Go
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"
|
|
}
|