All checks were successful
release-tag / release-image (push) Successful in 1m27s
323 lines
6.2 KiB
Go
323 lines
6.2 KiB
Go
package filesvc
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
type MemStore struct {
|
||
mu sync.Mutex
|
||
items map[ID]File
|
||
self string
|
||
// optionales Eventing
|
||
subs []chan ChangeEvent
|
||
persistPath string
|
||
}
|
||
|
||
func NewMemStore(self string) *MemStore {
|
||
return &MemStore{self: strings.TrimSpace(self), items: make(map[ID]File)}
|
||
}
|
||
|
||
func NewMemStorePersistent(self, path string) *MemStore {
|
||
m := NewMemStore(self)
|
||
m.persistPath = strings.TrimSpace(path)
|
||
// beim Start versuchen zu laden
|
||
_ = m.loadFromDisk()
|
||
return m
|
||
}
|
||
|
||
// --- Persistenz-Helper (NEU) ---
|
||
|
||
func (m *MemStore) loadFromDisk() error {
|
||
if m.persistPath == "" {
|
||
return nil
|
||
}
|
||
f, err := os.Open(m.persistPath)
|
||
if err != nil {
|
||
return nil // Datei existiert beim ersten Start nicht – ok
|
||
}
|
||
defer f.Close()
|
||
var snap Snapshot
|
||
if err := json.NewDecoder(f).Decode(&snap); err != nil {
|
||
return err
|
||
}
|
||
m.mu.Lock()
|
||
for _, it := range snap.Items {
|
||
m.items[it.ID] = it
|
||
}
|
||
m.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
func (m *MemStore) saveLocked() error {
|
||
if m.persistPath == "" {
|
||
return nil
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(m.persistPath), 0o755); err != nil {
|
||
return err
|
||
}
|
||
// Snapshot aus Map bauen
|
||
snap := Snapshot{Items: make([]File, 0, len(m.items))}
|
||
for _, it := range m.items {
|
||
snap.Items = append(snap.Items, it)
|
||
}
|
||
// atomar schreiben
|
||
tmp := m.persistPath + ".tmp"
|
||
f, err := os.Create(tmp)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
enc := json.NewEncoder(f)
|
||
enc.SetIndent("", " ")
|
||
if err := enc.Encode(&snap); err != nil {
|
||
f.Close()
|
||
_ = os.Remove(tmp)
|
||
return err
|
||
}
|
||
if err := f.Sync(); err != nil {
|
||
f.Close()
|
||
_ = os.Remove(tmp)
|
||
return err
|
||
}
|
||
if err := f.Close(); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return err
|
||
}
|
||
return os.Rename(tmp, m.persistPath)
|
||
}
|
||
|
||
/*** Store ***/
|
||
|
||
func (m *MemStore) Get(_ context.Context, id ID) (File, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
it, ok := m.items[id]
|
||
if !ok || it.Deleted {
|
||
return File{}, ErrNotFound
|
||
}
|
||
return it, nil
|
||
}
|
||
|
||
func (m *MemStore) List(_ context.Context, next ID, limit int) ([]File, ID, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
|
||
if limit <= 0 || limit > 1000 {
|
||
limit = 100
|
||
}
|
||
|
||
// sortiere deterministisch nach UpdatedAt, dann ID
|
||
all := make([]File, 0, len(m.items))
|
||
for _, v := range m.items {
|
||
all = append(all, v)
|
||
}
|
||
slices.SortFunc(all, func(a, b File) int {
|
||
if a.UpdatedAt == b.UpdatedAt {
|
||
if a.ID == b.ID {
|
||
return 0
|
||
}
|
||
if a.ID < b.ID {
|
||
return -1
|
||
}
|
||
return 1
|
||
}
|
||
if a.UpdatedAt < b.UpdatedAt {
|
||
return -1
|
||
}
|
||
return 1
|
||
})
|
||
|
||
start := 0
|
||
if next != "" {
|
||
for i, it := range all {
|
||
if it.ID >= next {
|
||
start = i
|
||
break
|
||
}
|
||
}
|
||
}
|
||
end := start + limit
|
||
if end > len(all) {
|
||
end = len(all)
|
||
}
|
||
out := make([]File, 0, end-start)
|
||
for _, it := range all[start:end] {
|
||
if !it.Deleted {
|
||
out = append(out, it)
|
||
}
|
||
}
|
||
var nextOut ID
|
||
if end < len(all) {
|
||
nextOut = all[end].ID
|
||
}
|
||
return out, nextOut, nil
|
||
}
|
||
|
||
func (m *MemStore) Create(_ context.Context, name string) (File, error) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return File{}, ErrBadInput
|
||
}
|
||
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
now := time.Now().UnixNano()
|
||
uid, err := NewUUIDv4()
|
||
if err != nil {
|
||
return File{}, err
|
||
}
|
||
it := File{ID: uid, Name: name, UpdatedAt: now, Owner: m.self}
|
||
m.items[it.ID] = it
|
||
_ = m.saveLocked()
|
||
m.emit(it)
|
||
return it, nil
|
||
}
|
||
|
||
func (m *MemStore) Rename(_ context.Context, id ID, newName string) (File, error) {
|
||
newName = strings.TrimSpace(newName)
|
||
if newName == "" {
|
||
return File{}, ErrBadInput
|
||
}
|
||
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
it, ok := m.items[id]
|
||
if !ok || it.Deleted {
|
||
return File{}, ErrNotFound
|
||
}
|
||
if it.Owner != "" && it.Owner != m.self { // ← nur Owner
|
||
return File{}, ErrForbidden
|
||
}
|
||
it.Name = strings.TrimSpace(newName)
|
||
it.UpdatedAt = time.Now().UnixNano()
|
||
m.items[id] = it
|
||
_ = m.saveLocked()
|
||
m.emit(it)
|
||
return it, nil
|
||
}
|
||
|
||
func (m *MemStore) Delete(_ context.Context, id ID) (File, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
it, ok := m.items[id]
|
||
if !ok {
|
||
return File{}, ErrNotFound
|
||
}
|
||
if it.Owner != "" && it.Owner != m.self { // ← nur Owner
|
||
return File{}, ErrForbidden
|
||
}
|
||
if it.Deleted {
|
||
return it, nil
|
||
}
|
||
it.Deleted = true
|
||
it.UpdatedAt = time.Now().UnixNano()
|
||
m.items[id] = it
|
||
_ = m.saveLocked()
|
||
m.emit(it)
|
||
return it, nil
|
||
}
|
||
|
||
func (m *MemStore) TakeoverOwner(_ context.Context, id ID, newOwner string) (File, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
it, ok := m.items[id]
|
||
if !ok || it.Deleted {
|
||
return File{}, ErrNotFound
|
||
}
|
||
newOwner = strings.TrimSpace(newOwner)
|
||
if newOwner == "" {
|
||
return File{}, ErrBadInput
|
||
}
|
||
// Sicherheit: nur für sich selbst übernehmen
|
||
if newOwner != m.self {
|
||
return File{}, ErrForbidden
|
||
}
|
||
if it.Owner == newOwner {
|
||
return it, nil
|
||
}
|
||
it.Owner = newOwner
|
||
it.UpdatedAt = time.Now().UnixNano()
|
||
m.items[id] = it
|
||
_ = m.saveLocked()
|
||
m.emitLocked(it)
|
||
return it, nil
|
||
}
|
||
|
||
/*** Replicable ***/
|
||
|
||
func (m *MemStore) Snapshot(_ context.Context) (Snapshot, error) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
s := Snapshot{Items: make([]File, 0, len(m.items))}
|
||
for _, it := range m.items {
|
||
s.Items = append(s.Items, it) // inkl. Tombstones
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
func (m *MemStore) ApplyRemote(_ context.Context, s Snapshot) error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
changed := false
|
||
for _, ri := range s.Items {
|
||
li, ok := m.items[ri.ID]
|
||
if !ok || ri.UpdatedAt > li.UpdatedAt {
|
||
// Owner nie überschreiben, außer er ist leer
|
||
if ok && li.Owner != "" && ri.Owner != "" && ri.Owner != li.Owner {
|
||
ri.Owner = li.Owner
|
||
}
|
||
m.items[ri.ID] = ri
|
||
changed = true
|
||
m.emitLocked(ri)
|
||
}
|
||
}
|
||
if changed {
|
||
_ = m.saveLocked() // ← NEU
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/*** Watchable (optional) ***/
|
||
|
||
func (m *MemStore) Watch(stop <-chan struct{}) <-chan ChangeEvent {
|
||
ch := make(chan ChangeEvent, 32)
|
||
|
||
m.mu.Lock()
|
||
m.subs = append(m.subs, ch)
|
||
m.mu.Unlock()
|
||
|
||
go func() {
|
||
<-stop
|
||
m.mu.Lock()
|
||
// entferne ch aus subs
|
||
for i, s := range m.subs {
|
||
if s == ch {
|
||
m.subs = append(m.subs[:i], m.subs[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
m.mu.Unlock()
|
||
close(ch)
|
||
}()
|
||
return ch
|
||
}
|
||
|
||
func (m *MemStore) emit(it File) {
|
||
m.emitLocked(it) // mu wird im Aufrufer gehalten
|
||
}
|
||
func (m *MemStore) emitLocked(it File) {
|
||
ev := ChangeEvent{At: time.Now(), Item: it}
|
||
for _, s := range m.subs {
|
||
select {
|
||
case s <- ev:
|
||
default: /* drop wenn voll */
|
||
}
|
||
}
|
||
}
|