MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
Store.execInTx runs related statements in one transaction and returns the first error. Used for deleting and moving folders (with the trash), shared folders, folder shares and mounts, deleting task lists and accounts, pinning, and the password reset (new password and the end of all other links together). Writes whose success the API reports now pass their error to the client (logout, new password, setup, sharing mode, new group, restore, unlinking a note block), follow-up tasks and alarm markers log failures. Remaining ignored writes are listed as intentional in flaws.md. Found on the way: DeleteTaskList unlinked all tasks before checking ownership, so a stranger with the list ID could empty it; viewers could unlink tasks from note blocks. Both fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
5.3 KiB
Go
156 lines
5.3 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
)
|
|
|
|
// Deleted notes go to a trash first (documents.is_archived = 1). Trashed notes vanish from the tree,
|
|
// search, export, stats and for everyone they were shared with; their owner can still read them,
|
|
// restore them or delete them for good. archived_by records who trashed the note (the owner, or the
|
|
// owner of the shared folder it lived in), so that person sees it in their trash as well.
|
|
|
|
// ErrNotInTrash is returned when restoring or purging a note that is not in the user's trash.
|
|
var ErrNotInTrash = errors.New("Notiz nicht im Papierkorb")
|
|
|
|
// ErrDocInTrash refuses changes to a trashed note; it has to be restored first.
|
|
var ErrDocInTrash = errors.New("Die Notiz liegt im Papierkorb. Stelle sie zuerst wieder her.")
|
|
|
|
// TrashedDocument is one entry of the trash list.
|
|
type TrashedDocument struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Icon string `json:"icon"`
|
|
FolderPath string `json:"folderPath"`
|
|
DeletedAt string `json:"deletedAt"`
|
|
IsOwner bool `json:"isOwner"`
|
|
}
|
|
|
|
const trashScope = `is_archived = 1 AND (user_id = ? OR archived_by = ?)`
|
|
|
|
func trashStmts(userID, docID string) []sqlStmt {
|
|
return []sqlStmt{
|
|
stmt(`UPDATE documents SET is_archived = 1, archived_at = ?, archived_by = ? WHERE id = ?`, domain.NowISO(), userID, docID),
|
|
stmt(`DELETE FROM doc_pins WHERE doc_id = ?`, docID),
|
|
}
|
|
}
|
|
|
|
func (s *Store) trashDocument(userID, docID string) error {
|
|
return s.execInTx(trashStmts(userID, docID)...)
|
|
}
|
|
|
|
// GetTrash lists the notes in the user's trash, newest deletion first.
|
|
func (s *Store) GetTrash(userID string) ([]TrashedDocument, error) {
|
|
rows, err := s.db.Query(`SELECT d.id, d.title, d.icon, d.folder_path, d.shared_folder_id, d.sub_folder_path, d.archived_at, d.user_id,
|
|
COALESCE(wsf.name, '')
|
|
FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id
|
|
WHERE d.`+trashScope+` ORDER BY d.archived_at DESC`, userID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []TrashedDocument{}
|
|
for rows.Next() {
|
|
var t TrashedDocument
|
|
var sharedFolderID, subPath, ownerID, folderName string
|
|
if err := rows.Scan(&t.ID, &t.Title, &t.Icon, &t.FolderPath, &sharedFolderID, &subPath, &t.DeletedAt, &ownerID, &folderName); err != nil {
|
|
continue
|
|
}
|
|
if sharedFolderID != "" {
|
|
t.FolderPath = folderName
|
|
if subPath != "" {
|
|
t.FolderPath += "/" + subPath
|
|
}
|
|
}
|
|
t.IsOwner = ownerID == userID
|
|
out = append(out, t)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) trashedDocOwner(userID, docID string) (string, error) {
|
|
var owner string
|
|
err := s.db.QueryRow(`SELECT user_id FROM documents WHERE id = ? AND `+trashScope, docID, userID, userID).Scan(&owner)
|
|
if err == sql.ErrNoRows {
|
|
return "", ErrNotInTrash
|
|
}
|
|
return owner, err
|
|
}
|
|
|
|
// RestoreDocument takes a note out of the trash. Its folder is created again if it was deleted in the
|
|
// meantime; a shared folder that no longer exists puts the note back into the owner's top level.
|
|
func (s *Store) RestoreDocument(userID, docID string) error {
|
|
owner, err := s.trashedDocOwner(userID, docID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var folderPath, sharedFolderID string
|
|
_ = s.db.QueryRow(`SELECT folder_path, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&folderPath, &sharedFolderID)
|
|
if sharedFolderID != "" {
|
|
var exists int
|
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM workspace_shared_folders WHERE id = ?`, sharedFolderID).Scan(&exists)
|
|
if exists == 0 {
|
|
if _, err := s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = '' WHERE id = ?`, docID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else if folderPath != "" {
|
|
if _, err := s.CreateFolder(owner, folderPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '' WHERE id = ?`, docID)
|
|
return err
|
|
}
|
|
|
|
// PurgeDocument deletes a trashed note for good.
|
|
func (s *Store) PurgeDocument(userID, docID string) error {
|
|
if _, err := s.trashedDocOwner(userID, docID); err != nil {
|
|
return err
|
|
}
|
|
return s.hardDeleteDocument(docID)
|
|
}
|
|
|
|
// EmptyTrash deletes every note in the user's trash for good and returns their IDs.
|
|
func (s *Store) EmptyTrash(userID string) ([]string, error) {
|
|
items, err := s.GetTrash(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]string, 0, len(items))
|
|
for _, t := range items {
|
|
if err := s.hardDeleteDocument(t.ID); err != nil {
|
|
return ids, err
|
|
}
|
|
ids = append(ids, t.ID)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (s *Store) hardDeleteDocument(docID string) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for _, q := range []string{
|
|
// Tasks created from blocks of this note keep existing without the link
|
|
`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block'
|
|
AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) = ?`,
|
|
`DELETE FROM blocks WHERE document_id = ?`,
|
|
`DELETE FROM block_tags WHERE document_id = ?`,
|
|
`DELETE FROM doc_pins WHERE doc_id = ?`,
|
|
`DELETE FROM doc_shares WHERE doc_id = ?`,
|
|
`DELETE FROM doc_team_shares WHERE doc_id = ?`,
|
|
`DELETE FROM doc_user_folders WHERE doc_id = ?`,
|
|
`DELETE FROM documents WHERE id = ?`,
|
|
} {
|
|
if _, err := tx.Exec(q, docID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|