Files
vikunja/pkg/models/export.go
kolaente 49bba7f830 fix: eliminate nested database sessions to prevent table locks
Refactor functions that created their own sessions when called from
within existing transactions, which caused "database table is locked"
errors in SQLite's shared-cache mode.

Changes:
- Add files.CreateWithSession() to reuse caller's session
- Refactor DeleteBackgroundFileIfExists() to accept session parameter
- Add variadic session parameter to notifications.Notify() and
  Notifiable.ShouldNotify() interface
- Update all Notify callers (~17 sites) to pass their session through
- Use files.CreateWithSession in SaveBackgroundFile and NewAttachment
- Fix test code to commit sessions before assertions
2026-02-25 11:03:02 +01:00

463 lines
11 KiB
Go

// 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 <https://www.gnu.org/licenses/>.
package models
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"time"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/api/pkg/version"
"code.vikunja.io/api/pkg/web"
"xorm.io/xorm"
)
func ExportUserData(s *xorm.Session, u *user.User) (err error) {
dumpFile, err := os.CreateTemp("", "vikunja-export-*.zip")
if err != nil {
return fmt.Errorf("error creating temp file: %w", err)
}
tmpFilename := dumpFile.Name()
defer dumpFile.Close()
dumpWriter := zip.NewWriter(dumpFile)
defer dumpWriter.Close()
// Get the data
taskIDs, err := exportProjectsAndTasks(s, u, dumpWriter)
if err != nil {
return err
}
// Task attachment files
err = exportTaskAttachments(s, dumpWriter, taskIDs)
if err != nil {
return err
}
// Saved filters
err = exportSavedFilters(s, u, dumpWriter)
if err != nil {
return err
}
// Background files
err = exportProjectBackgrounds(s, u, dumpWriter)
if err != nil {
return err
}
// Vikunja Version
err = utils.WriteBytesToZip("VERSION", []byte(version.Version), dumpWriter)
if err != nil {
return err
}
// If we reuse the same file again, saving it as a file in Vikunja will save it as a file with 0 bytes in size.
// Closing and reopening does work.
dumpWriter.Close()
dumpFile.Close()
exported, err := os.Open(tmpFilename)
if err != nil {
return err
}
stat, err := exported.Stat()
if err != nil {
return err
}
exportFile, err := files.CreateWithMimeAndSession(s, exported, tmpFilename, uint64(stat.Size()), u, "application/zip", false)
if err != nil {
return err
}
// Save the file id with the user
u.ExportFileID = exportFile.ID
_, err = s.
Where("id = ?", u.ID).
Cols("export_file_id").
Update(u)
if err != nil {
return
}
// Remove the old file
err = os.Remove(exported.Name())
if err != nil {
return err
}
// Send a notification
return notifications.Notify(u, &DataExportReadyNotification{
User: u,
}, s)
}
func getRawTasksForExport(s *xorm.Session, projectIDs []int64, a web.Auth) (tasks []*Task, err error) {
if len(projectIDs) == 0 {
return []*Task{}, nil
}
opts := &taskSearchOptions{
projectIDs: projectIDs,
page: 0,
perPage: -1,
sortby: []*sortParam{{
sortBy: taskPropertyID,
orderBy: orderAscending,
}},
}
hasFavoritesProject := false
for _, id := range projectIDs {
if id == FavoritesPseudoProject.ID {
hasFavoritesProject = true
break
}
}
var dbSearcher taskSearcher = &dbTaskSearcher{
s: s,
a: a,
hasFavoritesProject: hasFavoritesProject,
}
tasks, _, err = dbSearcher.Search(opts)
if err != nil {
return nil, err
}
taskMap := make(map[int64]*Task, len(tasks))
for _, t := range tasks {
taskMap[t.ID] = t
}
err = addMoreInfoToTasks(s, taskMap, a, nil, nil)
if err != nil {
return nil, err
}
return tasks, nil
}
func exportProjectsAndTasks(s *xorm.Session, u *user.User, wr *zip.Writer) (taskIDs []int64, err error) {
// Get all projects
rawProjects, _, _, err := getRawProjectsForUser(
s,
&projectOptions{
search: "",
user: u,
page: 0,
perPage: -1,
getArchived: true,
})
if err != nil {
return taskIDs, err
}
if len(rawProjects) == 0 {
return
}
projects := []*ProjectWithTasksAndBuckets{}
projectsMap := make(map[int64]*ProjectWithTasksAndBuckets, len(rawProjects))
projectIDs := []int64{}
for _, p := range rawProjects {
if p.BackgroundFileID > 0 {
p.BackgroundInformation = &files.File{
ID: p.BackgroundFileID,
}
}
pp := &ProjectWithTasksAndBuckets{
Project: *p,
}
projects = append(projects, pp)
projectsMap[p.ID] = pp
projectIDs = append(projectIDs, p.ID)
}
views := map[int64]*ProjectView{}
err = s.In("project_id", projectIDs).Find(&views)
if err != nil {
return
}
viewIDs := []int64{}
for _, v := range views {
if projectsMap[v.ProjectID].Views == nil {
projectsMap[v.ProjectID].Views = []*ProjectView{}
}
projectsMap[v.ProjectID].Views = append(projectsMap[v.ProjectID].Views, v)
viewIDs = append(viewIDs, v.ID)
}
tasks, err := getRawTasksForExport(s, projectIDs, u)
if err != nil {
return taskIDs, err
}
taskMap := make(map[int64]*TaskWithComments, len(tasks))
for _, t := range tasks {
taskMap[t.ID] = &TaskWithComments{
Task: *t,
}
if _, exists := projectsMap[t.ProjectID]; !exists {
log.Debugf("[User Data Export] Project %d does not exist for task %d, omitting", t.ProjectID, t.ID)
continue
}
projectsMap[t.ProjectID].Tasks = append(projectsMap[t.ProjectID].Tasks, taskMap[t.ID])
taskIDs = append(taskIDs, t.ID)
}
comments := []*TaskComment{}
err = s.
Join("LEFT", "tasks", "tasks.id = task_comments.task_id").
In("tasks.project_id", projectIDs).
Find(&comments)
if err != nil {
return
}
for _, c := range comments {
if _, exists := taskMap[c.TaskID]; !exists {
log.Debugf("[User Data Export] Task %d does not exist for comment %d, omitting", c.TaskID, c.ID)
continue
}
taskMap[c.TaskID].Comments = append(taskMap[c.TaskID].Comments, c)
}
buckets := []*Bucket{}
err = s.In("project_view_id", viewIDs).Find(&buckets)
if err != nil {
return
}
bucketIDs := []int64{}
for _, b := range buckets {
view, exists := views[b.ProjectViewID]
if !exists {
log.Debugf("[User Data Export] Project view %d does not exist for bucket %d, omitting", b.ProjectViewID, b.ID)
continue
}
_, exists = projectsMap[view.ProjectID]
if !exists {
log.Debugf("[User Data Export] Project %d does not exist for bucket %d, omitting", view.ProjectID, b.ID)
continue
}
projectsMap[view.ProjectID].Buckets = append(projectsMap[view.ProjectID].Buckets, b)
bucketIDs = append(bucketIDs, b.ID)
}
taskBuckets := []*TaskBucket{}
err = s.In("bucket_id", bucketIDs).Find(&taskBuckets)
if err != nil {
return
}
for _, tb := range taskBuckets {
view, exists := views[tb.ProjectViewID]
if !exists {
log.Debugf("[User Data Export] Project view %d does not exist, omitting", tb.ProjectViewID)
continue
}
_, exists = projectsMap[view.ProjectID]
if !exists {
log.Debugf("[User Data Export] Project %d does not exist, omitting", view.ProjectID)
continue
}
if projectsMap[view.ProjectID].TaskBuckets == nil {
projectsMap[view.ProjectID].TaskBuckets = []*TaskBucket{}
}
projectsMap[view.ProjectID].TaskBuckets = append(projectsMap[view.ProjectID].TaskBuckets, tb)
}
taskPositions := []*TaskPosition{}
err = s.In("project_view_id", viewIDs).Find(&taskPositions)
if err != nil {
return
}
for _, p := range taskPositions {
view, exists := views[p.ProjectViewID]
if !exists {
log.Debugf("[User Data Export] Project view %d does not exist, omitting", p.ProjectViewID)
continue
}
_, exists = projectsMap[view.ProjectID]
if !exists {
log.Debugf("[User Data Export] Project %d does not exist, omitting", view.ProjectID)
continue
}
if projectsMap[view.ProjectID].Positions == nil {
projectsMap[view.ProjectID].Positions = []*TaskPosition{}
}
projectsMap[view.ProjectID].Positions = append(projectsMap[view.ProjectID].Positions, p)
}
data, err := json.Marshal(projects)
if err != nil {
return taskIDs, err
}
return taskIDs, utils.WriteBytesToZip("data.json", data, wr)
}
func exportTaskAttachments(s *xorm.Session, wr *zip.Writer, taskIDs []int64) (err error) {
tas, err := getTaskAttachmentsByTaskIDs(s, taskIDs)
if err != nil {
return err
}
attachmentFiles := make(map[int64]io.ReadCloser)
for _, ta := range tas {
err = ta.File.LoadFileByID()
if err != nil {
var pathError *fs.PathError
if errors.As(err, &pathError) {
continue
}
return err
}
attachmentFiles[ta.FileID] = ta.File.File
}
return utils.WriteFilesToZip(attachmentFiles, wr)
}
func exportSavedFilters(s *xorm.Session, u *user.User, wr *zip.Writer) (err error) {
filters, err := getSavedFiltersForUser(s, u, "")
if err != nil {
return err
}
data, err := json.Marshal(filters)
if err != nil {
return err
}
return utils.WriteBytesToZip("filters.json", data, wr)
}
func exportProjectBackgrounds(s *xorm.Session, u *user.User, wr *zip.Writer) (err error) {
projects, _, _, err := getRawProjectsForUser(
s,
&projectOptions{
user: u,
page: -1,
},
)
if err != nil {
return err
}
backgroundFiles := make(map[int64]io.ReadCloser)
for _, l := range projects {
if l.BackgroundFileID == 0 {
continue
}
bgFile := &files.File{
ID: l.BackgroundFileID,
}
err = bgFile.LoadFileByID()
if err != nil {
var pathError *fs.PathError
if errors.As(err, &pathError) {
continue
}
return err
}
backgroundFiles[l.BackgroundFileID] = bgFile.File
}
return utils.WriteFilesToZip(backgroundFiles, wr)
}
func RegisterOldExportCleanupCron() {
const logPrefix = "[User Export Cleanup Cron] "
err := cron.Schedule("0 * * * *", func() {
s := db.NewSession()
defer s.Close()
users := []*user.User{}
err := s.Where("export_file_id IS NOT NULL AND export_file_id != ?", 0).Find(&users)
if err != nil {
log.Errorf(logPrefix+"Could not get users with export files: %s", err)
return
}
fileIDs := []int64{}
for _, u := range users {
fileIDs = append(fileIDs, u.ExportFileID)
}
fs := []*files.File{}
err = s.Where("created < ?", time.Now().Add(-time.Hour*24*7)).In("id", fileIDs).Find(&fs)
if err != nil {
log.Errorf(logPrefix+"Could not get users with export files: %s", err)
return
}
if len(fs) == 0 {
return
}
log.Debugf(logPrefix+"Removing %d old user data exports...", len(fs))
for _, f := range fs {
err = f.Delete(s)
if err != nil {
log.Errorf(logPrefix+"Could not remove user export file %d: %s", f.ID, err)
return
}
}
_, err = s.In("export_file_id", fileIDs).Cols("export_file_id").Update(&user.User{})
if err != nil {
log.Errorf(logPrefix+"Could not update user export file state: %s", err)
return
}
log.Debugf(logPrefix+"Removed %d old user data exports...", len(fs))
if err := s.Commit(); err != nil {
log.Errorf(logPrefix+"Error committing export cleanup: %s", err)
}
})
if err != nil {
log.Fatalf("Could not register old export cleanup cron: %s", err)
}
}