Skip to content

Commit

Permalink
cleanup images from deleted comments
Browse files Browse the repository at this point in the history
Previously, images were deleted only from comments deleted
before EditDuration expiration. After this change, any deletion
of the comment deletes images if they are not used elsewhere
in comments under the same page.
  • Loading branch information
paskal authored and umputun committed Jan 20, 2024
1 parent 82c6178 commit 81c30e0
Show file tree
Hide file tree
Showing 15 changed files with 318 additions and 7 deletions.
11 changes: 11 additions & 0 deletions backend/_example/memory_store/accessor/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ func (m *MemImage) Load(id string) ([]byte, error) {
return img, nil
}

// Delete image by ID
func (m *MemImage) Delete(id string) error {
m.mu.Lock()
// delete key from permanent and staging storage
delete(m.images, id)
delete(m.insertTime, id)
delete(m.imagesStaging, id)
m.mu.Unlock()
return nil
}

// Commit moves image from staging to permanent
func (m *MemImage) Commit(id string) error {
m.mu.RLock()
Expand Down
29 changes: 26 additions & 3 deletions backend/_example/memory_store/accessor/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
)

// gopher png for test, from https://golang.org/src/image/png/example_test.go
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
const rawGopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
Expand All @@ -38,7 +38,9 @@ const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwU
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="

func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func gopherPNG() io.Reader {
return base64.NewDecoder(base64.StdEncoding, strings.NewReader(rawGopher))
}

func TestMemImage_LoadAfterSave(t *testing.T) {
svc := NewMemImageStore()
Expand All @@ -57,7 +59,8 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, gopher, img)

svc.ResetCleanupTimer(id)
err = svc.ResetCleanupTimer(id)
assert.NoError(t, err)

err = svc.Commit(id)
assert.NoError(t, err)
Expand All @@ -70,6 +73,26 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
assert.Equal(t, gopher, img)
}

func TestMemImage_LoadAfterDelete(t *testing.T) {
svc := NewMemImageStore()
gopher, err := io.ReadAll(gopherPNG())
assert.NoError(t, err)

id := "test_img"
err = svc.Save(id, gopher)
assert.NoError(t, err)

err = svc.Delete(id)
assert.NoError(t, err)

img, err := svc.Load(id)
assert.EqualError(t, err, "image test_img not found")
assert.Empty(t, img)

err = svc.ResetCleanupTimer(id)
assert.EqualError(t, err, "image test_img not found")
}

func TestMemImage_CommitFail(t *testing.T) {
svc := NewMemImageStore()
err := svc.Commit("test_id")
Expand Down
11 changes: 10 additions & 1 deletion backend/_example/memory_store/server/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ func (s *RPC) imgResetClnTimerHndl(id uint64, params json.RawMessage) (rr jrpc.R
}
err := s.img.ResetCleanupTimer(fileID)
return jrpc.EncodeResponse(id, nil, err)

}

func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
Expand All @@ -47,6 +46,16 @@ func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response)
return jrpc.EncodeResponse(id, value, err)
}

func (s *RPC) imgDeleteHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
return jrpc.Response{Error: err.Error()}
}
err := s.img.Delete(fileID)
return jrpc.EncodeResponse(id, nil, err)

}

