From 9aabd37b5d7367f0eb0262677988c25439d4aa49 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 26 Mar 2026 16:14:22 +0100 Subject: [PATCH] feat: implement project soft-delete with restore and purge - Add DeletedAt field to Project model with XORM soft-delete tag - Replace hard delete with soft-delete in Project.Delete() - Recursively soft-delete all descendant projects via CTE - Add PermanentDelete() for actual cascade deletion (used by purge job and user deletion) - Add RestoreProject() to restore soft-deleted projects and descendants - Add GetDeletedProjects() to list soft-deleted projects for a user - Add background purge cron job (hourly) for projects past 30-day retention - Update user deletion to use PermanentDelete instead of soft-delete --- pkg/initialize/init.go | 1 + pkg/models/project.go | 224 ++++++++++++++++++++++++++++-- pkg/models/project_soft_delete.go | 83 +++++++++++ pkg/models/user_delete.go | 5 +- 4 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 pkg/models/project_soft_delete.go diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index 7c70609ce..e9af0c796 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -125,6 +125,7 @@ func FullInit() { models.RegisterAddTaskToFilterViewCron() user.RegisterTokenCleanupCron() models.RegisterSessionCleanupCron() + models.RegisterSoftDeletedProjectPurgeCron() user.RegisterDeletionNotificationCron() openid.CleanupSavedOpenIDProviders() openid.RegisterEmptyOpenIDTeamCleanupCron() diff --git a/pkg/models/project.go b/pkg/models/project.go index bcb3b9d17..7c4b7a931 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -80,6 +80,9 @@ type Project struct { Expand ProjectExpandable `xorm:"-" json:"-" query:"expand"` MaxPermission Permission `xorm:"-" json:"max_permission"` + // A timestamp when this project was deleted. If null, the project has not been deleted. + DeletedAt *time.Time `xorm:"deleted null" json:"deleted_at"` + // A timestamp when this project was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` // A timestamp when this project was last updated. You cannot change this value. @@ -463,6 +466,7 @@ func getUserProjectsStatement(userID int64, search string) *builder.Builder { builder.Eq{"ul.user_id": userID}, builder.Eq{"l.owner_id": userID}, ), + builder.IsNull{"l.deleted_at"}, } ids := []int64{} @@ -555,7 +559,8 @@ func getAllProjectsForUser(s *xorm.Session, userID int64, opts *projectOptions) baseQuery := querySQLString + ` UNION ALL SELECT p.id, p.title, p.description, p.identifier, p.hex_color, p.owner_id, p.parent_project_id, (ap.is_archived OR p.is_archived) AS is_archived, p.background_file_id, p.background_blur_hash, p.position, p.created, p.updated FROM projects p -INNER JOIN all_projects ap ON p.parent_project_id = ap.id` +INNER JOIN all_projects ap ON p.parent_project_id = ap.id +WHERE p.deleted_at IS NULL` columnStr := strings.Join([]string{ "all_projects.id", @@ -679,13 +684,14 @@ func GetAllParentProjects(s *xorm.Session, projectID int64) (allProjects map[int FROM projects p WHERE - p.id = ? + p.id = ? AND p.deleted_at IS NULL UNION ALL SELECT p.* FROM projects p INNER JOIN all_projects pc ON p.ID = pc.parent_project_id + WHERE p.deleted_at IS NULL ) SELECT DISTINCT * FROM all_projects`, projectID).Find(&allProjects) return @@ -1208,6 +1214,64 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { return &ErrCannotDeleteDefaultProject{ProjectID: p.ID} } + // XORM's Delete() auto-sets deleted_at = NOW() because of the `deleted` tag + _, err = s.ID(p.ID).Delete(&Project{}) + if err != nil { + return + } + + events.DispatchOnCommit(s, &ProjectDeletedEvent{ + Project: p, + Doer: a, + }) + + // Recursively soft-delete all descendant projects + err = softDeleteProjectDescendants(s, p.ID, a) + return +} + +// softDeleteProjectDescendants uses a recursive CTE to find and soft-delete all descendant projects. +func softDeleteProjectDescendants(s *xorm.Session, parentProjectID int64, _ web.Auth) error { + var descendantIDs []int64 + err := s.SQL( + ` +WITH RECURSIVE descendant_ids (id) AS ( + SELECT id + FROM projects + WHERE parent_project_id = ? AND deleted_at IS NULL + UNION ALL + SELECT p.id + FROM projects p + INNER JOIN descendant_ids di ON p.parent_project_id = di.id + WHERE p.deleted_at IS NULL +) +SELECT id FROM descendant_ids`, + parentProjectID, + ).Find(&descendantIDs) + if err != nil { + return fmt.Errorf("failed to find descendant projects for parent ID %d: %w", parentProjectID, err) + } + + if len(descendantIDs) == 0 { + return nil + } + + now := time.Now() + _, err = s.Unscoped(). + In("id", descendantIDs). + Cols("deleted_at"). + Update(&Project{DeletedAt: &now}) + if err != nil { + return fmt.Errorf("failed to soft-delete descendant projects for parent ID %d: %w", parentProjectID, err) + } + + return nil +} + +// PermanentDelete permanently deletes a project and all related entities. +// This is called by the purge job for projects past the retention period. +func (p *Project) PermanentDelete(s *xorm.Session, a web.Auth) (err error) { + // Delete all tasks on that project // Using the loop to make sure all related entities to all tasks are properly deleted as well. tasks, _, _, err := getRawTasksForProjects(s, []*Project{p}, a, &taskSearchOptions{}) @@ -1222,7 +1286,15 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { } } - fullProject, err := GetProjectSimpleByID(s, p.ID) + // Use Unscoped to find the soft-deleted project + fullProject := &Project{} + exists, err := s.Unscoped().Where("id = ?", p.ID).Get(fullProject) + if err != nil { + return + } + if !exists { + return ErrProjectDoesNotExist{ID: p.ID} + } if err != nil { return } @@ -1233,6 +1305,10 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { } // If we're deleting a default project, remove it as default + isDefaultProject, err := p.isDefaultProject(s) + if err != nil { + return err + } if isDefaultProject { _, err = s.Where("default_project_id = ?", p.ID). Cols("default_project_id"). @@ -1282,25 +1358,21 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { return } - // Delete the project - _, err = s.ID(p.ID).Delete(&Project{}) + // Permanently delete the project row (bypass soft-delete) + _, err = s.Unscoped().ID(p.ID).Delete(&Project{}) if err != nil { return } - events.DispatchOnCommit(s, &ProjectDeletedEvent{ - Project: fullProject, - Doer: a, - }) - + // Recursively permanently delete child projects childProjects := []*Project{} - err = s.Where("parent_project_id = ?", fullProject.ID).Find(&childProjects) + err = s.Unscoped().Where("parent_project_id = ?", fullProject.ID).Find(&childProjects) if err != nil { return } for _, child := range childProjects { - err = child.Delete(s, a) + err = child.PermanentDelete(s, a) if err != nil { return } @@ -1309,6 +1381,131 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { return } +// RestoreProject restores a soft-deleted project and all its descendants that were soft-deleted. +func RestoreProject(s *xorm.Session, projectID int64, a web.Auth) (project *Project, err error) { + project = &Project{} + exists, err := s.Unscoped(). + Where("id = ? AND deleted_at IS NOT NULL", projectID). + Get(project) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrProjectDoesNotExist{ID: projectID} + } + + // Check admin permission (using Unscoped so the permission check can find the deleted project) + isAdmin, err := checkProjectAdminUnscoped(s, projectID, a) + if err != nil { + return nil, err + } + if !isAdmin { + return nil, ErrGenericForbidden{} + } + + // Restore the project itself + _, err = s.Unscoped(). + Where("id = ?", projectID). + Cols("deleted_at"). + Update(&Project{}) + if err != nil { + return nil, err + } + + // Restore all descendant projects that are soft-deleted + var descendantIDs []int64 + err = s.SQL( + ` +WITH RECURSIVE descendant_ids (id) AS ( + SELECT id + FROM projects + WHERE parent_project_id = ? + UNION ALL + SELECT p.id + FROM projects p + INNER JOIN descendant_ids di ON p.parent_project_id = di.id +) +SELECT id FROM descendant_ids`, + projectID, + ).Find(&descendantIDs) + if err != nil { + return nil, fmt.Errorf("failed to find descendant projects for restore: %w", err) + } + + if len(descendantIDs) > 0 { + _, err = s.Unscoped(). + In("id", descendantIDs). + Where("deleted_at IS NOT NULL"). + Cols("deleted_at"). + Update(&Project{}) + if err != nil { + return nil, fmt.Errorf("failed to restore descendant projects: %w", err) + } + } + + project.DeletedAt = nil + return project, nil +} + +// checkProjectAdminUnscoped checks if a user has admin permission on a project, +// including soft-deleted projects. +func checkProjectAdminUnscoped(s *xorm.Session, projectID int64, a web.Auth) (bool, error) { + // Check if the user is the owner + project := &Project{} + exists, err := s.Unscoped(). + Where("id = ?", projectID). + Get(project) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + if project.OwnerID == a.GetID() { + return true, nil + } + + // Check direct user shares with admin permission + var count int64 + count, err = s.Where("project_id = ? AND user_id = ? AND `right` = ?", projectID, a.GetID(), PermissionAdmin). + Count(&ProjectUser{}) + if err != nil { + return false, err + } + if count > 0 { + return true, nil + } + + // Check team shares with admin permission + count, err = s.SQL( + `SELECT COUNT(*) FROM team_projects tp + INNER JOIN team_members tm ON tp.team_id = tm.team_id + WHERE tp.project_id = ? AND tm.user_id = ? AND tp.`+"`right`"+` = ?`, + projectID, a.GetID(), PermissionAdmin, + ).Count() + if err != nil { + return false, err + } + + return count > 0, nil +} + +// SoftDeleteRetentionDays is the number of days a soft-deleted project is retained before permanent purge. +const SoftDeleteRetentionDays = 30 + +// GetDeletedProjects returns all soft-deleted projects the user has admin access to. +func GetDeletedProjects(s *xorm.Session, a web.Auth) (projects []*Project, err error) { + projects = []*Project{} + err = s.Unscoped(). + Where("deleted_at IS NOT NULL AND owner_id = ?", a.GetID()). + Find(&projects) + if err != nil { + return nil, err + } + + return projects, nil +} + // DeleteBackgroundFileIfExists deletes the list's background file from the db and the filesystem, // if one exists func (p *Project) DeleteBackgroundFileIfExists(s *xorm.Session) (err error) { @@ -1347,11 +1544,12 @@ func setArchiveStateForProjectDescendants(s *xorm.Session, parentProjectID int64 WITH RECURSIVE descendant_ids (id) AS ( SELECT id FROM projects - WHERE parent_project_id = ? + WHERE parent_project_id = ? AND deleted_at IS NULL UNION ALL SELECT p.id FROM projects p INNER JOIN descendant_ids di ON p.parent_project_id = di.id + WHERE p.deleted_at IS NULL ) SELECT id FROM descendant_ids`, parentProjectID, diff --git a/pkg/models/project_soft_delete.go b/pkg/models/project_soft_delete.go new file mode 100644 index 000000000..4c892b31b --- /dev/null +++ b/pkg/models/project_soft_delete.go @@ -0,0 +1,83 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "time" + + "code.vikunja.io/api/pkg/cron" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/user" +) + +// RegisterSoftDeletedProjectPurgeCron registers the cron job that permanently +// deletes projects whose soft-delete retention period has expired. +func RegisterSoftDeletedProjectPurgeCron() { + err := cron.Schedule("0 * * * *", purgeSoftDeletedProjects) + if err != nil { + log.Errorf("Could not register soft-deleted project purge cron: %s", err.Error()) + } +} + +func purgeSoftDeletedProjects() { + cutoff := time.Now().Add(-SoftDeleteRetentionDays * 24 * time.Hour) + + s := db.NewSession() + defer s.Close() + + var projects []*Project + err := s.Unscoped(). + Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff). + Find(&projects) + if err != nil { + log.Errorf("Could not get soft-deleted projects for purge: %s", err) + return + } + + if len(projects) == 0 { + return + } + + log.Debugf("Found %d soft-deleted projects past retention period for purge", len(projects)) + + for _, p := range projects { + func() { + ps := db.NewSession() + defer ps.Close() + + // Use a system user auth for the permanent delete + doer := &user.User{ID: p.OwnerID} + + err = p.PermanentDelete(ps, doer) + if err != nil { + _ = ps.Rollback() + log.Errorf("Could not permanently delete project %d: %s", p.ID, err) + return + } + + err = ps.Commit() + if err != nil { + _ = ps.Rollback() + log.Errorf("Could not commit permanent deletion of project %d: %s", p.ID, err) + return + } + + log.Debugf("Permanently deleted project %d", p.ID) + }() + } +} diff --git a/pkg/models/user_delete.go b/pkg/models/user_delete.go index f36a5f2f9..92c7b2536 100644 --- a/pkg/models/user_delete.go +++ b/pkg/models/user_delete.go @@ -137,10 +137,11 @@ func DeleteUser(s *xorm.Session, u *user.User) (err error) { for _, p := range projectsToDelete { if p.ParentProjectID != 0 { - // Child projects are deleted by p.Delete + // Child projects are deleted by p.PermanentDelete continue } - err = p.Delete(s, u) + // Use PermanentDelete for user deletion since the user account is being removed entirely + err = p.PermanentDelete(s, u) // If the user is the owner of the default project it will be deleted, if they are not the owner // we can ignore the error as the project was shared in that case. if err != nil && !IsErrCannotDeleteDefaultProject(err) {