Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/cmd/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ func routes(r *web.Engine) *web.Engine {
r.Post("/_api/signin/verify", handlers.VerifySignInCode())
r.Post("/_api/signin/resend", handlers.ResendSignInCode())

// Cancel a scheduled site deletion. Authorised by the unguessable key in the emailed link
// alone, so it must stay reachable without authentication (it only restores access).
if !env.IsSingleHostMode() {
r.Get("/admin/danger-zone/cancel", handlers.CancelTenantDeletion())
}

// Block if it's private tenant with unauthenticated user
r.Use(middlewares.CheckTenantPrivacy())

Expand Down Expand Up @@ -180,6 +186,14 @@ func routes(r *web.Engine) *web.Engine {
// From this step, only Administrators are allowed
ui.Use(middlewares.IsAuthorized(enum.RoleAdministrator))

// Danger Zone — delete the entire site. Hosted multi-tenant only; owner-only is
// enforced inside the handlers.
if !env.IsSingleHostMode() {
ui.Get("/admin/danger-zone", handlers.DangerZonePage())
ui.Delete("/_api/admin/tenant", handlers.RequestTenantDeletion())
ui.Post("/_api/admin/tenant/cancel-deletion", handlers.CancelTenantDeletionByOwner())
}

ui.Get("/admin/export", handlers.Page("Export · Site Settings", "", "Administration/pages/Export.page"))
ui.Get("/admin/export/posts.csv", handlers.ExportPostsToCSV())
ui.Get("/admin/export/backup.zip", handlers.ExportBackupZip())
Expand Down
1 change: 1 addition & 0 deletions app/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func startJobs(ctx context.Context) {
c := cron.New()
_ = c.AddJob(jobs.NewJob(ctx, "PurgeExpiredNotificationsJob", jobs.PurgeExpiredNotificationsJobHandler{}))
_ = c.AddJob(jobs.NewJob(ctx, "EmailSupressionJob", jobs.EmailSupressionJobHandler{}))
_ = c.AddJob(jobs.NewJob(ctx, "DeleteScheduledTenantsJob", jobs.DeleteScheduledTenantsJobHandler{}))

c.Start()
}
Expand Down
132 changes: 132 additions & 0 deletions app/handlers/danger_zone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package handlers

import (
"net/http"
"time"

"github.com/getfider/fider/app/models/cmd"
"github.com/getfider/fider/app/models/entity"
"github.com/getfider/fider/app/models/query"
"github.com/getfider/fider/app/pkg/bus"
"github.com/getfider/fider/app/pkg/env"
"github.com/getfider/fider/app/pkg/rand"
"github.com/getfider/fider/app/pkg/web"
"github.com/getfider/fider/app/tasks"
)

// gracePeriod is how long a site deletion is delayed so the owner can change their mind.
const gracePeriod = time.Hour

// tenantOwner returns the account owner (the lowest-id active administrator) of the current
// tenant. Callers compare the returned user's ID with c.User().ID to gate owner-only actions.
func tenantOwner(c *web.Context) (*entity.User, error) {
q := &query.GetTenantOwner{TenantID: c.Tenant().ID}
if err := bus.Dispatch(c, q); err != nil {
return nil, err
}
return q.Result, nil
}

// DangerZonePage renders the admin Danger Zone (delete entire site).
func DangerZonePage() web.HandlerFunc {
return func(c *web.Context) error {
owner, err := tenantOwner(c)
if err != nil {
return c.Failure(err)
}

return c.Page(http.StatusOK, web.Props{
Page: "Administration/pages/DangerZone.page",
Title: "Danger Zone · Site Settings",
Data: web.Map{
"isOwner": c.User().ID == owner.ID,
"scheduledDeletionAt": c.Tenant().ScheduledDeletionAt,
},
})
}
}

// RequestTenantDeletion schedules deletion of the whole site after a grace period. Only the
// account owner may do this, and only on hosted multi-tenant instances.
func RequestTenantDeletion() web.HandlerFunc {
return func(c *web.Context) error {
if env.IsSingleHostMode() {
return c.Forbidden()
}

owner, err := tenantOwner(c)
if err != nil {
return c.Failure(err)
}
if c.User().ID != owner.ID {
return c.Forbidden()
}

input := new(struct {
Subdomain string `json:"subdomain"`
})
if err := c.Bind(input); err != nil {
return c.BadRequest(web.Map{})
}
if input.Subdomain != c.Tenant().Subdomain {
return c.BadRequest(web.Map{"message": "The subdomain you entered does not match this site."})
}

cancelKey := rand.String(64)
scheduledAt := time.Now().Add(gracePeriod)

if err := bus.Dispatch(c, &cmd.ScheduleTenantDeletion{
TenantID: c.Tenant().ID,
RequestedByUserID: c.User().ID,
CancelKey: cancelKey,
ScheduledAt: scheduledAt,
}); err != nil {
return c.Failure(err)
}

c.Enqueue(tasks.SendDeleteAccountScheduledEmail(owner, c.Tenant().Name, scheduledAt, c.BaseURL(), cancelKey))

return c.Ok(web.Map{"scheduledDeletionAt": scheduledAt})
}
}

// CancelTenantDeletionByOwner cancels a scheduled deletion from the Danger Zone page. Unlike
// the emailed link, this is authorised by the logged-in owner rather than a key.
func CancelTenantDeletionByOwner() web.HandlerFunc {
return func(c *web.Context) error {
if env.IsSingleHostMode() {
return c.Forbidden()
}

owner, err := tenantOwner(c)
if err != nil {
return c.Failure(err)
}
if c.User().ID != owner.ID {
return c.Forbidden()
}

if err := bus.Dispatch(c, &cmd.CancelTenantDeletion{TenantID: c.Tenant().ID}); err != nil {
return c.Failure(err)
}
return c.Ok(web.Map{})
}
}

// CancelTenantDeletion cancels a scheduled deletion. The unguessable key in the query string
// is the sole authorisation, so the emailed cancel link works without the owner being logged
// in (it only ever restores access).
func CancelTenantDeletion() web.HandlerFunc {
return func(c *web.Context) error {
key := c.QueryParam("k")
if key != "" {
byKey := &query.GetTenantByCancelKey{Key: key}
if err := bus.Dispatch(c, byKey); err == nil && byKey.Result != nil {
if err := bus.Dispatch(c, &cmd.CancelTenantDeletion{TenantID: byKey.Result.ID}); err != nil {
return c.Failure(err)
}
}
}
return c.Redirect(c.BaseURL() + "/admin/danger-zone")
}
}
128 changes: 128 additions & 0 deletions app/handlers/danger_zone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package handlers_test

import (
"context"
"net/http"
"testing"
"time"

"github.com/getfider/fider/app/handlers"
"github.com/getfider/fider/app/models/cmd"
"github.com/getfider/fider/app/models/entity"
"github.com/getfider/fider/app/models/query"
. "github.com/getfider/fider/app/pkg/assert"
"github.com/getfider/fider/app/pkg/bus"
"github.com/getfider/fider/app/pkg/mock"
)

// ownerIs registers a GetTenantOwner handler that always returns the given user.
func ownerIs(owner *entity.User) {
bus.AddHandler(func(ctx context.Context, q *query.GetTenantOwner) error {
q.Result = owner
return nil
})
}

func TestRequestTenantDeletion_SingleHostBlocked(t *testing.T) {
RegisterT(t)
ownerIs(mock.JonSnow)

server := mock.NewSingleTenantServer()
code, _ := server.
OnTenant(mock.DemoTenant).
AsUser(mock.JonSnow).
ExecutePost(handlers.RequestTenantDeletion(), `{"subdomain":"demo"}`)

Expect(code).Equals(http.StatusForbidden)
}

func TestRequestTenantDeletion_NonOwnerForbidden(t *testing.T) {
RegisterT(t)
ownerIs(mock.JonSnow) // owner is Jon, but Arya is making the request

server := mock.NewServer()
code, _ := server.
OnTenant(mock.DemoTenant).
AsUser(mock.AryaStark).
ExecutePost(handlers.RequestTenantDeletion(), `{"subdomain":"demo"}`)

Expect(code).Equals(http.StatusForbidden)
}

func TestRequestTenantDeletion_WrongSubdomain(t *testing.T) {
RegisterT(t)
ownerIs(mock.JonSnow)

scheduled := false
bus.AddHandler(func(ctx context.Context, c *cmd.ScheduleTenantDeletion) error {
scheduled = true
return nil
})

server := mock.NewServer()
code, _ := server.
OnTenant(mock.DemoTenant).
AsUser(mock.JonSnow).
ExecutePost(handlers.RequestTenantDeletion(), `{"subdomain":"not-demo"}`)

Expect(code).Equals(http.StatusBadRequest)
Expect(scheduled).IsFalse()
}

func TestRequestTenantDeletion_OwnerSchedules(t *testing.T) {
RegisterT(t)
ownerIs(mock.JonSnow)

var scheduled *cmd.ScheduleTenantDeletion
bus.AddHandler(func(ctx context.Context, c *cmd.ScheduleTenantDeletion) error {
scheduled = c
return nil
})

server := mock.NewServer()
code, _ := server.
OnTenant(mock.DemoTenant).
AsUser(mock.JonSnow).
ExecutePost(handlers.RequestTenantDeletion(), `{"subdomain":"demo"}`)

Expect(code).Equals(http.StatusOK)
Expect(scheduled).IsNotNil()
Expect(scheduled.TenantID).Equals(mock.DemoTenant.ID)
Expect(scheduled.RequestedByUserID).Equals(mock.JonSnow.ID)
Expect(len(scheduled.CancelKey)).Equals(64)
Expect(scheduled.ScheduledAt.After(time.Now())).IsTrue()
}

func TestCancelTenantDeletionByOwner_NonOwnerForbidden(t *testing.T) {
RegisterT(t)
ownerIs(mock.JonSnow)

server := mock.NewServer()
code, _ := server.
OnTenant(mock.DemoTenant).
AsUser(mock.AryaStark).
ExecutePost(handlers.CancelTenantDeletionByOwner(), `{}`)

Expect(code).Equals(http.StatusForbidden)
}

func TestCancelTenantDeletionByOwner_OwnerCancels(t *testing.T) {
RegisterT(t)
ownerIs(mock.JonSnow)

var cancelled *cmd.CancelTenantDeletion
bus.AddHandler(func(ctx context.Context, c *cmd.CancelTenantDeletion) error {
cancelled = c
return nil
})

server := mock.NewServer()
code, _ := server.
OnTenant(mock.DemoTenant).
AsUser(mock.JonSnow).
ExecutePost(handlers.CancelTenantDeletionByOwner(), `{}`)

Expect(code).Equals(http.StatusOK)
Expect(cancelled).IsNotNil()
Expect(cancelled.TenantID).Equals(mock.DemoTenant.ID)
}
Loading
Loading