feat(models): add box activity tracking

Adds BoxActivity model to track actions taken on a box.
Updates related endpoints and UI for activity feed.
This commit is contained in:
2026-04-30 19:55:32 +03:00
parent e103829870
commit 89c885f637
6 changed files with 970 additions and 15 deletions

View File

@@ -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) {

View File

@@ -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"
}

View File

@@ -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
}