MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Pure moves, no behaviour change: the storage layer now has users, apikeys, thoughts, tasklists, documents, folders, tags, lists, teams and system files next to sqlite.go (store, schema, migrations); the API has handlers_*.go per area plus realtime.go and alarms.go next to server.go (server, router, middleware). Declarations were moved verbatim with an AST-based tool; imports pruned with goimports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
863 lines
27 KiB
Go
863 lines
27 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
)
|
|
|
|
func (s *Store) GetFolders(userID string) ([]string, error) {
|
|
folderSet := make(map[string]bool)
|
|
|
|
// 1. Collect from resolved documents for this user
|
|
docs, err := s.GetDocuments(userID)
|
|
if err == nil {
|
|
for _, doc := range docs {
|
|
if doc.FolderPath != "" {
|
|
addFolderSegments(doc.FolderPath, folderSet)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Add mounted shared folders (so even empty shared folders appear in the tree)
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
if err == nil {
|
|
for sfID, mPath := range mounts {
|
|
if mPath != "" {
|
|
addFolderSegments(mPath, folderSet)
|
|
|
|
// 2b. Add all subfolders created in this shared folder
|
|
sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
|
|
if err == nil {
|
|
for sRows.Next() {
|
|
var sp string
|
|
if err := sRows.Scan(&sp); err == nil && sp != "" {
|
|
fullP := mPath + "/" + sp
|
|
addFolderSegments(fullP, folderSet)
|
|
}
|
|
}
|
|
_ = sRows.Close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Add personal empty folders created by this user
|
|
pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID)
|
|
if err == nil {
|
|
for pRows.Next() {
|
|
var p string
|
|
if err := pRows.Scan(&p); err == nil && p != "" {
|
|
addFolderSegments(p, folderSet)
|
|
}
|
|
}
|
|
_ = pRows.Close()
|
|
}
|
|
|
|
result := make([]string, 0, len(folderSet))
|
|
for f := range folderSet {
|
|
result = append(result, f)
|
|
}
|
|
sort.Strings(result)
|
|
return result, nil
|
|
}
|
|
|
|
func addFolderSegments(fp string, set map[string]bool) {
|
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(fp)))
|
|
if clean == "." || clean == "" {
|
|
return
|
|
}
|
|
parts := strings.Split(clean, "/")
|
|
var cur string
|
|
for _, p := range parts {
|
|
if p != "" && p != "." {
|
|
if cur == "" {
|
|
cur = p
|
|
} else {
|
|
cur = cur + "/" + p
|
|
}
|
|
set[cur] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) CreateFolder(userID, folderPath string) (string, error) {
|
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
|
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
|
|
return "", fmt.Errorf("ungültiger Ordnerpfad")
|
|
}
|
|
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
var sfID, subPath string
|
|
if err == nil {
|
|
sfID, subPath, _ = matchMountPath(clean, mounts)
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
id := domain.NewID("wfld")
|
|
|
|
if sfID != "" {
|
|
var existsID string
|
|
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID)
|
|
if err == sql.ErrNoRows {
|
|
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
id, userID, sfID, clean, subPath, now)
|
|
}
|
|
} else {
|
|
var existsID string
|
|
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID)
|
|
if err == sql.ErrNoRows {
|
|
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`,
|
|
id, userID, clean, now)
|
|
}
|
|
}
|
|
|
|
return clean, nil
|
|
}
|
|
|
|
func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
|
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
|
|
return nil, fmt.Errorf("ungültiger Ordnerpfad")
|
|
}
|
|
|
|
var deletedDocIDs []string
|
|
seenDocID := make(map[string]bool)
|
|
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
if err == nil {
|
|
sfID, subPath, isMatch := matchMountPath(clean, mounts)
|
|
if isMatch && sfID != "" {
|
|
if subPath == "" {
|
|
// Root of a shared folder
|
|
shFolder, sfErr := s.GetSharedFolderByID(userID, sfID)
|
|
if sfErr == nil && !shFolder.IsOwner {
|
|
// Collaborator removing mount/share
|
|
_ = s.DeleteSharedFolder(userID, sfID)
|
|
return []string{}, nil
|
|
}
|
|
rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID)
|
|
if qErr == nil {
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
|
|
seenDocID[id] = true
|
|
deletedDocIDs = append(deletedDocIDs, id)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
}
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
|
|
} else {
|
|
// Subfolder inside a shared folder
|
|
rows, qErr := s.db.Query(
|
|
`SELECT id FROM documents WHERE shared_folder_id = ? AND (sub_folder_path = ? OR sub_folder_path LIKE ?)`,
|
|
sfID, subPath, subPath+"/%",
|
|
)
|
|
if qErr == nil {
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
|
|
seenDocID[id] = true
|
|
deletedDocIDs = append(deletedDocIDs, id)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
}
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`,
|
|
sfID, subPath, subPath+"/%")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find all personal documents owned by this user in this folder and subfolders
|
|
rows, err := s.db.Query(
|
|
`SELECT id FROM documents
|
|
WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL) AND (folder_path = ? OR folder_path LIKE ?)`,
|
|
userID, clean, clean+"/%",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
|
|
seenDocID[id] = true
|
|
deletedDocIDs = append(deletedDocIDs, id)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
// The notes of the folder go to the trash; restoring one creates its folder again
|
|
for _, id := range deletedDocIDs {
|
|
_ = s.trashDocument(userID, id)
|
|
}
|
|
|
|
// Also clear any personal folder placements for shared notes in this folder
|
|
_, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%")
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%")
|
|
|
|
return deletedDocIDs, nil
|
|
}
|
|
|
|
func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Document, error) {
|
|
doc, err := s.GetDocumentByID(userID, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if doc.IsArchived {
|
|
return nil, ErrDocInTrash
|
|
}
|
|
|
|
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath)))
|
|
if cleanTarget == "." {
|
|
cleanTarget = ""
|
|
}
|
|
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := domain.NowISO()
|
|
|
|
// Check if target folder falls within any mounted shared folder
|
|
targetFolderID, subPath, isTargetShared := matchMountPath(cleanTarget, mounts)
|
|
|
|
if isTargetShared {
|
|
// Moving into / within a shared folder
|
|
shFolder, err := s.GetSharedFolderByID(userID, targetFolderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !shFolder.IsOwner {
|
|
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
|
|
}
|
|
|
|
// Update document to belong to targetSharedFolderID
|
|
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ?, updated_at = ? WHERE id = ?`,
|
|
targetFolderID, subPath, now, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Remove personal override if any
|
|
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
|
|
|
doc.SharedFolderID = targetFolderID
|
|
doc.SubFolderPath = subPath
|
|
doc.IsSharedFolder = true
|
|
doc.FolderPath = cleanTarget
|
|
return doc, nil
|
|
}
|
|
|
|
// Target is NOT inside a shared folder (personal / standalone)
|
|
if doc.SharedFolderID != "" {
|
|
// Moving OUT of a shared folder into a personal folder
|
|
if !doc.IsOwner {
|
|
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
|
|
}
|
|
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
|
|
cleanTarget, now, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc.SharedFolderID = ""
|
|
doc.SubFolderPath = ""
|
|
doc.IsSharedFolder = false
|
|
doc.FolderPath = cleanTarget
|
|
return doc, nil
|
|
}
|
|
|
|
// Standalone note
|
|
if doc.IsOwner {
|
|
// Owner moves their own document -> updates documents.folder_path
|
|
_, err = s.db.Exec(`UPDATE documents SET folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc.FolderPath = cleanTarget
|
|
return doc, nil
|
|
}
|
|
|
|
// Recipient moves an individually shared note -> updates doc_user_folders ONLY!
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO doc_user_folders (user_id, doc_id, folder_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, doc_id) DO UPDATE SET folder_path = excluded.folder_path, updated_at = excluded.updated_at
|
|
`, userID, docID, cleanTarget, now, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
doc.FolderPath = cleanTarget
|
|
return doc, nil
|
|
}
|
|
|
|
// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path.
|
|
func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) {
|
|
cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath)))
|
|
if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") {
|
|
return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad")
|
|
}
|
|
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath)))
|
|
if cleanTarget == "." {
|
|
cleanTarget = ""
|
|
}
|
|
if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) {
|
|
return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden")
|
|
}
|
|
|
|
folderName := filepath.Base(cleanSource)
|
|
var newFolderPath string
|
|
if cleanTarget == "" {
|
|
newFolderPath = folderName
|
|
} else {
|
|
newFolderPath = cleanTarget + "/" + folderName
|
|
}
|
|
|
|
if newFolderPath == cleanSource {
|
|
return cleanSource, []string{}, nil
|
|
}
|
|
|
|
// 1. Check if sourcePath is the root mount of a shared folder
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
if err == nil {
|
|
for sfID, mPath := range mounts {
|
|
if mPath == cleanSource {
|
|
err := s.UpdateFolderMount(userID, sfID, newFolderPath)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return newFolderPath, []string{}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Move all affected documents
|
|
docs, err := s.GetDocuments(userID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
var movedDocIDs []string
|
|
for _, doc := range docs {
|
|
if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") {
|
|
var nextDocFolder string
|
|
if doc.FolderPath == cleanSource {
|
|
nextDocFolder = newFolderPath
|
|
} else {
|
|
suffix := strings.TrimPrefix(doc.FolderPath, cleanSource)
|
|
nextDocFolder = newFolderPath + suffix
|
|
}
|
|
_, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder)
|
|
if moveErr == nil {
|
|
movedDocIDs = append(movedDocIDs, doc.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Re-map workspace_folders entries
|
|
targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts)
|
|
sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts)
|
|
|
|
wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`)
|
|
if qErr == nil {
|
|
type wfRecord struct {
|
|
id, uid, sfID, path, subPath string
|
|
}
|
|
var records []wfRecord
|
|
for wfRows.Next() {
|
|
var r wfRecord
|
|
if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil {
|
|
records = append(records, r)
|
|
}
|
|
}
|
|
_ = wfRows.Close()
|
|
|
|
for _, r := range records {
|
|
if isSourceSF && r.sfID == sourceSFID {
|
|
matches := false
|
|
var suffix string
|
|
if sourceSub == "" {
|
|
matches = true
|
|
suffix = "/" + r.subPath
|
|
} else if r.subPath == sourceSub {
|
|
matches = true
|
|
suffix = ""
|
|
} else if strings.HasPrefix(r.subPath, sourceSub+"/") {
|
|
matches = true
|
|
suffix = strings.TrimPrefix(r.subPath, sourceSub)
|
|
}
|
|
if matches {
|
|
var newSub string
|
|
if targetSub == "" {
|
|
newSub = strings.TrimPrefix(folderName+suffix, "/")
|
|
} else {
|
|
newSub = targetSub + "/" + folderName + suffix
|
|
}
|
|
if isTargetSF {
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id)
|
|
} else {
|
|
newP := newFolderPath + suffix
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id)
|
|
}
|
|
}
|
|
} else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") {
|
|
suffix := strings.TrimPrefix(r.path, cleanSource)
|
|
newP := newFolderPath + suffix
|
|
if isTargetSF {
|
|
var newSub string
|
|
if targetSub == "" {
|
|
newSub = strings.TrimPrefix(folderName+suffix, "/")
|
|
} else {
|
|
newSub = targetSub + "/" + folderName + suffix
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id)
|
|
} else {
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_, _ = s.CreateFolder(userID, newFolderPath)
|
|
|
|
return newFolderPath, movedDocIDs, nil
|
|
}
|
|
|
|
// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID.
|
|
func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) {
|
|
if ownerID == "" || folderID == "" || cleanMount == "" {
|
|
return
|
|
}
|
|
|
|
// 1. Link all documents owned by ownerID in cleanMount to this shared folder
|
|
rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`,
|
|
ownerID, cleanMount, cleanMount+"/%")
|
|
if err == nil {
|
|
type docEntry struct{ id, fp string }
|
|
var entries []docEntry
|
|
for rows.Next() {
|
|
var de docEntry
|
|
if err := rows.Scan(&de.id, &de.fp); err == nil {
|
|
entries = append(entries, de)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
for _, de := range entries {
|
|
var subPath string
|
|
if de.fp == cleanMount {
|
|
subPath = ""
|
|
} else if strings.HasPrefix(de.fp, cleanMount+"/") {
|
|
subPath = strings.TrimPrefix(de.fp, cleanMount+"/")
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`,
|
|
folderID, subPath, de.id)
|
|
}
|
|
}
|
|
|
|
// 2. Link any workspace_folders created by ownerID in cleanMount
|
|
fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`,
|
|
ownerID, cleanMount, cleanMount+"/%")
|
|
if err == nil {
|
|
type wfEntry struct{ id, path string }
|
|
var wfEntries []wfEntry
|
|
for fRows.Next() {
|
|
var wfe wfEntry
|
|
if err := fRows.Scan(&wfe.id, &wfe.path); err == nil {
|
|
wfEntries = append(wfEntries, wfe)
|
|
}
|
|
}
|
|
_ = fRows.Close()
|
|
|
|
for _, wfe := range wfEntries {
|
|
subP := ""
|
|
if strings.HasPrefix(wfe.path, cleanMount+"/") {
|
|
subP = strings.TrimPrefix(wfe.path, cleanMount+"/")
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`,
|
|
folderID, subP, wfe.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) {
|
|
cleanName := strings.TrimSpace(folderName)
|
|
if cleanName == "" {
|
|
return nil, fmt.Errorf("ordnername darf nicht leer sein")
|
|
}
|
|
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath)))
|
|
if cleanMount == "." || cleanMount == "" {
|
|
cleanMount = cleanName
|
|
}
|
|
|
|
// Check if already shared by this user at this mount path
|
|
var existingID string
|
|
err := s.db.QueryRow(`
|
|
SELECT f.id FROM workspace_shared_folders f
|
|
JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
|
WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?)
|
|
`, userID, userID, cleanMount, cleanName).Scan(&existingID)
|
|
if err == nil && existingID != "" {
|
|
s.syncSharedFolderContents(userID, existingID, cleanMount)
|
|
return s.GetSharedFolderByID(userID, existingID)
|
|
}
|
|
|
|
now := domain.NowISO()
|
|
folderID := domain.NewID("fld")
|
|
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, folderID, userID, cleanName, now, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, userID, folderID, cleanMount, now, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.syncSharedFolderContents(userID, folderID, cleanMount)
|
|
|
|
var ownerName string
|
|
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName)
|
|
|
|
return &domain.SharedFolder{
|
|
ID: folderID,
|
|
OwnerID: userID,
|
|
OwnerName: ownerName,
|
|
Name: cleanName,
|
|
MountPath: cleanMount,
|
|
IsOwner: true,
|
|
Permission: "owner",
|
|
Shares: []domain.FolderShare{},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) GetSharedFolders(userID string) ([]domain.SharedFolder, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT f.id, f.owner_id, u.display_name, f.name,
|
|
COALESCE(m.mount_path, f.name) as mount_path,
|
|
CASE
|
|
WHEN f.owner_id = ? THEN 1
|
|
ELSE 0
|
|
END as is_owner,
|
|
CASE
|
|
WHEN f.owner_id = ? THEN 'owner'
|
|
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
|
|
ELSE 'read'
|
|
END as permission,
|
|
f.created_at, f.updated_at
|
|
FROM workspace_shared_folders f
|
|
JOIN users u ON f.owner_id = u.id
|
|
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
|
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
|
(s.target_type = 'user' AND s.target_id = ?) OR
|
|
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
)
|
|
WHERE f.owner_id = ? OR s.id IS NOT NULL
|
|
GROUP BY f.id
|
|
ORDER BY f.created_at ASC
|
|
`, userID, userID, userID, userID, userID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
folders := make([]domain.SharedFolder, 0)
|
|
for rows.Next() {
|
|
var f domain.SharedFolder
|
|
var isOwner int
|
|
if err := rows.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err == nil {
|
|
f.IsOwner = isOwner != 0
|
|
if f.IsOwner {
|
|
if shares, err := s.GetFolderShares(f.ID); err == nil {
|
|
f.Shares = shares
|
|
}
|
|
}
|
|
folders = append(folders, f)
|
|
}
|
|
}
|
|
return folders, nil
|
|
}
|
|
|
|
func (s *Store) GetSharedFolderByID(userID, folderID string) (*domain.SharedFolder, error) {
|
|
row := s.db.QueryRow(`
|
|
SELECT f.id, f.owner_id, u.display_name, f.name,
|
|
COALESCE(m.mount_path, f.name) as mount_path,
|
|
CASE
|
|
WHEN f.owner_id = ? THEN 1
|
|
ELSE 0
|
|
END as is_owner,
|
|
CASE
|
|
WHEN f.owner_id = ? THEN 'owner'
|
|
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
|
|
ELSE 'read'
|
|
END as permission,
|
|
f.created_at, f.updated_at
|
|
FROM workspace_shared_folders f
|
|
JOIN users u ON f.owner_id = u.id
|
|
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
|
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
|
(s.target_type = 'user' AND s.target_id = ?) OR
|
|
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
)
|
|
WHERE f.id = ? AND (f.owner_id = ? OR s.id IS NOT NULL)
|
|
GROUP BY f.id
|
|
`, userID, userID, userID, userID, userID, folderID, userID)
|
|
|
|
var f domain.SharedFolder
|
|
var isOwner int
|
|
if err := row.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("geteilter Ordner nicht gefunden")
|
|
}
|
|
f.IsOwner = isOwner != 0
|
|
if f.IsOwner {
|
|
if shares, err := s.GetFolderShares(f.ID); err == nil {
|
|
f.Shares = shares
|
|
}
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
func (s *Store) UpdateFolderMount(userID, folderID, mountPath string) error {
|
|
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mountPath)))
|
|
if cleanMount == "." || cleanMount == "" {
|
|
return fmt.Errorf("ungültiger Mount-Pfad")
|
|
}
|
|
now := domain.NowISO()
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, folder_id) DO UPDATE SET mount_path = excluded.mount_path, updated_at = excluded.updated_at
|
|
`, userID, folderID, cleanMount, now, now)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) GetFolderShares(folderID string) ([]domain.FolderShare, error) {
|
|
shares := make([]domain.FolderShare, 0)
|
|
rows, err := s.db.Query(`
|
|
SELECT s.id, s.folder_id, s.target_type, s.target_id,
|
|
CASE
|
|
WHEN s.target_type = 'user' THEN u.display_name
|
|
WHEN s.target_type = 'team' THEN t.name
|
|
ELSE ''
|
|
END as target_name,
|
|
COALESCE(u.email, '') as email,
|
|
COALESCE(u.avatar_url, '') as avatar_url,
|
|
s.permission, s.created_by, s.created_at
|
|
FROM workspace_folder_shares s
|
|
LEFT JOIN users u ON s.target_type = 'user' AND s.target_id = u.id
|
|
LEFT JOIN teams t ON s.target_type = 'team' AND s.target_id = t.id
|
|
WHERE s.folder_id = ?
|
|
ORDER BY s.created_at ASC
|
|
`, folderID)
|
|
if err != nil {
|
|
return shares, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var sh domain.FolderShare
|
|
if err := rows.Scan(&sh.ID, &sh.FolderID, &sh.TargetType, &sh.TargetID, &sh.TargetName, &sh.Email, &sh.AvatarURL, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
|
shares = append(shares, sh)
|
|
}
|
|
}
|
|
return shares, nil
|
|
}
|
|
|
|
func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIdentifier, permission string) (*domain.FolderShare, error) {
|
|
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !f.IsOwner {
|
|
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
|
|
}
|
|
|
|
cleanType := strings.TrimSpace(targetType)
|
|
cleanTarget := strings.TrimSpace(targetIdentifier)
|
|
if cleanType == "" {
|
|
if strings.HasPrefix(cleanTarget, "team_") {
|
|
cleanType = "team"
|
|
} else {
|
|
cleanType = "user"
|
|
}
|
|
}
|
|
|
|
permission = normalizeSharePermission(permission)
|
|
|
|
var targetID, targetName, email, avatarURL string
|
|
if cleanType == "team" {
|
|
err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName)
|
|
if err != nil {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
} else {
|
|
var u *domain.User
|
|
if strings.HasPrefix(cleanTarget, "usr_") {
|
|
u, _ = s.GetUserByID(cleanTarget)
|
|
}
|
|
if u == nil {
|
|
u, _ = s.GetUserByLogin(cleanTarget)
|
|
}
|
|
if u == nil {
|
|
u, err = s.GetUserByID(cleanTarget)
|
|
}
|
|
if err != nil || u == nil {
|
|
if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil {
|
|
cleanType = "team"
|
|
} else {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
} else {
|
|
if u.ID == f.OwnerID {
|
|
return nil, fmt.Errorf("ordner gehört bereits diesem Benutzer")
|
|
}
|
|
targetID = u.ID
|
|
targetName = u.DisplayName
|
|
email = u.Email
|
|
avatarURL = u.AvatarURL
|
|
}
|
|
}
|
|
|
|
if !s.CanShareWith(currentUserID, cleanType, targetID) {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
|
|
now := domain.NowISO()
|
|
shareID := domain.NewID("fsh")
|
|
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO workspace_folder_shares (id, folder_id, target_type, target_id, permission, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(folder_id, target_type, target_id) DO UPDATE SET permission = excluded.permission
|
|
`, shareID, folderID, cleanType, targetID, permission, currentUserID, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ownerMount string
|
|
_ = s.db.QueryRow(`SELECT COALESCE(m.mount_path, f.name) FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id WHERE f.id = ?`, folderID).Scan(&ownerMount)
|
|
if ownerMount == "" {
|
|
ownerMount = f.Name
|
|
}
|
|
s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount)
|
|
|
|
if cleanType == "user" {
|
|
_, _ = s.db.Exec(`
|
|
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, targetID, folderID, f.Name, now, now)
|
|
} else if cleanType == "team" {
|
|
mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID)
|
|
if err == nil {
|
|
var memberIDs []string
|
|
for mRows.Next() {
|
|
var uid string
|
|
if err := mRows.Scan(&uid); err == nil && uid != "" {
|
|
memberIDs = append(memberIDs, uid)
|
|
}
|
|
}
|
|
_ = mRows.Close()
|
|
for _, uid := range memberIDs {
|
|
_, _ = s.db.Exec(`
|
|
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, uid, folderID, f.Name, now, now)
|
|
}
|
|
}
|
|
}
|
|
|
|
return &domain.FolderShare{
|
|
ID: shareID,
|
|
FolderID: folderID,
|
|
TargetType: cleanType,
|
|
TargetID: targetID,
|
|
TargetName: targetName,
|
|
Email: email,
|
|
AvatarURL: avatarURL,
|
|
Permission: permission,
|
|
CreatedBy: currentUserID,
|
|
CreatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error {
|
|
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cleanID := strings.TrimSpace(shareID)
|
|
|
|
// Only the owner manages shares; everyone else can only leave the folder
|
|
if !f.IsOwner {
|
|
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`,
|
|
folderID, currentUserID, cleanID, cleanID)
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID)
|
|
return err
|
|
}
|
|
|
|
var targetType, targetID string
|
|
_ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`,
|
|
folderID, cleanID, cleanID).Scan(&targetType, &targetID)
|
|
|
|
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if targetType == "user" && targetID != "" {
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error {
|
|
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !f.IsOwner {
|
|
return s.DeleteFolderShare(currentUserID, folderID, currentUserID)
|
|
}
|
|
|
|
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID)
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID)
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID)
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID)
|
|
_, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)
|
|
return err
|
|
}
|
|
|
|
// GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder.
|
|
func (s *Store) GetSharedFolderDocIDs(folderID string) ([]string, error) {
|
|
rows, err := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ? AND is_archived = 0`, folderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var docIDs []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil && id != "" {
|
|
docIDs = append(docIDs, id)
|
|
}
|
|
}
|
|
return docIDs, nil
|
|
}
|