diff --git a/Makefile b/Makefile index 193a72db0633569291863a33b0bb8f0afab7bb0d..f8c143a6a24f68f6b70d2bc64cc6028b56f4c9af 100644 --- a/Makefile +++ b/Makefile @@ -11,11 +11,11 @@ build: GOOS=js GOARCH=wasm go build ./... update_release: - GOFLAGS="" go get -d gitlab.com/elixxir/client/v4@release - GOFLAGS="" go get gitlab.com/elixxir/crypto@release + GOFLAGS="" go get gitlab.com/xx_network/primitives@release GOFLAGS="" go get gitlab.com/elixxir/primitives@release GOFLAGS="" go get gitlab.com/xx_network/crypto@release - GOFLAGS="" go get gitlab.com/xx_network/primitives@release + GOFLAGS="" go get gitlab.com/elixxir/crypto@release + GOFLAGS="" go get -d gitlab.com/elixxir/client/v4@release update_master: GOFLAGS="" go get -d gitlab.com/elixxir/client@master diff --git a/go.mod b/go.mod index c259f8c90bf14352c7d6428cb4210911bef52f47..3ab70b64473519782a56ca71e5a1a21e24c7ec29 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/hack-pad/go-indexeddb v0.2.0 github.com/pkg/errors v0.9.1 github.com/spf13/jwalterweatherman v1.1.0 - gitlab.com/elixxir/client/v4 v4.6.2-0.20230413170823-18dc8e973dfb - gitlab.com/elixxir/crypto v0.0.7-0.20230322181929-8cb5fa100824 + gitlab.com/elixxir/client/v4 v4.6.2-0.20230413171204-002612660098 + gitlab.com/elixxir/crypto v0.0.7-0.20230413162806-a99ec4bfea32 gitlab.com/elixxir/primitives v0.0.3-0.20230214180039-9a25e2d3969c gitlab.com/xx_network/crypto v0.0.5-0.20230214003943-8a09396e95dd gitlab.com/xx_network/primitives v0.0.4-0.20230310205521-c440e68e34c4 diff --git a/go.sum b/go.sum index a82bd19e13e7d402351692a5751ee4c0dc7869d2..3bed21b3c592d81e5ca980ecc6f26831f302764c 100644 --- a/go.sum +++ b/go.sum @@ -391,12 +391,12 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= gitlab.com/elixxir/bloomfilter v0.0.0-20230315224936-a4459418f300 h1:oF3Pkf5NBb48KB89Q4sQXKQCIsWp1IVsqKWHWFsfBRc= gitlab.com/elixxir/bloomfilter v0.0.0-20230315224936-a4459418f300/go.mod h1:1X8gRIAPDisS3W6Vtr/ymiUmZMJUIwDV1o5DEOo/pzw= -gitlab.com/elixxir/client/v4 v4.6.2-0.20230413170823-18dc8e973dfb h1:xLxLy8g8NwUgYR+P5hUbUb35SRZeuv/HZErmlbIkBgs= -gitlab.com/elixxir/client/v4 v4.6.2-0.20230413170823-18dc8e973dfb/go.mod h1:WEYVoIXHi2YMR0JafS5pHNWLOtnkLBBt8KdhKF/ZHcY= +gitlab.com/elixxir/client/v4 v4.6.2-0.20230413171204-002612660098 h1:bdwXgEa0i9KpLiKQdhv6MEWAYLt3MsbNuIzFanVpWLY= +gitlab.com/elixxir/client/v4 v4.6.2-0.20230413171204-002612660098/go.mod h1:G+lN+LvQPGcm5BQnrhnqT1xiRIAzH3OffAM+5oI9SUg= gitlab.com/elixxir/comms v0.0.4-0.20230310205528-f06faa0d2f0b h1:8AVK93UEs/aufoqtFgyMVt9gf0oJ8F4pA60ZvEVvG+s= gitlab.com/elixxir/comms v0.0.4-0.20230310205528-f06faa0d2f0b/go.mod h1:z+qW0D9VpY5QKTd7wRlb5SK4kBNqLYsa4DXBcUXue9Q= -gitlab.com/elixxir/crypto v0.0.7-0.20230322181929-8cb5fa100824 h1:6gmaBG4glJKA41SV2tNBbT6mFwTEXR9Jn9JaU6JSSKM= -gitlab.com/elixxir/crypto v0.0.7-0.20230322181929-8cb5fa100824/go.mod h1:/SLOlvkYVVJf6IU+vEjMLnS7cjjcoTlPV45g6tv6INc= +gitlab.com/elixxir/crypto v0.0.7-0.20230413162806-a99ec4bfea32 h1:Had0F7rMPgJJ2BUZoFNgeJq33md9RpV15nvd08Uxdzc= +gitlab.com/elixxir/crypto v0.0.7-0.20230413162806-a99ec4bfea32/go.mod h1:/SLOlvkYVVJf6IU+vEjMLnS7cjjcoTlPV45g6tv6INc= gitlab.com/elixxir/ekv v0.2.1 h1:dtwbt6KmAXG2Tik5d60iDz2fLhoFBgWwST03p7T+9Is= gitlab.com/elixxir/ekv v0.2.1/go.mod h1:USLD7xeDnuZEavygdrgzNEwZXeLQJK/w1a+htpN+JEU= gitlab.com/elixxir/primitives v0.0.3-0.20230214180039-9a25e2d3969c h1:muG8ff95woeVVwQoJHCEclxBFB22lc7EixPylEkYDRU= diff --git a/indexedDb/impl/channels/callbacks.go b/indexedDb/impl/channels/callbacks.go index b3add64fcf95da83000434c784e96199a5fc3c0e..df560535a779f2e6c355c6e49a66d4b45c2047e2 100644 --- a/indexedDb/impl/channels/callbacks.go +++ b/indexedDb/impl/channels/callbacks.go @@ -258,8 +258,12 @@ func (m *manager) updateFromUUIDCB(data []byte) ([]byte, error) { status = &msg.Status } - m.model.UpdateFromUUID( + err = m.model.UpdateFromUUID( msg.UUID, messageID, timestamp, round, pinned, hidden, status) + if err != nil { + return []byte(err.Error()), nil + } + return nil, nil } @@ -292,15 +296,21 @@ func (m *manager) updateFromMessageIDCB(data []byte) ([]byte, error) { status = &msg.Status } - uuid := m.model.UpdateFromMessageID( + var ue wChannels.UuidError + uuid, err := m.model.UpdateFromMessageID( msg.MessageID, timestamp, round, pinned, hidden, status) + if err != nil { + ue.Error = []byte(err.Error()) + } else { + ue.UUID = uuid + } - uuidData, err := json.Marshal(uuid) + data, err = json.Marshal(ue) if err != nil { - return nil, errors.Errorf("failed to JSON marshal UUID: %+v", err) + return nil, errors.Errorf("failed to JSON marshal %T: %+v", ue, err) } - return uuidData, nil + return data, nil } // getMessageCB is the callback for wasmModel.GetMessage. Returns JSON diff --git a/indexedDb/impl/channels/implementation.go b/indexedDb/impl/channels/implementation.go index 627dd313a36974be2ff40ead5b00ee9f0635bfd3..54ec455870bb77645ec76f4e2cea7043bd5997e1 100644 --- a/indexedDb/impl/channels/implementation.go +++ b/indexedDb/impl/channels/implementation.go @@ -13,6 +13,7 @@ import ( "crypto/ed25519" "encoding/json" "strconv" + "strings" "syscall/js" "time" @@ -21,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" @@ -44,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") @@ -69,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)) @@ -81,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)) @@ -253,10 +381,13 @@ func (w *wasmModel) ReceiveReaction(channelID *id.ID, messageID, // messageID, timestamp, round, pinned, and hidden are all nillable and may be // updated based upon the UUID at a later date. If a nil value is passed, then // make no update. +// +// Returns an error if the message cannot be updated. It must return +// channels.NoMessageErr if the message does not exist. func (w *wasmModel) UpdateFromUUID(uuid uint64, messageID *message.ID, timestamp *time.Time, round *rounds.Round, pinned, hidden *bool, - status *channels.SentStatus) { - parentErr := errors.New("failed to UpdateFromUUID") + status *channels.SentStatus) error { + parentErr := "failed to UpdateFromUUID" // Convert messageID to the key generated by json.Marshal key := js.ValueOf(uuid) @@ -264,24 +395,24 @@ func (w *wasmModel) UpdateFromUUID(uuid uint64, messageID *message.ID, // Use the key to get the existing Message msgObj, err := impl.Get(w.db, messageStoreName, key) if err != nil { - jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, - "Unable to get message: %+v", err)) - return + if strings.Contains(err.Error(), impl.ErrDoesNotExist) { + return errors.WithMessage(channels.NoMessageErr, parentErr) + } + return errors.WithMessage(err, parentErr) } currentMsg, err := valueToMessage(msgObj) if err != nil { - jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, - "Failed to marshal Message: %+v", err)) - return + return errors.WithMessagef(err, + "%s Failed to marshal Message", parentErr) } _, err = w.updateMessage(currentMsg, messageID, timestamp, round, pinned, hidden, status) if err != nil { - jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, - "Unable to updateMessage: %+v", err)) + return errors.WithMessage(err, parentErr) } + return nil } // UpdateFromMessageID is called whenever a message with the message ID is @@ -293,33 +424,35 @@ func (w *wasmModel) UpdateFromUUID(uuid uint64, messageID *message.ID, // timestamp, round, pinned, and hidden are all nillable and may be updated // based upon the UUID at a later date. If a nil value is passed, then make // no update. +// +// Returns an error if the message cannot be updated. It must return +// channels.NoMessageErr if the message does not exist. func (w *wasmModel) UpdateFromMessageID(messageID message.ID, timestamp *time.Time, round *rounds.Round, pinned, hidden *bool, - status *channels.SentStatus) uint64 { - parentErr := errors.New("failed to UpdateFromMessageID") + status *channels.SentStatus) (uint64, error) { + parentErr := "failed to UpdateFromMessageID" msgObj, err := impl.GetIndex(w.db, messageStoreName, messageStoreMessageIndex, impl.EncodeBytes(messageID.Marshal())) if err != nil { - jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, - "Failed to get message by index: %+v", err)) - return 0 + if strings.Contains(err.Error(), impl.ErrDoesNotExist) { + return 0, errors.WithMessage(channels.NoMessageErr, parentErr) + } + return 0, errors.WithMessage(err, parentErr) } currentMsg, err := valueToMessage(msgObj) if err != nil { - jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, - "Failed to marshal Message: %+v", err)) - return 0 + return 0, errors.WithMessagef(err, + "%s Failed to marshal Message", parentErr) } uuid, err := w.updateMessage(currentMsg, &messageID, timestamp, round, pinned, hidden, status) if err != nil { - jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, - "Unable to updateMessage: %+v", err)) + return 0, errors.WithMessage(err, parentErr) } - return uuid + return uuid, nil } // buildMessage is a private helper that converts typical [channels.EventModel] @@ -499,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 diff --git a/indexedDb/worker/channels/implementation.go b/indexedDb/worker/channels/implementation.go index 4d51b97e6a2941e07849c0e2e405539ccc86a8f7..9639bc185095e74cbf4b3e63256fead39bbcef82 100644 --- a/indexedDb/worker/channels/implementation.go +++ b/indexedDb/worker/channels/implementation.go @@ -263,9 +263,12 @@ type MessageUpdateInfo struct { // messageID, timestamp, round, pinned, and hidden are all nillable and may be // updated based upon the UUID at a later date. If a nil value is passed, then // make no update. +// +// Returns an error if the message cannot be updated. It must return +// [channels.NoMessageErr] if the message does not exist. func (w *wasmModel) UpdateFromUUID(uuid uint64, messageID *message.ID, timestamp *time.Time, round *rounds.Round, pinned, hidden *bool, - status *channels.SentStatus) { + status *channels.SentStatus) error { msg := MessageUpdateInfo{UUID: uuid} if messageID != nil { msg.MessageID = *messageID @@ -294,12 +297,33 @@ func (w *wasmModel) UpdateFromUUID(uuid uint64, messageID *message.ID, data, err := json.Marshal(msg) if err != nil { - jww.ERROR.Printf( - "[CH] Could not JSON marshal payload for UpdateFromUUID: %+v", err) - return + return errors.Errorf( + "could not JSON marshal payload for UpdateFromUUID: %+v", err) } - w.wm.SendMessage(UpdateFromUUIDTag, data, nil) + errChan := make(chan error) + w.wm.SendMessage(UpdateFromUUIDTag, data, func(data []byte) { + if data != nil { + errChan <- errors.New(string(data)) + } else { + errChan <- nil + } + }) + + select { + case err = <-errChan: + return err + case <-time.After(worker.ResponseTimeout): + return errors.Errorf("timed out after %s waiting for response from "+ + "the worker about UpdateFromUUID", worker.ResponseTimeout) + } +} + +// UuidError is JSON marshalled and sent to the worker for +// [wasmModel.UpdateFromMessageID]. +type UuidError struct { + UUID uint64 `json:"uuid"` + Error []byte `json:"error"` } // UpdateFromMessageID is called whenever a message with the message ID is @@ -313,7 +337,7 @@ func (w *wasmModel) UpdateFromUUID(uuid uint64, messageID *message.ID, // no update. func (w *wasmModel) UpdateFromMessageID(messageID message.ID, timestamp *time.Time, round *rounds.Round, pinned, hidden *bool, - status *channels.SentStatus) uint64 { + status *channels.SentStatus) (uint64, error) { msg := MessageUpdateInfo{MessageID: messageID, MessageIDSet: true} if timestamp != nil { @@ -339,33 +363,34 @@ func (w *wasmModel) UpdateFromMessageID(messageID message.ID, data, err := json.Marshal(msg) if err != nil { - jww.ERROR.Printf("[CH] Could not JSON marshal payload for "+ + return 0, errors.Errorf("could not JSON marshal payload for "+ "UpdateFromMessageID: %+v", err) - return 0 } uuidChan := make(chan uint64) + errChan := make(chan error) w.wm.SendMessage(UpdateFromMessageIDTag, data, func(data []byte) { - var uuid uint64 - err = json.Unmarshal(data, &uuid) - if err != nil { - jww.ERROR.Printf("[CH] Could not JSON unmarshal response to "+ - "UpdateFromMessageID: %+v", err) - uuidChan <- 0 + var ue UuidError + if err = json.Unmarshal(data, &ue); err != nil { + errChan <- errors.Errorf("could not JSON unmarshal response "+ + "to UpdateFromMessageID: %+v", err) + } else if ue.Error != nil { + errChan <- errors.New(string(ue.Error)) + } else { + uuidChan <- ue.UUID } - uuidChan <- uuid }) select { case uuid := <-uuidChan: - return uuid + return uuid, nil + case err = <-errChan: + return 0, err case <-time.After(worker.ResponseTimeout): - jww.ERROR.Printf("[CH] Timed out after %s waiting for response from "+ + return 0, errors.Errorf("timed out after %s waiting for response from "+ "the worker about UpdateFromMessageID", worker.ResponseTimeout) } - - return 0 } // GetMessageMessage is JSON marshalled and sent to the worker for diff --git a/main.go b/main.go index 05277d5985e1884275fb1b53476c0c233a6dd358..c109ff54f41fc0c3acb34bd7d3c848013237360c 100644 --- a/main.go +++ b/main.go @@ -89,9 +89,15 @@ func main() { js.Global().Set("GetShareUrlType", js.FuncOf(wasm.GetShareUrlType)) js.Global().Set("ValidForever", js.FuncOf(wasm.ValidForever)) js.Global().Set("IsNicknameValid", js.FuncOf(wasm.IsNicknameValid)) + js.Global().Set("GetNoMessageErr", js.FuncOf(wasm.GetNoMessageErr)) + js.Global().Set("CheckNoMessageErr", js.FuncOf(wasm.CheckNoMessageErr)) js.Global().Set("NewChannelsDatabaseCipher", js.FuncOf(wasm.NewChannelsDatabaseCipher)) + // wasm/dm.go + js.Global().Set("InitChannelsFileTransfer", + js.FuncOf(wasm.InitChannelsFileTransfer)) + // wasm/dm.go js.Global().Set("NewDMClient", js.FuncOf(wasm.NewDMClient)) js.Global().Set("NewDMClientWithIndexedDb", diff --git a/wasm/channels.go b/wasm/channels.go index a775928eb820439bdb1a4739d8011b1b952cf7de..307fe5cee304ff580ba1726b8036ce81268cc991 100644 --- a/wasm/channels.go +++ b/wasm/channels.go @@ -256,7 +256,11 @@ func GetPublicChannelIdentityFromPrivate(_ js.Value, args []js.Value) any { // using [Cmix.GetID]. // - args[1] - Bytes of a private identity ([channel.PrivateIdentity]) that is // generated by [GenerateChannelIdentity] (Uint8Array). -// - args[2] - A function that initialises and returns a Javascript object +// - args[2] - JSON of an array of integers of [channels.ExtensionBuilder] +// IDs. The ID can be retrieved from an object with an extension builder +// (e.g., [ChannelsFileTransfer.GetExtensionBuilderID]). Leave empty if not +// using extension builders. Example: `[2,11,5]` (Uint8Array). +// - args[3] - A function that initialises and returns a Javascript object // that matches the [bindings.EventModel] interface. The function must match // the Build function in [bindings.EventModelBuilder]. // @@ -264,10 +268,13 @@ func GetPublicChannelIdentityFromPrivate(_ js.Value, args []js.Value) any { // - Javascript representation of the [ChannelsManager] object. // - Throws a TypeError if creating the manager fails. func NewChannelsManager(_ js.Value, args []js.Value) any { + cmixId := args[0].Int() privateIdentity := utils.CopyBytesToGo(args[1]) - em := newEventModelBuilder(args[2]) + extensionBuilderIDsJSON := utils.CopyBytesToGo(args[2]) + em := newEventModelBuilder(args[3]) - cm, err := bindings.NewChannelsManager(args[0].Int(), privateIdentity, em) + cm, err := bindings.NewChannelsManager( + cmixId, privateIdentity, extensionBuilderIDsJSON, em) if err != nil { utils.Throw(utils.TypeError, err) return nil @@ -324,7 +331,11 @@ func LoadChannelsManager(_ js.Value, args []js.Value) any { // - args[1] - Path to Javascript file that starts the worker (string). // - args[2] - Bytes of a private identity ([channel.PrivateIdentity]) that is // generated by [GenerateChannelIdentity] (Uint8Array). -// - args[3] - The received message callback, which is called everytime a +// - args[3] - JSON of an array of integers of [channels.ExtensionBuilder] +// IDs. The ID can be retrieved from an object with an extension builder +// (e.g., [ChannelsFileTransfer.GetExtensionBuilderID]). Leave empty if not +// using extension builders. Example: `[2,11,5]` (Uint8Array). +// - args[4] - The received message callback, which is called everytime a // message is added or changed in the database. It is a function that takes // in the same parameters as [channels.MessageReceivedCallback]. On the // Javascript side, the UUID is returned as an int and the channelID as a @@ -332,15 +343,15 @@ func LoadChannelsManager(_ js.Value, args []js.Value) any { // the UUID. The channel ID is provided so that the recipient can filter if // they want to the processes the update now or not. An "update" bool is // present which tells you if the row is new or if it is an edited old row. -// - args[4] - The deleted message callback, which is called everytime a +// - args[5] - The deleted message callback, which is called everytime a // message is deleted from the database. It is a function that takes in the // same parameters as [indexedDb.DeletedMessageCallback]. On the Javascript // side, the message ID is returned as a Uint8Array. -// - args[5] - The muted user callback, which is called everytime a user is +// - args[6] - The muted user callback, which is called everytime a user is // muted or unmuted. It is a function that takes in the same parameters as // [indexedDb.MutedUserCallback]. On the Javascript side, the channel ID and // user public key are returned as Uint8Array. -// - args[6] - ID of [ChannelDbCipher] object in tracker (int). Create this +// - args[7] - ID of [ChannelDbCipher] object in tracker (int). Create this // object with [NewChannelsDatabaseCipher] and get its id with // [ChannelDbCipher.GetID]. // @@ -352,10 +363,11 @@ func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { cmixID := args[0].Int() wasmJsPath := args[1].String() privateIdentity := utils.CopyBytesToGo(args[2]) - messageReceivedCB := args[3] - deletedMessageCB := args[4] - mutedUserCB := args[5] - cipherID := args[6].Int() + extensionBuilderIDsJSON := utils.CopyBytesToGo(args[3]) + messageReceivedCB := args[4] + deletedMessageCB := args[5] + mutedUserCB := args[6] + cipherID := args[7].Int() cipher, err := bindings.GetChannelDbCipherTrackerFromID(cipherID) if err != nil { @@ -363,7 +375,8 @@ func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { } return newChannelsManagerWithIndexedDb(cmixID, wasmJsPath, privateIdentity, - messageReceivedCB, deletedMessageCB, mutedUserCB, cipher) + extensionBuilderIDsJSON, messageReceivedCB, deletedMessageCB, + mutedUserCB, cipher) } // NewChannelsManagerWithIndexedDbUnsafe creates a new [ChannelsManager] from a @@ -384,7 +397,11 @@ func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { // - args[1] - Path to Javascript file that starts the worker (string). // - args[2] - Bytes of a private identity ([channel.PrivateIdentity]) that is // generated by [GenerateChannelIdentity] (Uint8Array). -// - args[3] - The received message callback, which is called everytime a +// - args[3] - JSON of an array of integers of [channels.ExtensionBuilder] +// IDs. The ID can be retrieved from an object with an extension builder +// (e.g., [ChannelsFileTransfer.GetExtensionBuilderID]). Leave empty if not +// using extension builders. Example: `[2,11,5]` (Uint8Array). +// - args[4] - The received message callback, which is called everytime a // message is added or changed in the database. It is a function that takes // in the same parameters as [indexedDb.MessageReceivedCallback]. On the // Javascript side, the UUID is returned as an int and the channelID as a @@ -392,11 +409,11 @@ func NewChannelsManagerWithIndexedDb(_ js.Value, args []js.Value) any { // the UUID. The channel ID is provided so that the recipient can filter if // they want to the processes the update now or not. An "update" bool is // present which tells you if the row is new or if it is an edited old row. -// - args[4] - The deleted message callback, which is called everytime a +// - args[5] - The deleted message callback, which is called everytime a // message is deleted from the database. It is a function that takes in the // same parameters as [indexedDb.DeletedMessageCallback]. On the Javascript // side, the message ID is returned as a Uint8Array. -// - args[5] - The muted user callback, which is called everytime a user is +// - args[6] - The muted user callback, which is called everytime a user is // muted or unmuted. It is a function that takes in the same parameters as // [indexedDb.MutedUserCallback]. On the Javascript side, the channel ID and // user public key are returned as Uint8Array. @@ -410,17 +427,19 @@ func NewChannelsManagerWithIndexedDbUnsafe(_ js.Value, args []js.Value) any { cmixID := args[0].Int() wasmJsPath := args[1].String() privateIdentity := utils.CopyBytesToGo(args[2]) - messageReceivedCB := args[3] - deletedMessageCB := args[4] - mutedUserCB := args[5] + extensionBuilderIDsJSON := utils.CopyBytesToGo(args[3]) + messageReceivedCB := args[4] + deletedMessageCB := args[5] + mutedUserCB := args[6] return newChannelsManagerWithIndexedDb(cmixID, wasmJsPath, privateIdentity, - messageReceivedCB, deletedMessageCB, mutedUserCB, nil) + extensionBuilderIDsJSON, messageReceivedCB, deletedMessageCB, + mutedUserCB, nil) } func newChannelsManagerWithIndexedDb(cmixID int, wasmJsPath string, - privateIdentity []byte, messageReceivedCB, deletedMessageCB, mutedUserCB js.Value, - cipher *bindings.ChannelDbCipher) any { + privateIdentity, extensionBuilderIDsJSON []byte, messageReceivedCB, + deletedMessageCB, mutedUserCB js.Value, cipher *bindings.ChannelDbCipher) any { messageReceived := func(uuid uint64, channelID *id.ID, update bool) { messageReceivedCB.Invoke(uuid, utils.CopyBytesToJS(channelID.Marshal()), update) @@ -440,7 +459,7 @@ func newChannelsManagerWithIndexedDb(cmixID int, wasmJsPath string, promiseFn := func(resolve, reject func(args ...any) js.Value) { cm, err := bindings.NewChannelsManagerGoEventModel( - cmixID, privateIdentity, model) + cmixID, privateIdentity, extensionBuilderIDsJSON, model) if err != nil { reject(utils.JsTrace(err)) } else { @@ -577,7 +596,7 @@ func loadChannelsManagerWithIndexedDb(cmixID int, wasmJsPath, storageTag string, promiseFn := func(resolve, reject func(args ...any) js.Value) { cm, err := bindings.LoadChannelsManagerGoEventModel( - cmixID, storageTag, model) + cmixID, storageTag, model, nil) if err != nil { reject(utils.JsTrace(err)) } else { @@ -1747,6 +1766,30 @@ func (cm *ChannelsManager) RegisterReceiveHandler(_ js.Value, args []js.Value) a // Event Model Logic // //////////////////////////////////////////////////////////////////////////////// +// GetNoMessageErr returns the error channels.NoMessageErr, which must be +// returned by EventModel methods (such as EventModel.UpdateFromUUID, +// EventModel.UpdateFromMessageID, and EventModel.GetMessage) when the message +// cannot be found. +// +// Returns: +// - channels.NoMessageErr error message (string). +func GetNoMessageErr(js.Value, []js.Value) any { + return bindings.GetNoMessageErr() +} + +// CheckNoMessageErr determines if the error returned by an EventModel function +// indicates that the message or item does not exist. It returns true if the +// error contains channels.NoMessageErr. +// +// Parameters: +// - args[0] - Error to check (Error). +// +// Returns +// - True if the error contains channels.NoMessageErr (boolean). +func CheckNoMessageErr(_ js.Value, args []js.Value) any { + return bindings.CheckNoMessageErr(utils.JsErrorToJson(args[0])) +} + // eventModelBuilder adheres to the [bindings.EventModelBuilder] interface. type eventModelBuilder struct { build func(args ...any) js.Value @@ -1944,13 +1987,22 @@ func (em *eventModel) ReceiveReaction(channelID, messageID, reactionTo []byte, // - uuid - The unique identifier of the message in the database (int). // - messageUpdateInfoJSON - JSON of [bindings.MessageUpdateInfo] // (Uint8Array). -func (em *eventModel) UpdateFromUUID(uuid int64, messageUpdateInfoJSON []byte) { - em.updateFromUUID(uuid, utils.CopyBytesToJS(messageUpdateInfoJSON)) +// +// Returns: +// - Returns an error if the message cannot be updated. It must return the +// error from [GetNoMessageErr] if the message does not exist. +func (em *eventModel) UpdateFromUUID( + uuid int64, messageUpdateInfoJSON []byte) error { + err := em.updateFromUUID(uuid, utils.CopyBytesToJS(messageUpdateInfoJSON)) + return js.Error{Value: err} } // UpdateFromMessageID is called whenever a message with the message ID is // modified. // +// Note for developers: The internal Javascript function must return JSON of +// [UuidAndError], which includes the returned UUID or an error. +// // Parameters: // - messageID - The bytes of the [channel.MessageID] of the received message // (Uint8Array). @@ -1960,16 +2012,31 @@ func (em *eventModel) UpdateFromUUID(uuid int64, messageUpdateInfoJSON []byte) { // Returns: // - A non-negative unique uuid for the modified message by which it can be // referenced later with [EventModel.UpdateFromUUID] int). +// - Returns an error if the message cannot be updated. It must return the +// error from [GetNoMessageErr] if the message does not exist. func (em *eventModel) UpdateFromMessageID( - messageID []byte, messageUpdateInfoJSON []byte) int64 { - return int64(em.updateFromMessageID(utils.CopyBytesToJS(messageID), - utils.CopyBytesToJS(messageUpdateInfoJSON)).Int()) + messageID []byte, messageUpdateInfoJSON []byte) (int64, error) { + uuidAndErrorBytes := utils.CopyBytesToGo(em.updateFromMessageID( + utils.CopyBytesToJS(messageID), + utils.CopyBytesToJS(messageUpdateInfoJSON))) + + var uae UuidAndError + err := json.Unmarshal(uuidAndErrorBytes, &uae) + if err != nil { + return 0, err + } + + if uae.Error != "" { + return 0, errors.New(uae.Error) + } + + return uae.UUID, nil } // GetMessage returns the message with the given [channel.MessageID]. // // Note for developers: The internal Javascript function must return JSON of -// MessageAndError, which includes the returned [channels.ModelMessage] or any +// [MessageAndError], which includes the returned [channels.ModelMessage] or any // error that occurs during lookup. // // Parameters: @@ -2020,6 +2087,21 @@ func (em *eventModel) MuteUser(channelID, pubkey []byte, unmute bool) { utils.CopyBytesToJS(channelID), utils.CopyBytesToJS(pubkey), unmute) } +// UuidAndError contains a UUID returned by an eventModel method or any possible +// error that occurs. Only one field should be present at a time. +// +// Example JSON: +// +// { "uuid": 5, } +// +// Or: +// +// { "error": "An error occurred." } +type UuidAndError struct { + UUID int64 `json:"uuid,omitempty"` + Error string `json:"error,omitempty"` +} + // MessageAndError contains a message returned by eventModel.GetMessage or any // possible error that occurs during lookup. Only one field should be present at // a time; if an error occurs, ModelMessage should be empty. diff --git a/wasm/channelsFileTransfer.go b/wasm/channelsFileTransfer.go new file mode 100644 index 0000000000000000000000000000000000000000..5155e4ad4668bef0f3ad52f52efec748e7dbb0ff --- /dev/null +++ b/wasm/channelsFileTransfer.go @@ -0,0 +1,569 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package wasm + +import ( + "gitlab.com/elixxir/client/v4/bindings" + "gitlab.com/elixxir/xxdk-wasm/utils" + "syscall/js" +) + +// ChannelsFileTransfer wraps the [bindings.ChannelsFileTransfer] object so its +// methods can be wrapped to be Javascript compatible. +type ChannelsFileTransfer struct { + api *bindings.ChannelsFileTransfer +} + +// newChannelsFileTransferJS creates a new Javascript compatible object +// (map[string]any) that matches the [ChannelsFileTransfer] structure. +func newChannelsFileTransferJS(api *bindings.ChannelsFileTransfer) map[string]any { + cft := ChannelsFileTransfer{api} + channelsFileTransferMap := map[string]any{ + "GetExtensionBuilderID": js.FuncOf(cft.GetExtensionBuilderID), + "MaxFileNameLen": js.FuncOf(cft.MaxFileNameLen), + "MaxFileTypeLen": js.FuncOf(cft.MaxFileTypeLen), + "MaxFileSize": js.FuncOf(cft.MaxFileSize), + "MaxPreviewSize": js.FuncOf(cft.MaxPreviewSize), + + // Uploading/Sending + "Upload": js.FuncOf(cft.Upload), + "Send": js.FuncOf(cft.Send), + "RegisterSentProgressCallback": js.FuncOf(cft.RegisterSentProgressCallback), + "RetryUpload": js.FuncOf(cft.RetryUpload), + "CloseSend": js.FuncOf(cft.CloseSend), + + // Downloading + "Download": js.FuncOf(cft.Download), + "RegisterReceivedProgressCallback": js.FuncOf(cft.RegisterReceivedProgressCallback), + } + + return channelsFileTransferMap +} + +// InitChannelsFileTransfer creates a file transfer manager for channels. +// +// Parameters: +// - args[0] - ID of [E2e] object in tracker (int). +// - args[1] - JSON of [channelsFileTransfer.Params] (Uint8Array). +// +// Returns: +// - New [ChannelsFileTransfer] object. +// +// Returns a promise: +// - Resolves to a Javascript representation of the [ChannelsFileTransfer] +// object. +// - Rejected with an error if creating the file transfer object fails. +func InitChannelsFileTransfer(_ js.Value, args []js.Value) any { + e2eID := args[0].Int() + paramsJson := utils.CopyBytesToGo(args[1]) + + promiseFn := func(resolve, reject func(args ...any) js.Value) { + cft, err := bindings.InitChannelsFileTransfer(e2eID, paramsJson) + if err != nil { + reject(utils.JsTrace(err)) + } else { + resolve(newChannelsFileTransferJS(cft)) + } + } + + return utils.CreatePromise(promiseFn) +} + +// GetExtensionBuilderID returns the ID of the extension builder in the tracker. +// Pass this ID into the channel manager creator to use file transfer manager in +// conjunction with channels. +// +// Returns: +// - Extension builder ID (int). +func (cft *ChannelsFileTransfer) GetExtensionBuilderID(js.Value, []js.Value) any { + return cft.api.GetExtensionBuilderID() +} + +// MaxFileNameLen returns the max number of bytes allowed for a file name. +// +// Returns: +// - Max number of bytes (int). +func (cft *ChannelsFileTransfer) MaxFileNameLen(js.Value, []js.Value) any { + return cft.api.MaxFileNameLen() +} + +// MaxFileTypeLen returns the max number of bytes allowed for a file type. +// +// Returns: +// - Max number of bytes (int). +func (cft *ChannelsFileTransfer) MaxFileTypeLen(js.Value, []js.Value) any { + return cft.api.MaxFileNameLen() +} + +// MaxFileSize returns the max number of bytes allowed for a file. +// +// Returns: +// - Max number of bytes (int). +func (cft *ChannelsFileTransfer) MaxFileSize(js.Value, []js.Value) any { + return cft.api.MaxFileSize() +} + +// MaxPreviewSize returns the max number of bytes allowed for a file preview. +// +// Returns: +// - Max number of bytes (int). +func (cft *ChannelsFileTransfer) MaxPreviewSize(js.Value, []js.Value) any { + return cft.api.MaxFileSize() +} + +//////////////////////////////////////////////////////////////////////////////// +// Uploading/Sending // +//////////////////////////////////////////////////////////////////////////////// + +// Upload starts uploading the file to a new ID that can be sent to the +// specified channel when complete. To get progress information about the +// upload, a [bindings.FtSentProgressCallback] must be registered. All errors +// returned on the callback are fatal and the user must take action to either +// [ChannelsFileTransfer.RetryUpload] or [ChannelsFileTransfer.CloseSend]. +// +// The file is added to the event model at the returned file ID with the status +// [channelsFileTransfer.Uploading]. Once the upload is complete, the file link +// is added to the event model with the status [channelsFileTransfer.Complete]. +// +// The [bindings.FtSentProgressCallback] only indicates the progress of the file +// upload, not the status of the file in the event model. You must rely on +// updates from the event model to know when it can be retrieved. +// +// Parameters: +// - args[0] - File contents. Max size defined by +// [ChannelsFileTransfer.MaxFileSize] (Uint8Array). +// - args[1] - The number of sending retries allowed on send failure (e.g. a +// retry of 2.0 with 6 parts means 12 total possible sends) (float). +// - args[2] - The progress callback, which is a callback that reports the +// progress of the file upload. The callback is called once on +// initialization, on every progress update (or less if restricted by the +// period), or on fatal error. It must be a Javascript object that +// implements the [bindings.FtSentProgressCallback] interface. +// - args[3] - Progress callback period. A progress callback will be limited +// from triggering only once per period, in milliseconds (int). +// +// Returns a promise: +// - Resolves to the marshalled bytes of [fileTransfer.ID] that uniquely +// identifies the file (Uint8Array). +// - Rejected with an error if initiating the upload fails. +func (cft *ChannelsFileTransfer) Upload(_ js.Value, args []js.Value) any { + var ( + fileData = utils.CopyBytesToGo(args[0]) + retry = float32(args[1].Float()) + progressCB = &ftSentCallback{utils.WrapCB(args[2], "Callback")} + period = args[3].Int() + ) + + promiseFn := func(resolve, reject func(args ...any) js.Value) { + fileID, err := cft.api.Upload(fileData, retry, progressCB, period) + if err != nil { + reject(utils.JsTrace(err)) + } else { + resolve(utils.CopyBytesToJS(fileID)) + } + } + + return utils.CreatePromise(promiseFn) +} + +// Send sends the specified file info to the channel. Once a file is uploaded +// via [ChannelsFileTransfer.Upload], its file info (found in the event model) +// can be sent to any channel. +// +// Parameters: +// - args[0] - Marshalled bytes of the channel's [id.ID] to send the file to +// (Uint8Array). +// - args[1] - JSON of [channelsFileTransfer.FileLink] stored in the event +// model (Uint8Array). +// - args[2] - Human-readable file name. Max length defined by +// [ChannelsFileTransfer.MaxFileNameLen] (string). +// - args[3] - Shorthand that identifies the type of file. Max length defined +// by [ChannelsFileTransfer.MaxFileTypeLen] (string). +// - args[4] - A preview of the file data (e.g. a thumbnail). Max size defined +// by [ChannelsFileTransfer.MaxPreviewSize] (Uint8Array). +// - args[5] - The duration, in milliseconds, that the file is available in +// the channel (int). For the maximum amount of time, use [ValidForever]. +// - args[6] - JSON of [xxdk.CMIXParams] (Uint8Array). If left empty, +// [GetDefaultCMixParams] will be used internally. +// +// Returns a promise: +// - Resolves to the JSON of [bindings.ChannelSendReport] (Uint8Array). +// - Rejected with an error if sending fails. +func (cft *ChannelsFileTransfer) Send(_ js.Value, args []js.Value) any { + var ( + channelIdBytes = utils.CopyBytesToGo(args[0]) + fileLinkJSON = utils.CopyBytesToGo(args[1]) + fileName = args[2].String() + fileType = args[3].String() + preview = utils.CopyBytesToGo(args[4]) + validUntilMS = args[5].Int() + cmixParamsJSON = utils.CopyBytesToGo(args[6]) + ) + + promiseFn := func(resolve, reject func(args ...any) js.Value) { + fileID, err := cft.api.Send(channelIdBytes, fileLinkJSON, fileName, + fileType, preview, validUntilMS, cmixParamsJSON) + if err != nil { + reject(utils.JsTrace(err)) + } else { + resolve(utils.CopyBytesToJS(fileID)) + } + } + + return utils.CreatePromise(promiseFn) +} + +// RegisterSentProgressCallback allows for the registration of a callback to +// track the progress of an individual file upload. A +// [bindings.FtSentProgressCallback] is auto-registered on +// [ChannelsFileTransfer.Send]; this function should be called when resuming +// clients or registering extra callbacks. +// +// The callback will be called immediately when added to report the current +// progress of the transfer. It will then call every time a file part arrives, +// the transfer completes, or a fatal error occurs. It is called at most once +// every period regardless of the number of progress updates. +// +// In the event that the client is closed and resumed, this function must be +// used to re-register any callbacks previously registered with this function or +// [ChannelsFileTransfer.Send]. +// +// The [bindings.FtSentProgressCallback] only indicates the progress of the file +// upload, not the status of the file in the event model. You must rely on +// updates from the event model to know when it can be retrieved. +// +// Parameters: +// - args[0] - Marshalled bytes of the file's [fileTransfer.ID] (Uint8Array). +// - args[1] - The progress callback, which is a callback that reports the +// progress of the file upload. The callback is called once on +// initialization, on every progress update (or less if restricted by the +// period), or on fatal error. It must be a Javascript object that +// implements the [bindings.FtSentProgressCallback] interface. +// - args[2] - Progress callback period. A progress callback will be limited +// from triggering only once per period, in milliseconds (int). +// +// Returns a promise: +// - Resolves on success (void). +// - Rejected with an error if registering the callback fails. +func (cft *ChannelsFileTransfer) RegisterSentProgressCallback( + _ js.Value, args []js.Value) any { + var ( + fileIDBytes = utils.CopyBytesToGo(args[0]) + progressCB = &ftSentCallback{utils.WrapCB(args[1], "Callback")} + periodMS = args[2].Int() + ) + + promiseFn := func(resolve, reject func(args ...any) js.Value) { + err := cft.api.RegisterSentProgressCallback( + fileIDBytes, progressCB, periodMS) + if err != nil { + reject(utils.JsTrace(err)) + } else { + resolve() + } + } + + return utils.CreatePromise(promiseFn) +} + +// RetryUpload retries uploading a failed file upload. Returns an error if the +// transfer has not failed. +// +// This function should be called once a transfer errors out (as reported by the +// progress callback). +// +// A new progress callback must be registered on retry. Any previously +// registered callbacks are defunct when the upload fails. +// +// Parameters: +// - args[0] - Marshalled bytes of the file's [fileTransfer.ID] (Uint8Array). +// - args[1] - The progress callback, which is a callback that reports the +// progress of the file upload. The callback is called once on +// initialization, on every progress update (or less if restricted by the +// period), or on fatal error. It must be a Javascript object that +// implements the [bindings.FtSentProgressCallback] interface. +// - args[2] - Progress callback period. A progress callback will be limited +// from triggering only once per period, in milliseconds (int). +// +// Returns a promise: +// - Resolves on success (void). +// - Rejected with an error if registering retrying the upload fails. +func (cft *ChannelsFileTransfer) RetryUpload(_ js.Value, args []js.Value) any { + var ( + fileIDBytes = utils.CopyBytesToGo(args[0]) + progressCB = &ftSentCallback{utils.WrapCB(args[1], "Callback")} + periodMS = args[2].Int() + ) + + promiseFn := func(resolve, reject func(args ...any) js.Value) { + err := cft.api.RetryUpload(fileIDBytes, progressCB, periodMS) + if err != nil { + reject(utils.JsTrace(err)) + } else { + resolve() + } + } + + return utils.CreatePromise(promiseFn) +} + +// CloseSend deletes a file from the internal storage once a transfer has +// completed or reached the retry limit. If neither of those condition are met, +// an error is returned. +// +// This function should be called once a transfer completes or errors out (as +// reported by the progress callback). +// +// Parameters: +// - args[0] - Marshalled bytes of the file's [fileTransfer.ID] (Uint8Array). +// +// Returns a promise: +// - Resolves on success (void). +// - Rejected with an error if the file has not failed or completed or if +// closing failed. +func (cft *ChannelsFileTransfer) CloseSend(_ js.Value, args []js.Value) any { + fileIDBytes := utils.CopyBytesToGo(args[0]) + + promiseFn := func(resolve, reject func(args ...any) js.Value) { + err := cft.api.CloseSend(fileIDBytes) + if err != nil { + reject(utils.JsTrace(err)) + } else { + resolve() + } + } + + return utils.CreatePromise(promiseFn) +} + +//////////////////////////////////////////////////////////////////////////////// +// Download // +//////////////////////////////////////////////////////////////////////////////// + +// Download begins the download of the file described in the marshalled +// [channelsFileTransfer.FileInfo]. The progress of the download is reported on +// the [bindings.FtReceivedProgressCallback]. +// +// Once the download completes, the file will be stored in the event model with +// the given file ID and with the status [channels.ReceptionProcessingComplete]. +// +// The [bindings.FtReceivedProgressCallback] only indicates the progress of the +// file download, not the status of the file in the event model. You must rely +// on updates from the event model to know when it can be retrieved. +// +// Parameters: +// - args[0] - The JSON of [channelsFileTransfer.FileInfo] received on a +// channel (Uint8Array). +// - args[1] - The progress callback, which is a callback that reports the +// progress of the file download. The callback is called once on +// initialization, on every progress update (or less if restricted by the +// period), or on fatal error. It must be a Javascript object that +// implements the [bindings.FtReceivedProgressCallback] interface. +// - args[2] - Progress callback period. A progress callback will be limited +// from triggering only once per period, in milliseconds (int). +// +// Returns: +// - Marshalled bytes of [fileTransfer.ID] that uniquely identifies the file. +// +// Returns a promise: +// - Resolves to the marshalled bytes of [fileTransfer.ID] that uniquely +// identifies the file. (Uint8Array). +// - Rejected with an error if downloading fails. +func (cft *ChannelsFileTransfer) Download(_ js.Value, args []js.Value) any { + var ( + fileInfoJSON = utils.CopyBytesToGo(args[0]) + progressCB = &ftReceivedCallback{utils.WrapCB(args[1], "Callback")} + periodMS = args[2].Int() + ) + + promiseFn := func(resolve, reject func(args ...any) js.Value) { + fileID, err := cft.api.Download(fileInfoJSON, progressCB, periodMS) + if err != nil { + reject(utils.JsTrace(err)) + } else { + resolve(utils.CopyBytesToJS(fileID)) + } + } + + return utils.CreatePromise(promiseFn) +} + +// RegisterReceivedProgressCallback allows for the registration of a callback to +// track the progress of an individual file download. +// +// The callback will be called immediately when added to report the current +// progress of the transfer. It will then call every time a file part is +// received, the transfer completes, or a fatal error occurs. It is called at +// most once every period regardless of the number of progress updates. +// +// In the event that the client is closed and resumed, this function must be +// used to re-register any callbacks previously registered. +// +// Once the download completes, the file will be stored in the event model with +// the given file ID and with the status [channelsFileTransfer.Complete]. +// +// The [bindings.FtReceivedProgressCallback] only indicates the progress of the +// file download, not the status of the file in the event model. You must rely +// on updates from the event model to know when it can be retrieved. +// +// Parameters: +// - args[0] - Marshalled bytes of the file's [fileTransfer.ID] (Uint8Array). +// - args[1] - The progress callback, which is a callback that reports the +// progress of the file download. The callback is called once on +// initialization, on every progress update (or less if restricted by the +// period), or on fatal error. It must be a Javascript object that +// implements the [bindings.FtReceivedProgressCallback] interface. +// - args[2] - Progress callback period. A progress callback will be limited +// from triggering only once per period, in milliseconds (int). +// +// Returns a promise: +// - Resolves on success (void). +// - Rejected with an error if registering the callback fails. +func (cft *ChannelsFileTransfer) RegisterReceivedProgressCallback( + _ js.Value, args []js.Value) any { + var ( + fileIDBytes = utils.CopyBytesToGo(args[0]) + progressCB = &ftReceivedCallback{utils.WrapCB(args[1], "Callback")} + periodMS = args[2].Int() + ) + + promiseFn := func(resolve, reject func(args ...any) js.Value) { + err := cft.api.RegisterReceivedProgressCallback( + fileIDBytes, progressCB, periodMS) + if err != nil { + reject(utils.JsTrace(err)) + } else { + resolve() + } + } + + return utils.CreatePromise(promiseFn) +} + +//////////////////////////////////////////////////////////////////////////////// +// Callbacks // +//////////////////////////////////////////////////////////////////////////////// + +// ftSentCallback wraps Javascript callbacks to adhere to the +// [bindings.FtSentProgressCallback] interface. +type ftSentCallback struct { + callback func(args ...any) js.Value +} + +// Callback is called when the status of the sent file changes. +// +// Parameters: +// - payload - Returns the contents of the message. JSON of +// [bindings.Progress] (Uint8Array). +// - t - Returns a tracker that allows the lookup of the status of any file +// part. It is a Javascript object that matches the functions on +// [FilePartTracker]. +// - err - Returns an error on failure (Error). + +// Callback is called when the progress on a sent file changes or an error +// occurs in the transfer. +// +// The [ChFilePartTracker] can be used to look up the status of individual file +// parts. Note, when completed == true, the [ChFilePartTracker] may be nil. +// +// Any error returned is fatal and the file must either be retried with +// [ChannelsFileTransfer.RetryUpload] or canceled with +// [ChannelsFileTransfer.CloseSend]. +// +// This callback only indicates the status of the file transfer, not the status +// of the file in the event model. Do NOT use this callback as an indicator of +// when the file is available in the event model. +// +// Parameters: +// - payload - JSON of [bindings.FtSentProgress], which describes the progress +// of the current sent transfer. +// - fpt - File part tracker that allows the lookup of the status of +// individual file parts. +// - err - Fatal errors during sending. +func (fsc *ftSentCallback) Callback( + payload []byte, t *bindings.ChFilePartTracker, err error) { + fsc.callback(utils.CopyBytesToJS(payload), newChFilePartTrackerJS(t), + utils.JsTrace(err)) +} + +// ftReceivedCallback wraps Javascript callbacks to adhere to the +// [bindings.FtReceivedProgressCallback] interface. +type ftReceivedCallback struct { + callback func(args ...any) js.Value +} + +// Callback is called when +// the progress on a received file changes or an error occurs in the transfer. +// +// The [ChFilePartTracker] can be used to look up the status of individual file +// parts. Note, when completed == true, the [ChFilePartTracker] may be nil. +// +// This callback only indicates the status of the file transfer, not the status +// of the file in the event model. Do NOT use this callback as an indicator of +// when the file is available in the event model. +// +// Parameters: +// - payload - JSON of [bindings.FtReceivedProgress], which describes the +// progress of the current received transfer. +// - fpt - File part tracker that allows the lookup of the status of +// individual file parts. +// - err - Fatal errors during receiving. +func (frc *ftReceivedCallback) Callback( + payload []byte, t *bindings.ChFilePartTracker, err error) { + frc.callback(utils.CopyBytesToJS(payload), newChFilePartTrackerJS(t), + utils.JsTrace(err)) +} + +//////////////////////////////////////////////////////////////////////////////// +// File Part Tracker // +//////////////////////////////////////////////////////////////////////////////// + +// ChFilePartTracker wraps the [bindings.ChFilePartTracker] object so its +// methods can be wrapped to be Javascript compatible. +type ChFilePartTracker struct { + api *bindings.ChFilePartTracker +} + +// newChFilePartTrackerJS creates a new Javascript compatible object +// (map[string]any) that matches the [FilePartTracker] structure. +func newChFilePartTrackerJS(api *bindings.ChFilePartTracker) map[string]any { + fpt := ChFilePartTracker{api} + ftMap := map[string]any{ + "GetPartStatus": js.FuncOf(fpt.GetPartStatus), + "GetNumParts": js.FuncOf(fpt.GetNumParts), + } + + return ftMap +} + +// GetPartStatus returns the status of the file part with the given part number. +// +// The possible values for the status are: +// - 0 < Part does not exist +// - 0 = unsent +// - 1 = arrived (sender has sent a part, and it has arrived) +// - 2 = received (receiver has received a part) +// +// Parameters: +// - args[0] - Index of part (int). +// +// Returns: +// - Part status (int). +func (fpt *ChFilePartTracker) GetPartStatus(_ js.Value, args []js.Value) any { + return fpt.api.GetPartStatus(args[0].Int()) +} + +// GetNumParts returns the total number of file parts in the transfer. +// +// Returns: +// - Number of parts (int). +func (fpt *ChFilePartTracker) GetNumParts(js.Value, []js.Value) any { + return fpt.api.GetNumParts() +} diff --git a/wasm/channelsFileTransfer_test.go b/wasm/channelsFileTransfer_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d697de63b3bb23049b7e933355a09eeb749955c1 --- /dev/null +++ b/wasm/channelsFileTransfer_test.go @@ -0,0 +1,98 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package wasm + +import ( + "gitlab.com/elixxir/client/v4/bindings" + "reflect" + "testing" +) + +// Tests that the map representing ChannelsFileTransfer returned by +// newChannelsFileTransferJS contains all of the methods on ChannelsFileTransfer. +func Test_newChannelsFileTransferJS(t *testing.T) { + cftType := reflect.TypeOf(&ChannelsFileTransfer{}) + + ft := newChannelsFileTransferJS(&bindings.ChannelsFileTransfer{}) + if len(ft) != cftType.NumMethod() { + t.Errorf("ChannelsFileTransfer JS object does not have all methods."+ + "\nexpected: %d\nreceived: %d", cftType.NumMethod(), len(ft)) + } + + for i := 0; i < cftType.NumMethod(); i++ { + method := cftType.Method(i) + + if _, exists := ft[method.Name]; !exists { + t.Errorf("Method %s does not exist.", method.Name) + } + } +} + +// Tests that ChannelsFileTransfer has all the methods that +// [bindings.ChannelsFileTransfer] has. +func Test_ChannelsFileTransferMethods(t *testing.T) { + cftType := reflect.TypeOf(&ChannelsFileTransfer{}) + binCftType := reflect.TypeOf(&bindings.ChannelsFileTransfer{}) + + if binCftType.NumMethod() != cftType.NumMethod() { + t.Errorf("WASM ChannelsFileTransfer object does not have all methods "+ + "from bindings.\nexpected: %d\nreceived: %d", + binCftType.NumMethod(), cftType.NumMethod()) + } + + for i := 0; i < binCftType.NumMethod(); i++ { + method := binCftType.Method(i) + + if _, exists := cftType.MethodByName(method.Name); !exists { + t.Errorf("Method %s does not exist.", method.Name) + } + } +} + +// Tests that the map representing ChFilePartTracker returned by +// newChFilePartTrackerJS contains all of the methods on ChFilePartTracker. +func Test_newChFilePartTrackerJS(t *testing.T) { + fptType := reflect.TypeOf(&FilePartTracker{}) + + fpt := newChFilePartTrackerJS(&bindings.ChFilePartTracker{}) + if len(fpt) != fptType.NumMethod() { + t.Errorf("ChFilePartTracker JS object does not have all methods."+ + "\nexpected: %d\nreceived: %d", fptType.NumMethod(), len(fpt)) + } + + for i := 0; i < fptType.NumMethod(); i++ { + method := fptType.Method(i) + + if _, exists := fpt[method.Name]; !exists { + t.Errorf("Method %s does not exist.", method.Name) + } + } +} + +// Tests that ChFilePartTracker has all the methods that +// [bindings.ChFilePartTracker] has. +func Test_ChFilePartTrackerMethods(t *testing.T) { + fptType := reflect.TypeOf(&ChFilePartTracker{}) + binFptType := reflect.TypeOf(&bindings.ChFilePartTracker{}) + + if binFptType.NumMethod() != fptType.NumMethod() { + t.Errorf("WASM ChFilePartTracker object does not have all methods from "+ + "bindings.\nexpected: %d\nreceived: %d", + binFptType.NumMethod(), fptType.NumMethod()) + } + + for i := 0; i < binFptType.NumMethod(); i++ { + method := binFptType.Method(i) + + if _, exists := fptType.MethodByName(method.Name); !exists { + t.Errorf("Method %s does not exist.", method.Name) + } + } +} diff --git a/wasm/docs.go b/wasm/docs.go index b2d6f4894014b15873b2649118e2af09a8cd7091..5b8e7a6dced810bb861c14c45bc41e55fc2385ae 100644 --- a/wasm/docs.go +++ b/wasm/docs.go @@ -15,6 +15,7 @@ import ( "gitlab.com/elixxir/client/v4/auth" "gitlab.com/elixxir/client/v4/catalog" "gitlab.com/elixxir/client/v4/channels" + "gitlab.com/elixxir/client/v4/channelsFileTransfer" "gitlab.com/elixxir/client/v4/cmix" "gitlab.com/elixxir/client/v4/cmix/message" "gitlab.com/elixxir/client/v4/connect" @@ -69,4 +70,6 @@ var ( _ = broadcast.Channel{} _ = netTime.Now _ = ed25519.PublicKey{} + _ = channelsFileTransfer.Params{} + _ = fileTransfer.ID{} )