package filesharing import ( "crypto/rand" "cwtch.im/cwtch/event" "cwtch.im/cwtch/settings" "encoding/hex" "encoding/json" "errors" "fmt" "io" "math" "os" path "path/filepath" "regexp" "runtime" "strconv" "strings" "time" "cwtch.im/cwtch/model" "cwtch.im/cwtch/model/attr" "cwtch.im/cwtch/model/constants" "cwtch.im/cwtch/peer" "cwtch.im/cwtch/protocol/files" "git.openprivacy.ca/openprivacy/log" ) // Functionality groups some common UI triggered functions for contacts... type Functionality struct { } func (f *Functionality) NotifySettingsUpdate(settings settings.GlobalSettings) { } func (f *Functionality) EventsToRegister() []event.Type { return []event.Type{event.ProtocolEngineCreated, event.ManifestReceived, event.FileDownloaded} } func (f *Functionality) ExperimentsToRegister() []string { return []string{constants.FileSharingExperiment} } // OnEvent handles File Sharing Hooks like Manifest Received and FileDownloaded func (f *Functionality) OnEvent(ev event.Event, profile peer.CwtchPeer) { if profile.IsFeatureEnabled(constants.FileSharingExperiment) { switch ev.EventType { case event.ProtocolEngineCreated: f.ReShareFiles(profile) case event.ManifestReceived: log.Debugf("Manifest Received Event!: %v", ev) handle := ev.Data[event.Handle] fileKey := ev.Data[event.FileKey] serializedManifest := ev.Data[event.SerializedManifest] manifestFilePath, exists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%v.manifest", fileKey)) if exists { downloadFilePath, exists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%v.path", fileKey)) if exists { log.Debugf("downloading manifest to %v, file to %v", manifestFilePath, downloadFilePath) var manifest files.Manifest err := json.Unmarshal([]byte(serializedManifest), &manifest) if err == nil { // We only need to check the file size here, as manifest is sent to engine and the file created // will be bound to the size advertised in manifest. fileSizeLimitValue, fileSizeLimitExists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%v.limit", fileKey)) if fileSizeLimitExists { fileSizeLimit, err := strconv.ParseUint(fileSizeLimitValue, 10, 64) if err == nil { if manifest.FileSizeInBytes >= fileSizeLimit { log.Debugf("could not download file, size %v greater than limit %v", manifest.FileSizeInBytes, fileSizeLimitValue) } else { manifest.Title = manifest.FileName manifest.FileName = downloadFilePath log.Debugf("saving manifest") err = manifest.Save(manifestFilePath) if err != nil { log.Errorf("could not save manifest: %v", err) } else { tempFile := "" if runtime.GOOS == "android" { tempFile = manifestFilePath[0 : len(manifestFilePath)-len(".manifest")] log.Debugf("derived android temp path: %v", tempFile) } profile.PublishEvent(event.NewEvent(event.ManifestSaved, map[event.Field]string{ event.FileKey: fileKey, event.Handle: handle, event.SerializedManifest: string(manifest.Serialize()), event.TempFile: tempFile, event.NameSuggestion: manifest.Title, })) } } } else { log.Errorf("error saving manifest: file size limit is incorrect: %v", err) } } else { log.Errorf("error saving manifest: could not find file size limit info") } } else { log.Errorf("error saving manifest: %v", err) } } else { log.Errorf("found manifest path but not download path for %v", fileKey) } } else { log.Errorf("no download path found for manifest: %v", fileKey) } case event.FileDownloaded: fileKey := ev.Data[event.FileKey] profile.SetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.complete", fileKey), "true") } } else { log.Errorf("profile called filesharing experiment OnContactReceiveValue even though file sharing was not enabled. This is likely a programming error.") } } func (f *Functionality) OnContactRequestValue(profile peer.CwtchPeer, conversation model.Conversation, eventID string, path attr.ScopedZonedPath) { // nop } func (f *Functionality) OnContactReceiveValue(profile peer.CwtchPeer, conversation model.Conversation, path attr.ScopedZonedPath, value string, exists bool) { // Profile should not call us if FileSharing is disabled if profile.IsFeatureEnabled(constants.FileSharingExperiment) { scope, zone, zpath := path.GetScopeZonePath() log.Debugf("file sharing contact receive value") if exists && scope.IsConversation() && zone == attr.FilesharingZone && strings.HasSuffix(zpath, ".manifest.size") { fileKey := strings.Replace(zpath, ".manifest.size", "", 1) size, err := strconv.Atoi(value) // if size is valid and below the maximum size for a manifest // this is to prevent malicious sharers from using large amounts of memory when distributing // a manifest as we reconstruct this in-memory if err == nil && size < files.MaxManifestSize { profile.PublishEvent(event.NewEvent(event.ManifestSizeReceived, map[event.Field]string{event.FileKey: fileKey, event.ManifestSize: value, event.Handle: conversation.Handle})) } else { profile.PublishEvent(event.NewEvent(event.ManifestError, map[event.Field]string{event.FileKey: fileKey, event.Handle: conversation.Handle})) } } } else { log.Errorf("profile called filesharing experiment OnContactReceiveValue even though file sharing was not enabled. This is likely a programming error.") } } // FunctionalityGate returns filesharing functionality - gates now happen on function calls. func FunctionalityGate() *Functionality { return new(Functionality) } // PreviewFunctionalityGate returns filesharing if image previews are enabled func PreviewFunctionalityGate(experimentMap map[string]bool) (*Functionality, error) { if experimentMap[constants.FileSharingExperiment] && experimentMap[constants.ImagePreviewsExperiment] { return new(Functionality), nil } return nil, errors.New("image previews are not enabled") } // OverlayMessage presents the canonical format of the File Sharing functionality Overlay Message // This is the format that the UI will parse to display the message type OverlayMessage struct { Name string `json:"f"` Hash string `json:"h"` Nonce string `json:"n"` Size uint64 `json:"s"` } // FileKey is the unique reference to a file offer func (om *OverlayMessage) FileKey() string { return fmt.Sprintf("%s.%s", om.Hash, om.Nonce) } // ShouldAutoDL checks file size and file name. *DOES NOT* check user settings or contact state func (om *OverlayMessage) ShouldAutoDL() bool { if om.Size > constants.ImagePreviewMaxSizeInBytes { return false } lname := strings.ToLower(om.Name) for _, s := range constants.AutoDLFileExts { if strings.HasSuffix(lname, s) { return true } } return false } func (f *Functionality) VerifyOrResumeDownloadDefaultLimit(profile peer.CwtchPeer, conversation int, fileKey string) error { return f.VerifyOrResumeDownload(profile, conversation, fileKey, files.MaxManifestSize*files.DefaultChunkSize) } func (f *Functionality) VerifyOrResumeDownload(profile peer.CwtchPeer, conversation int, fileKey string, size uint64) error { if manifestFilePath, exists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.manifest", fileKey)); exists { if downloadfilepath, exists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.path", fileKey)); exists { manifest, err := files.LoadManifest(manifestFilePath) if err == nil { // Assert the filename...this is technically not necessary, but is here for completeness manifest.FileName = downloadfilepath if manifest.VerifyFile() == nil { // Send a FileDownloaded Event. Usually when VerifyOrResumeDownload is triggered it's because some UI is awaiting the results of a // Download. profile.PublishEvent(event.NewEvent(event.FileDownloaded, map[event.Field]string{event.FileKey: fileKey, event.FilePath: downloadfilepath, event.TempFile: downloadfilepath})) // File is verified and there is nothing else to do... return nil } else { // Kick off another Download... return f.DownloadFile(profile, conversation, downloadfilepath, manifestFilePath, fileKey, size) } } } } return errors.New("file download metadata does not exist, or is corrupted") } func (f *Functionality) CheckDownloadStatus(profile peer.CwtchPeer, fileKey string) error { path, _ := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.path", fileKey)) if value, exists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.complete", fileKey)); exists && value == event.True { profile.PublishEvent(event.NewEvent(event.FileDownloaded, map[event.Field]string{ event.ProfileOnion: profile.GetOnion(), event.FileKey: fileKey, event.FilePath: path, event.TempFile: "", })) } else { log.Debugf("CheckDownloadStatus found .path but not .complete") profile.PublishEvent(event.NewEvent(event.FileDownloadProgressUpdate, map[event.Field]string{ event.ProfileOnion: profile.GetOnion(), event.FileKey: fileKey, event.Progress: "-1", event.FileSizeInChunks: "-1", event.FilePath: path, })) } return nil // cannot fail } func (f *Functionality) EnhancedShareFile(profile peer.CwtchPeer, conversationID int, sharefilepath string) string { fileKey, overlay, err := f.ShareFile(sharefilepath, profile) if err != nil { log.Errorf("error sharing file: %v", err) } else if conversationID == -1 { // FIXME: At some point we might want to allow arbitrary public files, but for now this API will assume // there is only one, and it is the custom profile image... profile.SetScopedZonedAttribute(attr.PublicScope, attr.ProfileZone, constants.CustomProfileImageKey, fileKey) } else { // Set a new attribute so we can associate this download with this conversation... profile.SetConversationAttribute(conversationID, attr.ConversationScope.ConstructScopedZonedPath(attr.FilesharingZone.ConstructZonedPath(fileKey)), "") id, err := profile.SendMessage(conversationID, overlay) if err == nil { return profile.EnhancedGetMessageById(conversationID, id) } } return "" } // DownloadFileDefaultLimit given a profile, a conversation handle and a file sharing key, start off a download process // to downloadFilePath with a default filesize limit func (f *Functionality) DownloadFileDefaultLimit(profile peer.CwtchPeer, conversationID int, downloadFilePath string, manifestFilePath string, key string) error { return f.DownloadFile(profile, conversationID, downloadFilePath, manifestFilePath, key, files.MaxManifestSize*files.DefaultChunkSize) } // DownloadFile given a profile, a conversation handle and a file sharing key, start off a download process // to downloadFilePath func (f *Functionality) DownloadFile(profile peer.CwtchPeer, conversationID int, downloadFilePath string, manifestFilePath string, key string, limit uint64) error { // assert that we are allowed to download the file if !profile.IsFeatureEnabled(constants.FileSharingExperiment) { return errors.New("filesharing functionality is not enabled") } // Don't download files if the download or manifest path is not set if downloadFilePath == "" || manifestFilePath == "" { return errors.New("download path or manifest path is empty") } // Don't download files if the download file directory does not exist // Unless we are on Android where the kernel wishes to keep us ignorant of the // actual path and/or existence of the file. We handle this case further down // the line when the manifest is received and protocol engine and the Android layer // negotiate a temporary local file -> final file copy. We don't want to worry // about that here... if runtime.GOOS != "android" { if _, err := os.Stat(path.Dir(downloadFilePath)); os.IsNotExist(err) { return errors.New("download directory does not exist") } // Don't download files if the manifest file directory does not exist if _, err := os.Stat(path.Dir(manifestFilePath)); os.IsNotExist(err) { return errors.New("manifest directory does not exist") } } // Store local.filesharing.filekey.manifest as the location of the manifest profile.SetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.manifest", key), manifestFilePath) // Store local.filesharing.filekey.path as the location of the download profile.SetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.path", key), downloadFilePath) // Store local.filesharing.filekey.limit as the max file size of the download profile.SetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.limit", key), strconv.FormatUint(limit, 10)) // Get the value of conversation.filesharing.filekey.manifest.size from `handle` profile.SendScopedZonedGetValToContact(conversationID, attr.ConversationScope, attr.FilesharingZone, fmt.Sprintf("%s.manifest.size", key)) return nil } // startFileShare is a private method used to finalize a file share and publish it to the protocol engine for processing. // if force is set to true, this function will ignore timestamp checks... func (f *Functionality) startFileShare(profile peer.CwtchPeer, filekey string, manifest string, force bool) error { tsStr, exists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.ts", filekey)) if exists && !force { ts, err := strconv.ParseInt(tsStr, 10, 64) if err != nil || ts < time.Now().Unix()-2592000 { log.Errorf("ignoring request to download a file offered more than 30 days ago") return err } } // set the filekey status to active profile.SetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.active", filekey), constants.True) // reset the timestamp... profile.SetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.ts", filekey), strconv.FormatInt(time.Now().Unix(), 10)) // share the manifest profile.PublishEvent(event.NewEvent(event.ShareManifest, map[event.Field]string{event.FileKey: filekey, event.SerializedManifest: manifest})) return nil } // RestartFileShare takes in an existing filekey and, assuming the manifest exists, restarts sharing of the manifest // by default this function always forces a file share, even if the file has timed out. func (f *Functionality) RestartFileShare(profile peer.CwtchPeer, filekey string) error { return f.restartFileShareAdvanced(profile, filekey, true) } // RestartFileShareAdvanced takes in an existing filekey and, assuming the manifest exists, restarts sharing of the manifest in addition // to a set of parameters func (f *Functionality) restartFileShareAdvanced(profile peer.CwtchPeer, filekey string, force bool) error { // assert that we are allowed to restart filesharing if !profile.IsFeatureEnabled(constants.FileSharingExperiment) { return errors.New("filesharing functionality is not enabled") } // check that a manifest exists manifest, manifestExists := profile.GetScopedZonedAttribute(attr.ConversationScope, attr.FilesharingZone, fmt.Sprintf("%s.manifest", filekey)) if manifestExists { // everything is in order, so reshare this file with the engine log.Debugf("restarting file share: %v", filekey) return f.startFileShare(profile, filekey, manifest, force) } return fmt.Errorf("manifest does not exist for filekey: %v", filekey) } // ReShareFiles given a profile we iterate through all existing fileshares and re-share them // if the time limit has not expired func (f *Functionality) ReShareFiles(profile peer.CwtchPeer) error { // assert that we are allowed to restart filesharing if !profile.IsFeatureEnabled(constants.FileSharingExperiment) { return errors.New("filesharing functionality is not enabled") } keys, err := profile.GetScopedZonedAttributeKeys(attr.LocalScope, attr.FilesharingZone) if err != nil { return err } for _, key := range keys { // only look at timestamp keys // this is an arbitrary choice if strings.HasSuffix(key, ".ts") { _, zonedpath := attr.ParseScope(key) _, keypath := attr.ParseZone(zonedpath) keyparts := strings.Split(keypath, ".") // assert that the key is well-formed if len(keyparts) == 3 && keyparts[2] == "ts" { // fetch the timestamp key filekey := strings.Join(keyparts[:2], ".") sharedFile, err := f.GetFileShareInfo(profile, filekey) // If we haven't explicitly stopped sharing the file then attempt a reshare if err == nil && sharedFile.Active { // this reshare can fail because we don't force sharing of files older than 30 days... err := f.restartFileShareAdvanced(profile, filekey, false) if err != nil { log.Debugf("could not reshare file: %v", err) } } else { log.Debugf("could not get fileshare info %v", err) } } } } return nil } // GetFileShareInfo returns information related to a known fileshare. // An error is returned if the data is incomplete func (f *Functionality) GetFileShareInfo(profile peer.CwtchPeer, filekey string) (*SharedFile, error) { timestampString, tsExists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.ts", filekey)) pathString, pathExists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.path", filekey)) activeString, activeExists := profile.GetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.active", filekey)) if tsExists && pathExists && activeExists { timestamp, err := strconv.Atoi(timestampString) if err == nil { dateShared := time.Unix(int64(timestamp), 0) expired := time.Since(dateShared) >= time.Hour*24*30 return &SharedFile{ FileKey: filekey, Path: pathString, DateShared: dateShared, Active: !expired && activeString == constants.True, Expired: expired, }, nil } } return nil, fmt.Errorf("nonexistant or malformed fileshare %v", filekey) } // ShareFile given a profile and a conversation handle, sets up a file sharing process to share the file // at filepath func (f *Functionality) ShareFile(filepath string, profile peer.CwtchPeer) (string, string, error) { // assert that we are allowed to share files if !profile.IsFeatureEnabled(constants.FileSharingExperiment) { return "", "", errors.New("filesharing functionality is not enabled") } manifest, err := files.CreateManifest(filepath) if err != nil { return "", "", err } var nonce [24]byte if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { log.Errorf("Cannot read from random: %v\n", err) return "", "", err } message := OverlayMessage{ Name: path.Base(manifest.FileName), Hash: hex.EncodeToString(manifest.RootHash), Nonce: hex.EncodeToString(nonce[:]), Size: manifest.FileSizeInBytes, } data, _ := json.Marshal(message) wrapper := model.MessageWrapper{ Overlay: model.OverlayFileSharing, Data: string(data), } wrapperJSON, _ := json.Marshal(wrapper) key := fmt.Sprintf("%x.%x", manifest.RootHash, nonce) serializedManifest, _ := json.Marshal(manifest) // Store the size of the manifest (in chunks) as part of the public scope so contacts who we share the file with // can fetch the manifest as if it were a file. // manifest.FileName gets redacted in filesharing_subsystem (to remove the system-specific file hierarchy), // but we need to *store* the full path because the sender also uses it to locate the file lenDiff := len(filepath) - len(path.Base(filepath)) // the sender needs to know the location of the file so they can display it in a preview... // This eventually becomes a message attribute, but we don't have access to the message identifier until // the message gets sent. // In the worst case, this can be obtained using CheckDownloadStatus (though in practice this lookup will be // rare because the UI will almost always initiate the construction of a preview a file directly after sending it). profile.SetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.path", key), filepath) // Store the timestamp, manifest and manifest size for later. profile.SetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.ts", key), strconv.FormatInt(time.Now().Unix(), 10)) profile.SetScopedZonedAttribute(attr.ConversationScope, attr.FilesharingZone, fmt.Sprintf("%s.manifest", key), string(serializedManifest)) profile.SetScopedZonedAttribute(attr.ConversationScope, attr.FilesharingZone, fmt.Sprintf("%s.manifest.size", key), strconv.Itoa(int(math.Ceil(float64(len(serializedManifest)-lenDiff)/float64(files.DefaultChunkSize))))) err = f.startFileShare(profile, key, string(serializedManifest), false) return key, string(wrapperJSON), err } // SharedFile encapsulates information about a shared file // including the file key, file path, the original share date and the // current sharing status type SharedFile struct { // The roothash.nonce identifier derived for this file share FileKey string // Path is the OS specific location of the file Path string // DateShared is the original datetime the file was shared DateShared time.Time // Active is true if the file is currently being shared, false otherwise Active bool // Expired is true if the file is not eligible to be shared (because e.g. it has been too long since the file was originally shared, // or the file no longer exists). Expired bool } func (f *Functionality) EnhancedGetSharedFiles(profile peer.CwtchPeer, conversationID int) string { data, err := json.Marshal(f.GetSharedFiles(profile, conversationID)) if err == nil { return string(data) } return "" } // GetSharedFiles returns all file shares associated with a given conversation func (f *Functionality) GetSharedFiles(profile peer.CwtchPeer, conversationID int) []SharedFile { var sharedFiles []SharedFile ci, err := profile.GetConversationInfo(conversationID) if err == nil { for k := range ci.Attributes { // when we share a file with a conversation we set a single attribute conversation.filesharing. if strings.HasPrefix(k, "conversation.filesharing") { parts := strings.SplitN(k, ".", 3) if len(parts) == 3 { key := parts[2] sharedFile, err := f.GetFileShareInfo(profile, key) if err == nil { sharedFiles = append(sharedFiles, *sharedFile) } } } } } return sharedFiles } // GenerateDownloadPath creates a file path that doesn't currently exist on the filesystem func GenerateDownloadPath(basePath, fileName string, overwrite bool) (filePath, manifestPath string) { // avoid all kina funky shit re := regexp.MustCompile(`[^A-Za-z0-9._-]`) filePath = re.ReplaceAllString(filePath, "") // avoid hidden files on linux for strings.HasPrefix(filePath, ".") { filePath = strings.TrimPrefix(filePath, ".") } // avoid empties if strings.TrimSpace(filePath) == "" { filePath = "untitled" } // if you like it, put a / on it if !strings.HasSuffix(basePath, string(os.PathSeparator)) { basePath = fmt.Sprintf("%s%s", basePath, string(os.PathSeparator)) } filePath = fmt.Sprintf("%s%s", basePath, fileName) manifestPath = fmt.Sprintf("%s.manifest", filePath) // if file is named "file", iterate "file", "file (2)", "file (3)", ... until DNE // if file is named "file.ext", iterate "file.ext", "file (2).ext", "file (3).ext", ... until DNE parts := strings.Split(fileName, ".") fileNameBase := parts[0] fileNameExt := "" if len(parts) > 1 { fileNameBase = strings.Join(parts[0:len(parts)-1], ".") fileNameExt = fmt.Sprintf(".%s", parts[len(parts)-1]) } if !overwrite { for i := 2; ; i++ { if _, err := os.Open(filePath); os.IsNotExist(err) { if _, err := os.Open(manifestPath); os.IsNotExist(err) { return } } filePath = fmt.Sprintf("%s%s (%d)%s", basePath, fileNameBase, i, fileNameExt) manifestPath = fmt.Sprintf("%s.manifest", filePath) } } return } // StopFileShare sends a message to the ProtocolEngine to cease sharing a particular file func (f *Functionality) StopFileShare(profile peer.CwtchPeer, fileKey string) error { // Note we do not do a permissions check here, as we are *always* permitted to stop sharing files. // set the filekey status to inactive profile.SetScopedZonedAttribute(attr.LocalScope, attr.FilesharingZone, fmt.Sprintf("%s.active", fileKey), constants.False) profile.PublishEvent(event.NewEvent(event.StopFileShare, map[event.Field]string{event.FileKey: fileKey})) return nil // cannot fail } // StopAllFileShares sends a message to the ProtocolEngine to cease sharing all files func (f *Functionality) StopAllFileShares(profile peer.CwtchPeer) { // Note we do not do a permissions check here, as we are *always* permitted to stop sharing files. profile.PublishEvent(event.NewEvent(event.StopAllFileShares, map[event.Field]string{})) }