diff --git a/lib/models/models.go b/lib/models/models.go index e5c9291..4d01029 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -41,21 +41,28 @@ type BoxFile struct { } type BoxManifest struct { - Files []BoxFile `json:"files"` - OwnerID string `json:"owner_id,omitempty"` - OwnerUsername string `json:"owner_username,omitempty"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` - RetentionKey string `json:"retention_key"` - RetentionLabel string `json:"retention_label"` - RetentionSecs int64 `json:"retention_seconds"` - PasswordSalt string `json:"password_salt,omitempty"` - PasswordHash string `json:"password_hash,omitempty"` - PasswordHashAlg string `json:"password_hash_alg,omitempty"` - AuthToken string `json:"auth_token,omitempty"` - DisableZip bool `json:"disable_zip,omitempty"` - OneTimeDownload bool `json:"one_time_download,omitempty"` - Consumed bool `json:"consumed,omitempty"` + Files []BoxFile `json:"files"` + OwnerID string `json:"owner_id,omitempty"` + OwnerUsername string `json:"owner_username,omitempty"` + Activity []BoxActivity `json:"activity,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + RetentionKey string `json:"retention_key"` + RetentionLabel string `json:"retention_label"` + RetentionSecs int64 `json:"retention_seconds"` + PasswordSalt string `json:"password_salt,omitempty"` + PasswordHash string `json:"password_hash,omitempty"` + PasswordHashAlg string `json:"password_hash_alg,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + DisableZip bool `json:"disable_zip,omitempty"` + OneTimeDownload bool `json:"one_time_download,omitempty"` + Consumed bool `json:"consumed,omitempty"` +} + +type BoxActivity struct { + At time.Time `json:"at"` + Message string `json:"message"` + Actor string `json:"actor,omitempty"` } type BoxSummary struct { diff --git a/lib/server/account_auth.go b/lib/server/account_auth.go index 275fe0c..0601e78 100644 --- a/lib/server/account_auth.go +++ b/lib/server/account_auth.go @@ -40,6 +40,14 @@ func (app *App) registerAccountRoutes(router *gin.Engine) { protected.POST("/boxes/bulk/delete", app.handleAccountBoxesBulkDelete) protected.POST("/boxes/bulk/bump-expiry", app.handleAccountBoxesBulkBumpExpiry) protected.POST("/boxes/delete-largest", app.handleAccountBoxesDeleteLargest) + protected.GET("/boxes/:id", app.handleAccountBoxManager) + protected.POST("/boxes/:id", app.handleAccountBoxUpdate) + protected.POST("/boxes/:id/extend", app.handleAccountBoxExtend) + protected.POST("/boxes/:id/expire", app.handleAccountBoxExpire) + protected.POST("/boxes/:id/delete", app.handleAccountBoxDelete) + protected.POST("/boxes/:id/password", app.handleAccountBoxPassword) + protected.POST("/boxes/:id/password/remove", app.handleAccountBoxPasswordRemove) + protected.POST("/boxes/:id/files/delete", app.handleAccountBoxFilesDelete) } func (app *App) handleAccountLogin(ctx *gin.Context) { diff --git a/lib/server/account_box_manager.go b/lib/server/account_box_manager.go new file mode 100644 index 0000000..2d20f56 --- /dev/null +++ b/lib/server/account_box_manager.go @@ -0,0 +1,515 @@ +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" +} diff --git a/lib/server/account_box_manager_test.go b/lib/server/account_box_manager_test.go new file mode 100644 index 0000000..310bfba --- /dev/null +++ b/lib/server/account_box_manager_test.go @@ -0,0 +1,219 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "warpbox/lib/boxstore" + "warpbox/lib/metastore" +) + +func TestAccountBoxManagerAdminCanViewAndEdit(t *testing.T) { + app, admin := setupAccountTestApp(t) + router := setupAccountTestRouter(t, app) + session := createAccountTestSession(t, app, admin) + id := "abababababababababababababababab" + createIndexedBox(t, app, id, "", "", 10, false) + + response := getAccountBoxManager(router, session, id) + if response.Code != http.StatusOK { + t.Fatalf("expected manager page, got %d body=%s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "WarpBox Box Manager") { + t.Fatal("expected manager UI") + } + + form := url.Values{"disable_zip": []string{"true"}} + response = postAccountBoxForm(router, session, "/account/boxes/"+id, form) + if response.Code != http.StatusSeeOther { + t.Fatalf("expected update redirect, got %d", response.Code) + } + manifest, err := boxstore.ReadManifest(id) + if err != nil { + t.Fatalf("ReadManifest returned error: %v", err) + } + if !manifest.DisableZip { + t.Fatal("expected sharing rule update") + } +} + +func TestAccountBoxManagerOwnerViewAllowedAndDeniedByPolicy(t *testing.T) { + app, _ := setupAccountTestApp(t) + user, err := app.store.CreateUserWithPassword("owner-view", "owner-view@example.test", "secret", nil) + if err != nil { + t.Fatalf("CreateUserWithPassword returned error: %v", err) + } + router := setupAccountTestRouter(t, app) + session := createAccountTestSession(t, app, user) + id := "bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc" + createIndexedBox(t, app, id, user.ID, user.Username, 10, false) + + response := getAccountBoxManager(router, session, id) + if response.Code != http.StatusOK { + t.Fatalf("expected owner manager page, got %d", response.Code) + } + + app.config.BoxOwnerEditEnabled = false + response = getAccountBoxManager(router, session, id) + if response.Code != http.StatusForbidden { + t.Fatalf("expected owner denied by policy, got %d", response.Code) + } +} + +func TestAccountBoxManagerOwnerRefreshLimits(t *testing.T) { + app, _ := setupAccountTestApp(t) + app.config.BoxOwnerMaxRefreshCount = 1 + app.config.BoxOwnerMaxRefreshAmountSeconds = 60 + app.config.BoxOwnerMaxTotalExpirySeconds = 7200 + user, err := app.store.CreateUserWithPassword("owner-refresh", "owner-refresh@example.test", "secret", nil) + if err != nil { + t.Fatalf("CreateUserWithPassword returned error: %v", err) + } + router := setupAccountTestRouter(t, app) + session := createAccountTestSession(t, app, user) + id := "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + createIndexedBox(t, app, id, user.ID, user.Username, 10, false) + + response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}}) + if response.Code != http.StatusSeeOther { + t.Fatalf("expected owner refresh success, got %d body=%s", response.Code, response.Body.String()) + } + record, ok, err := app.store.GetBoxRecord(id) + if err != nil || !ok { + t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err) + } + if record.RefreshCount != 1 { + t.Fatalf("expected refresh count 1, got %d", record.RefreshCount) + } + + response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}}) + if response.Code != http.StatusOK { + t.Fatalf("expected refresh count rejection render, got %d", response.Code) + } + if !strings.Contains(response.Body.String(), "refresh count") { + t.Fatal("expected refresh count error") + } +} + +func TestAccountBoxManagerOwnerRefreshRejectedOverMaxDuration(t *testing.T) { + app, _ := setupAccountTestApp(t) + app.config.BoxOwnerMaxRefreshAmountSeconds = 60 + user, err := app.store.CreateUserWithPassword("owner-duration", "owner-duration@example.test", "secret", nil) + if err != nil { + t.Fatalf("CreateUserWithPassword returned error: %v", err) + } + router := setupAccountTestRouter(t, app) + session := createAccountTestSession(t, app, user) + id := "dededededededededededededededede" + createIndexedBox(t, app, id, user.ID, user.Username, 10, false) + + response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"120"}}) + if response.Code != http.StatusOK { + t.Fatalf("expected max duration rejection render, got %d", response.Code) + } + if !strings.Contains(response.Body.String(), "maximum single extension") { + t.Fatal("expected max duration error") + } +} + +func TestAccountBoxManagerPasswordSetRemovePermissions(t *testing.T) { + app, _ := setupAccountTestApp(t) + user, err := app.store.CreateUserWithPassword("owner-pass", "owner-pass@example.test", "secret", nil) + if err != nil { + t.Fatalf("CreateUserWithPassword returned error: %v", err) + } + router := setupAccountTestRouter(t, app) + session := createAccountTestSession(t, app, user) + id := "efefefefefefefefefefefefefefefef" + createIndexedBox(t, app, id, user.ID, user.Username, 10, false) + + response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/password", url.Values{"password": []string{"new-secret"}}) + if response.Code != http.StatusSeeOther { + t.Fatalf("expected password set redirect, got %d", response.Code) + } + manifest, err := boxstore.ReadManifest(id) + if err != nil { + t.Fatalf("ReadManifest returned error: %v", err) + } + if manifest.PasswordHash == "" || manifest.AuthToken == "" { + t.Fatal("expected password set") + } + + app.config.BoxOwnerPasswordEditEnabled = false + response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/password/remove", nil) + if response.Code != http.StatusOK { + t.Fatalf("expected password permission render, got %d", response.Code) + } + if !strings.Contains(response.Body.String(), "password editing disabled") { + t.Fatal("expected password permission error") + } +} + +func TestAccountBoxManagerFileDeleteAndBoxDeletePermissions(t *testing.T) { + app, _ := setupAccountTestApp(t) + user, err := app.store.CreateUserWithPassword("owner-delete", "owner-delete@example.test", "secret", nil) + if err != nil { + t.Fatalf("CreateUserWithPassword returned error: %v", err) + } + router := setupAccountTestRouter(t, app) + session := createAccountTestSession(t, app, user) + id := "fafafafafafafafafafafafafafafafa" + createIndexedBox(t, app, id, user.ID, user.Username, 10, false) + manifest, err := boxstore.ReadManifest(id) + if err != nil { + t.Fatalf("ReadManifest returned error: %v", err) + } + fileID := manifest.Files[0].ID + + response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/files/delete", url.Values{"file_ids": []string{fileID}}) + if response.Code != http.StatusSeeOther { + t.Fatalf("expected file delete redirect, got %d", response.Code) + } + manifest, err = boxstore.ReadManifest(id) + if err != nil { + t.Fatalf("ReadManifest returned error: %v", err) + } + if len(manifest.Files) != 0 { + t.Fatalf("expected file removed, got %#v", manifest.Files) + } + + app.config.BoxOwnerEditEnabled = false + response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil) + if response.Code != http.StatusForbidden { + t.Fatalf("expected delete permission denied after policy disabled, got %d", response.Code) + } + + app.config.BoxOwnerEditEnabled = true + response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil) + if response.Code != http.StatusSeeOther { + t.Fatalf("expected box delete redirect, got %d", response.Code) + } + if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) { + t.Fatalf("expected box directory deleted, stat err=%v", err) + } +} + +func getAccountBoxManager(router http.Handler, session metastore.Session, id string) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodGet, "/account/boxes/"+id, nil) + request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token}) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func postAccountBoxForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder { + if values == nil { + values = url.Values{} + } + values.Set("csrf_token", session.CSRFToken) + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token}) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} diff --git a/static/css/account.css b/static/css/account.css index fdd2fa9..ac4a29c 100644 --- a/static/css/account.css +++ b/static/css/account.css @@ -1040,6 +1040,53 @@ textarea:disabled { padding: 8px; } +.box-manager-layout { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 10px; + min-height: min(940px, calc(100vh - 96px)); +} + +.box-manager-grid { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); + gap: 10px; +} + +.box-manager-main, +.box-manager-side { + min-height: 0; + display: grid; + gap: 10px; + align-content: start; +} + +.files-scroll { + height: 430px; + overflow-x: auto; +} + +.policy-pre { + max-height: 220px; + min-height: 160px; +} + +.activity-list-compact { + display: grid; +} + +.activity-list-compact .activity-row { + grid-template-columns: 132px minmax(0, 1fr); +} + +.activity-list-compact .activity-title, +.activity-list-compact .activity-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .scroll-panel { overflow: auto; color: #000000; @@ -1321,6 +1368,10 @@ textarea:disabled { grid-template-columns: 1fr; } + .box-manager-grid { + grid-template-columns: 1fr; + } + .alerts-filterbar { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1460,6 +1511,10 @@ textarea:disabled { height: 420px; } + .files-scroll { + height: 360px; + } + .win98-window-controls { display: none; } diff --git a/templates/account_box_manager.html b/templates/account_box_manager.html new file mode 100644 index 0000000..6b6371d --- /dev/null +++ b/templates/account_box_manager.html @@ -0,0 +1,151 @@ +{{ template "account_shell_start" . }} +
+ {{ template "account_window_titlebar" . }} + + + +
+ {{ if .Error }}{{ end }} + +
+
+

Status

+

{{ .Box.Status }}

+

{{ .Box.Flags }}

+
+
+

Storage

+

{{ .Box.Storage }}

+

{{ len .Files }} files

+
+
+

Expiration

+

{{ .Box.ExpiresAt }}

+
+
+

Owner policy

+

{{ if .Policy.CanEditMetadata }}editable{{ else }}locked{{ end }}{{ if .Policy.CanExtendExpiry }}refreshable{{ else }}no refresh{{ end }}

+
+
+ +
+
+
+
I

Identity

+
+

Box: {{ .Box.ID }}

+

Owner: {{ .Box.Owner }}

+

Created: {{ .Box.CreatedAt }}

+
+
+ +
+
S

Sharing Rules

+ +
+ +
+
F

Files

+
+ {{ template "account_csrf_field" . }} +
+ + + + {{ range .Files }} + + + + + + + + {{ else }} + + {{ end }} + + +
+
+ +
+
+
+
+ + +
+
+ + +
+{{ template "account_shell_end" . }}