func (s *RPC) imgCommitHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
var fileID string
if err := json.Unmarshal(params, &fileID); err != nil {
Expand Down
5 changes: 5 additions & 0 deletions backend/_example/memory_store/server/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,9 @@ func TestRPC_imgInfoHndl(t *testing.T) {
info, err = ri.Info()
assert.NoError(t, err)
assert.False(t, info.FirstStagingImageTS.IsZero())

err = ri.Delete("test_img")
assert.NoError(t, err)
_, err = ri.Load("test_img")
assert.EqualError(t, err, "image test_img not found")
}
1 change: 1 addition & 0 deletions backend/_example/memory_store/server/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func (s *RPC) addHandlers() {
"save_with_id": s.imgSaveWithIDHndl,
"reset_cleanup_timer": s.imgResetClnTimerHndl,
"load": s.imgLoadHndl,
"delete": s.imgDeleteHndl,
"commit": s.imgCommitHndl,
"cleanup": s.imgCleanupHndl,
"info": s.imgInfoHndl,
Expand Down
13 changes: 13 additions & 0 deletions backend/app/store/image/bolt_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"time"

Expand Down Expand Up @@ -118,6 +119,18 @@ func (b *Bolt) Load(id string) ([]byte, error) {
return data, nil
}

// Delete image from storage
func (b *Bolt) Delete(id string) error {
return b.db.Update(func(tx *bolt.Tx) error {
// deleting a non-existing key doesn't return an error, so joining errors from deleting an image
// from both buckets is safe and will return nil if there are no errors on the real delete
// or image is absent in both buckets
err := tx.Bucket([]byte(imagesBktName)).Delete([]byte(id))
err = errors.Join(err, tx.Bucket([]byte(imagesStagedBktName)).Delete([]byte(id)))
return err
})
}

// Cleanup runs scan of staging and removes old data based on ttl
func (b *Bolt) Cleanup(_ context.Context, ttl time.Duration) error {
return b.db.Update(func(tx *bolt.Tx) error {
Expand Down
30 changes: 30 additions & 0 deletions backend/app/store/image/bolt_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,36 @@ func TestBoltStore_LoadAfterSave(t *testing.T) {
assert.Error(t, err)
}

func TestBoltStore_LoadAfterDelete(t *testing.T) {
svc, teardown := prepareBoltImageStorageTest(t)
defer teardown()

// delete image from permanent storage
id := "test_img"
err := svc.Save(id, gopherPNGBytes())
assert.NoError(t, err)

err = svc.Commit(id)
require.NoError(t, err)

err = svc.Delete(id)
assert.NoError(t, err)

_, err = svc.Load(id)
assert.Error(t, err)

// delete staging image
id = "staging_img"
err = svc.Save(id, gopherPNGBytes())
assert.NoError(t, err)

err = svc.Delete(id)
assert.NoError(t, err)

_, err = svc.Load(id)
assert.Error(t, err)
}

func TestBoltStore_Cleanup(t *testing.T) {
svc, teardown := prepareBoltImageStorageTest(t)
defer teardown()
Expand Down
25 changes: 24 additions & 1 deletion backend/app/store/image/fs_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ type FileSystem struct {
Staging string
Partitions int

crc struct {
moveLock sync.Mutex // needed only for deleting images or moving them from staging to permanent storage
crc struct {
*crc64.Table
sync.Once
mask string
Expand All @@ -50,6 +51,8 @@ func (f *FileSystem) Save(id string, img []byte) error {

// Commit file stored in staging location by moving it to permanent location
func (f *FileSystem) Commit(id string) error {
f.moveLock.Lock()
defer f.moveLock.Unlock()
log.Printf("[DEBUG] Commit image %s", id)
stagingImage, permImage := f.location(f.Staging, id), f.location(f.Location, id)

Expand Down Expand Up @@ -107,11 +110,31 @@ func (f *FileSystem) Load(id string) ([]byte, error) {
return io.ReadAll(fh)
}

// Delete image from storage
func (f *FileSystem) Delete(id string) error {
f.moveLock.Lock()
defer f.moveLock.Unlock()
staging := f.location(f.Staging, id)
// file doesn't exist on staging, delete from permanent location
if _, err := os.Stat(staging); os.IsNotExist(err) {
file := f.location(f.Location, id)
e := os.Remove(file)
_ = os.Remove(path.Dir(file)) // try to remove directory
return e
}
// delete file from staging
err := os.Remove(staging)
_ = os.Remove(path.Dir(staging)) // try to remove directory
return err
}

// Cleanup runs scan of staging and removes old files based on ttl
func (f *FileSystem) Cleanup(_ context.Context, ttl time.Duration) error {
if _, err := os.Stat(f.Staging); os.IsNotExist(err) {
return nil
}
f.moveLock.Lock()
defer f.moveLock.Unlock()

// we can ignore context as on local FS remove is relatively fast operation
err := filepath.Walk(f.Staging, func(fpath string, info os.FileInfo, err error) error {
Expand Down
25 changes: 25 additions & 0 deletions backend/app/store/image/fs_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,31 @@ func TestFsStore_LoadAfterCommit(t *testing.T) {
assert.Error(t, err)
}

func TestFsStore_LoadAfterDelete(t *testing.T) {
svc, teardown := prepareImageTest(t)
defer teardown()

id := "test_img"
err := svc.Save(id, gopherPNGBytes())
assert.NoError(t, err)
err = svc.Commit(id)
require.NoError(t, err)
err = svc.Delete(id)
require.NoError(t, err)

_, err = svc.Load(id)
assert.Error(t, err)

// create file on staging
err = svc.Save(id, gopherPNGBytes())
assert.NoError(t, err)
err = svc.Delete(id)
require.NoError(t, err)

_, err = svc.Load(id)
assert.Error(t, err)
}

func TestFsStore_location(t *testing.T) {
tbl := []struct {
partitions int
Expand Down
6 changes: 6 additions & 0 deletions backend/app/store/image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type Store interface {
Info() (StoreInfo, error) // get meta information about storage
Save(id string, img []byte) error // store image with passed id to staging
Load(id string) ([]byte, error) // load image by ID
Delete(id string) error // delete image by ID

ResetCleanupTimer(id string) error // resets cleanup timer for the image, called on comment preview
Commit(id string) error // move image from staging to permanent
Expand Down Expand Up @@ -212,6 +213,11 @@ func (s *Service) Load(id string) ([]byte, error) {
return s.store.Load(id)
}

// Delete wraps storage Delete function.
func (s *Service) Delete(id string) error {
return s.store.Delete(id)
}

// Save wraps storage Save function, validating and resizing the image before calling it.
func (s *Service) Save(userID string, r io.Reader) (id string, err error) {
id = path.Join(userID, guid())
Expand Down
48 changes: 46 additions & 2 deletions backend/app/store/image/image_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions backend/app/store/image/remote_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ func (r *RPC) Load(id string) ([]byte, error) {
return io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(rawImg)))
}

// Delete image from storage
func (r *RPC) Delete(id string) error {
_, err := r.Call("image.delete", id)
return err
}

// Commit file stored in staging location by moving it to permanent location
func (r *RPC) Commit(id string) error {
_, err := r.Call("image.commit", id)
Expand Down
Loading

0 comments on commit 81c30e0

Please sign in to comment.