diff --git a/indexedDb/impl/channels/implementation.go b/indexedDb/impl/channels/implementation.go index c858614524f989e5851d9add3f0bb4f304587f3c..54ec455870bb77645ec76f4e2cea7043bd5997e1 100644 --- a/indexedDb/impl/channels/implementation.go +++ b/indexedDb/impl/channels/implementation.go @@ -22,9 +22,11 @@ import ( jww "github.com/spf13/jwalterweatherman" "gitlab.com/elixxir/client/v4/channels" + cft "gitlab.com/elixxir/client/v4/channelsFileTransfer" "gitlab.com/elixxir/client/v4/cmix/rounds" cryptoBroadcast "gitlab.com/elixxir/crypto/broadcast" cryptoChannel "gitlab.com/elixxir/crypto/channel" + "gitlab.com/elixxir/crypto/fileTransfer" "gitlab.com/elixxir/crypto/message" "gitlab.com/elixxir/xxdk-wasm/indexedDb/impl" wChannels "gitlab.com/elixxir/xxdk-wasm/indexedDb/worker/channels" @@ -45,6 +47,131 @@ type wasmModel struct { mutedUserCB wChannels.MutedUserCallback } +// ReceiveFile is called when a file upload or download beings. +// +// fileLink and fileData are nillable and may be updated based +// upon the UUID or file ID later. +// +// fileID is always unique to the fileData. fileLink is the JSON of +// channelsFileTransfer.FileLink. +// +// Returns any fatal errors. +func (w *wasmModel) ReceiveFile(fileID fileTransfer.ID, fileLink, + fileData []byte, timestamp time.Time, status cft.Status) error { + + newFile := &File{ + Id: fileID.Marshal(), + Data: fileData, + Link: fileLink, + Timestamp: timestamp, + Status: uint8(status), + } + return w.upsertFile(newFile) +} + +// UpdateFile is called when a file upload or download completes or changes. +// +// fileLink, fileData, timestamp, and status are all nillable and may be +// updated based upon the file ID at a later date. If a nil value is passed, +// then make no update. +// +// Returns an error if the file cannot be updated. It must return +// channels.NoMessageErr if the file does not exist. +func (w *wasmModel) UpdateFile(fileID fileTransfer.ID, fileLink, + fileData []byte, timestamp *time.Time, status *cft.Status) error { + parentErr := "[Channels indexedDB] failed to UpdateFile" + + // Get the File as it currently exists in storage + fileObj, err := impl.Get(w.db, fileStoreName, impl.EncodeBytes(fileID.Marshal())) + if err != nil { + if strings.Contains(err.Error(), impl.ErrDoesNotExist) { + return errors.WithMessage(channels.NoMessageErr, parentErr) + } + return errors.WithMessage(err, parentErr) + } + currentFile, err := valueToFile(fileObj) + if err != nil { + return errors.WithMessage(err, parentErr) + } + + // Update the fields if specified + if status != nil { + currentFile.Status = uint8(*status) + } + if timestamp != nil { + currentFile.Timestamp = *timestamp + } + if fileData != nil { + currentFile.Data = fileData + } + if fileLink != nil { + currentFile.Link = fileLink + } + + return w.upsertFile(currentFile) +} + +// upsertFile is a helper function that will update an existing File +// if File.Id is specified. Otherwise, it will perform an insert. +func (w *wasmModel) upsertFile(newFile *File) error { + newFileJson, err := json.Marshal(&newFile) + if err != nil { + return err + } + fileObj, err := utils.JsonToJS(newFileJson) + if err != nil { + return err + } + + _, err = impl.Put(w.db, fileStoreName, fileObj) + return err +} + +// GetFile returns the ModelFile containing the file data and download link +// for the given file ID. +// +// Returns an error if the file cannot be retrieved. It must return +// channels.NoMessageErr if the file does not exist. +func (w *wasmModel) GetFile(fileID fileTransfer.ID) ( + cft.ModelFile, error) { + fileObj, err := impl.Get(w.db, fileStoreName, + impl.EncodeBytes(fileID.Marshal())) + if err != nil { + if strings.Contains(err.Error(), impl.ErrDoesNotExist) { + return cft.ModelFile{}, channels.NoMessageErr + } + return cft.ModelFile{}, err + } + + resultFile, err := valueToFile(fileObj) + if err != nil { + return cft.ModelFile{}, err + } + + result := cft.ModelFile{ + ID: fileTransfer.NewID(resultFile.Data), + Link: resultFile.Link, + Data: resultFile.Data, + Timestamp: resultFile.Timestamp, + Status: cft.Status(resultFile.Status), + } + return result, nil +} + +// DeleteFile deletes the file with the given file ID. +// +// Returns fatal errors. It must return channels.NoMessageErr if the file +// does not exist. +func (w *wasmModel) DeleteFile(fileID fileTransfer.ID) error { + err := impl.Delete(w.db, fileStoreName, impl.EncodeBytes(fileID.Marshal())) + if err != nil { + if strings.Contains(err.Error(), impl.ErrDoesNotExist) { + return channels.NoMessageErr + } + } + return err +} + // JoinChannel is called whenever a channel is joined locally. func (w *wasmModel) JoinChannel(channel *cryptoBroadcast.Channel) { parentErr := errors.New("failed to JoinChannel") @@ -70,7 +197,7 @@ func (w *wasmModel) JoinChannel(channel *cryptoBroadcast.Channel) { return } - _, err = impl.Put(w.db, channelsStoreName, channelObj) + _, err = impl.Put(w.db, channelStoreName, channelObj) if err != nil { jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, "Unable to put Channel: %+v", err)) @@ -82,7 +209,7 @@ func (w *wasmModel) LeaveChannel(channelID *id.ID) { parentErr := errors.New("failed to LeaveChannel") // Delete the channel from storage - err := impl.Delete(w.db, channelsStoreName, js.ValueOf(channelID.String())) + err := impl.Delete(w.db, channelStoreName, js.ValueOf(channelID.String())) if err != nil { jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, "Unable to delete Channel: %+v", err)) @@ -505,9 +632,11 @@ func (w *wasmModel) MuteUser( // valueToMessage is a helper for converting js.Value to Message. func valueToMessage(msgObj js.Value) (*Message, error) { resultMsg := &Message{} - err := json.Unmarshal([]byte(utils.JsToJson(msgObj)), resultMsg) - if err != nil { - return nil, err - } - return resultMsg, nil + return resultMsg, json.Unmarshal([]byte(utils.JsToJson(msgObj)), resultMsg) +} + +// valueToFile is a helper for converting js.Value to File. +func valueToFile(fileObj js.Value) (*File, error) { + resultFile := &File{} + return resultFile, json.Unmarshal([]byte(utils.JsToJson(fileObj)), resultFile) } diff --git a/indexedDb/impl/channels/implementation_test.go b/indexedDb/impl/channels/implementation_test.go index 9577217901a13075bcd6b5bb3848d69d8577e81c..4b22b8300f5549a5dc2f3902f7ed95277777555f 100644 --- a/indexedDb/impl/channels/implementation_test.go +++ b/indexedDb/impl/channels/implementation_test.go @@ -10,9 +10,13 @@ package main import ( + "bytes" "crypto/ed25519" "encoding/json" + "errors" "fmt" + cft "gitlab.com/elixxir/client/v4/channelsFileTransfer" + "gitlab.com/elixxir/crypto/fileTransfer" "os" "strconv" "testing" @@ -42,6 +46,79 @@ func dummyReceivedMessageCB(uint64, *id.ID, bool) {} func dummyDeletedMessageCB(message.ID) {} func dummyMutedUserCB(*id.ID, ed25519.PublicKey, bool) {} +// Happy path test for receiving, updating, getting, and deleting a File. +func TestWasmModel_ReceiveFile(t *testing.T) { + testString := "TestWasmModel_ReceiveFile" + m, err := newWASMModel(testString, nil, + dummyReceivedMessageCB, dummyDeletedMessageCB, dummyMutedUserCB) + if err != nil { + t.Fatal(err) + } + + testTs := time.Now() + testBytes := []byte(testString) + testStatus := cft.Downloading + + // Insert a test row + fId := fileTransfer.NewID(testBytes) + err = m.ReceiveFile(fId, testBytes, testBytes, testTs, testStatus) + if err != nil { + t.Fatal(err) + } + + // Attempt to get stored row + storedFile, err := m.GetFile(fId) + if err != nil { + t.Fatal(err) + } + // Spot check stored attribute + if !bytes.Equal(storedFile.Link, testBytes) { + t.Fatalf("Got unequal FileLink values") + } + + // Attempt to updated stored row + newTs := time.Now() + newBytes := []byte("test") + newStatus := cft.Complete + err = m.UpdateFile(fId, nil, newBytes, &newTs, &newStatus) + if err != nil { + t.Fatal(err) + } + + // Check that the update took + updatedFile, err := m.GetFile(fId) + if err != nil { + t.Fatal(err) + } + // Link should not have changed + if !bytes.Equal(updatedFile.Link, testBytes) { + t.Fatalf("Link should not have changed") + } + // Other attributes should have changed + if !bytes.Equal(updatedFile.Data, newBytes) { + t.Fatalf("Data should have updated") + } + if !updatedFile.Timestamp.Equal(newTs) { + t.Fatalf("TS should have updated, expected %s got %s", + newTs, updatedFile.Timestamp) + } + if updatedFile.Status != newStatus { + t.Fatalf("Status should have updated") + } + + // Delete the row + err = m.DeleteFile(fId) + if err != nil { + t.Fatal(err) + } + + // Check that the delete operation took and get provides the expected error + _, err = m.GetFile(fId) + if err == nil || !errors.Is(channels.NoMessageErr, err) { + t.Fatal(err) + } +} + // Happy path, insert message and look it up func TestWasmModel_GetMessage(t *testing.T) { cipher, err := cryptoChannel.NewCipher( @@ -54,7 +131,7 @@ func TestWasmModel_GetMessage(t *testing.T) { if c != nil { cs = "_withCipher" } - testString := "TestWasmModel_msgIDLookup" + cs + testString := "TestWasmModel_GetMessage" + cs t.Run(testString, func(t *testing.T) { storage.GetLocalStorage().Clear() testMsgId := message.DeriveChannelMessageID(&id.ID{1}, 0, []byte(testString)) @@ -235,7 +312,7 @@ func Test_wasmModel_JoinChannel_LeaveChannel(t *testing.T) { } eventModel.JoinChannel(testChannel) eventModel.JoinChannel(testChannel2) - results, err2 := impl.Dump(eventModel.db, channelsStoreName) + results, err2 := impl.Dump(eventModel.db, channelStoreName) if err2 != nil { t.Fatal(err2) } @@ -243,7 +320,7 @@ func Test_wasmModel_JoinChannel_LeaveChannel(t *testing.T) { t.Fatalf("Expected 2 channels to exist") } eventModel.LeaveChannel(testChannel.ReceptionID) - results, err = impl.Dump(eventModel.db, channelsStoreName) + results, err = impl.Dump(eventModel.db, channelStoreName) if err != nil { t.Fatal(err) } diff --git a/indexedDb/impl/channels/init.go b/indexedDb/impl/channels/init.go index 9a8b940898ac30e81fa755e9d4053a21220ef66b..363440d541029007a6c167d94f8a1fcd66edf4fd 100644 --- a/indexedDb/impl/channels/init.go +++ b/indexedDb/impl/channels/init.go @@ -23,7 +23,7 @@ import ( // currentVersion is the current version of the IndexedDb runtime. Used for // migration purposes. -const currentVersion uint = 1 +const currentVersion uint = 2 // NewWASMEventModel returns a [channels.EventModel] backed by a wasmModel. // The name should be a base64 encoding of the users public key. Returns the @@ -62,6 +62,14 @@ func newWASMModel(databaseName string, encryption cryptoChannel.Cipher, oldVersion = 1 } + if oldVersion == 1 && newVersion >= 2 { + err := v2Upgrade(db) + if err != nil { + return err + } + oldVersion = 2 + } + // if oldVersion == 1 && newVersion >= 2 { v2Upgrade(), oldVersion = 2 } return nil }) @@ -136,10 +144,22 @@ func v1Upgrade(db *idb.Database) error { } // Build Channel ObjectStore - _, err = db.CreateObjectStore(channelsStoreName, storeOpts) + _, err = db.CreateObjectStore(channelStoreName, storeOpts) if err != nil { return err } return nil } + +// v1Upgrade performs the v1 -> v2 database upgrade. +// +// This can never be changed without permanently breaking backwards +// compatibility. +func v2Upgrade(db *idb.Database) error { + _, err := db.CreateObjectStore(fileStoreName, idb.ObjectStoreOptions{ + KeyPath: js.ValueOf(pkeyName), + AutoIncrement: false, + }) + return err +} diff --git a/indexedDb/impl/channels/model.go b/indexedDb/impl/channels/model.go index d1dcee88b3efd5f4a877685366154caefaa46a82..e5d3e00aa5209985de60a77f5330a53208b1af88 100644 --- a/indexedDb/impl/channels/model.go +++ b/indexedDb/impl/channels/model.go @@ -18,8 +18,9 @@ const ( pkeyName = "id" // Text representation of the names of the various [idb.ObjectStore]. - messageStoreName = "messages" - channelsStoreName = "channels" + messageStoreName = "messages" + channelStoreName = "channels" + fileStoreName = "files" // Message index names. messageStoreMessageIndex = "message_id_index" @@ -73,3 +74,21 @@ type Channel struct { Name string `json:"name"` Description string `json:"description"` } + +// File defines the IndexedDb representation of a single File. +type File struct { + // Id is a unique identifier for a given File. + Id []byte `json:"id"` // Matches pkeyName + + // Data stores the actual contents of the File. + Data []byte `json:"data"` + + // Link contains all the information needed to download the file data. + Link []byte `json:"link"` + + // Timestamp is the last time the file data, link, or status was modified. + Timestamp time.Time `json:"timestamp"` + + // Status of the file in the event model. + Status uint8 `json:"status"` +} diff --git a/indexedDb/impl/utils_test.go b/indexedDb/impl/utils_test.go index 6b6da6ff36ee8be5412146443093ee7d85c07c4f..00e235834c44788905af73cafd7448961708a4dc 100644 --- a/indexedDb/impl/utils_test.go +++ b/indexedDb/impl/utils_test.go @@ -40,6 +40,20 @@ func TestGetIndex_NoMessageError(t *testing.T) { } } +// Test simple put on empty DB is successful +func TestPut(t *testing.T) { + objectStoreName := "messages" + db := newTestDB(objectStoreName, "index", t) + testValue := js.ValueOf(make(map[string]interface{})) + result, err := Put(db, objectStoreName, testValue) + if err != nil { + t.Fatalf(err.Error()) + } + if !result.Equal(js.ValueOf(1)) { + t.Fatalf("Failed to generate autoincremented key") + } +} + // newTestDB creates a new idb.Database for testing. func newTestDB(name, index string, t *testing.T) *idb.Database { // Attempt to open database object