import std.algorithm; import std.array: array; import std.datetime; import std.exception: enforce; import std.file, std.json, std.path; import std.regex; import std.stdio, std.string, std.uni, std.uri; import std.conv; import std.encoding; import core.time, core.thread; import core.stdc.stdlib; import config, itemdb, onedrive, selective, upload, util; static import log; // threshold after which files will be uploaded using an upload session private long thresholdFileSize = 4 * 2^^20; // 4 MiB // flag to set whether local files should be deleted from OneDrive private bool noRemoteDelete = false; // flag to set whether the local file should be deleted once it is successfully uploaded to OneDrive private bool localDeleteAfterUpload = false; // flag to set if we are running as uploadOnly private bool uploadOnly = false; // Do we configure to disable the upload validation routine private bool disableUploadValidation = false; // Do we configure to disable the download validation routine private bool disableDownloadValidation = false; // Do we perform a local cleanup of files that are 'extra' on the local file system, when using --download-only private bool cleanupLocalFiles = false; private bool isItemFolder(const ref JSONValue item) { return ("folder" in item) != null; } private bool isItemFile(const ref JSONValue item) { return ("file" in item) != null; } private bool isItemDeleted(const ref JSONValue item) { return ("deleted" in item) != null; } private bool isItemRoot(const ref JSONValue item) { return ("root" in item) != null; } private bool isItemRemote(const ref JSONValue item) { return ("remoteItem" in item) != null; } private bool hasParentReference(const ref JSONValue item) { return ("parentReference" in item) != null; } private bool hasParentReferenceId(const ref JSONValue item) { return ("id" in item["parentReference"]) != null; } private bool hasParentReferencePath(const ref JSONValue item) { return ("path" in item["parentReference"]) != null; } private bool isMalware(const ref JSONValue item) { return ("malware" in item) != null; } private bool hasFileSize(const ref JSONValue item) { return ("size" in item) != null; } private bool hasId(const ref JSONValue item) { return ("id" in item) != null; } private bool hasHashes(const ref JSONValue item) { return ("hashes" in item["file"]) != null; } private bool hasQuickXorHash(const ref JSONValue item) { return ("quickXorHash" in item["file"]["hashes"]) != null; } private bool hasSHA256Hash(const ref JSONValue item) { return ("sha256Hash" in item["file"]["hashes"]) != null; } private bool isDotFile(const(string) path) { // always allow the root if (path == ".") return false; auto paths = pathSplitter(buildNormalizedPath(path)); foreach(base; paths) { if (startsWith(base, ".")){ return true; } } return false; } // construct an Item struct from a JSON driveItem private Item makeDatabaseItem(const ref JSONValue driveItem) { Item item = { id: driveItem["id"].str, name: "name" in driveItem ? driveItem["name"].str : null, // name may be missing for deleted files in OneDrive Biz eTag: "eTag" in driveItem ? driveItem["eTag"].str : null, // eTag is not returned for the root in OneDrive Biz cTag: "cTag" in driveItem ? driveItem["cTag"].str : null, // cTag is missing in old files (and all folders in OneDrive Biz) }; // OneDrive API Change: https://github.com/OneDrive/onedrive-api-docs/issues/834 // OneDrive no longer returns lastModifiedDateTime if the item is deleted by OneDrive if(isItemDeleted(driveItem)){ // Set mtime to SysTime(0) item.mtime = SysTime(0); } else { // Item is not in a deleted state // Resolve 'Key not found: fileSystemInfo' when then item is a remote item // https://github.com/abraunegg/onedrive/issues/11 if (isItemRemote(driveItem)) { // remoteItem is a OneDrive object that exists on a 'different' OneDrive drive id, when compared to account default // Normally, the 'remoteItem' field will contain 'fileSystemInfo' however, if the user uses the 'Add Shortcut ..' option in OneDrive WebUI // to create a 'link', this object, whilst remote, does not have 'fileSystemInfo' in the expected place, thus leading to a application crash // See: https://github.com/abraunegg/onedrive/issues/1533 if ("fileSystemInfo" in driveItem["remoteItem"]) { // 'fileSystemInfo' is in 'remoteItem' which will be the majority of cases item.mtime = SysTime.fromISOExtString(driveItem["remoteItem"]["fileSystemInfo"]["lastModifiedDateTime"].str); } else { // is a remote item, but 'fileSystemInfo' is missing from 'remoteItem' item.mtime = SysTime.fromISOExtString(driveItem["fileSystemInfo"]["lastModifiedDateTime"].str); } } else { // item exists on account default drive id item.mtime = SysTime.fromISOExtString(driveItem["fileSystemInfo"]["lastModifiedDateTime"].str); } } if (isItemFile(driveItem)) { item.type = ItemType.file; } else if (isItemFolder(driveItem)) { item.type = ItemType.dir; } else if (isItemRemote(driveItem)) { item.type = ItemType.remote; } else { // do not throw exception, item will be removed in applyDifferences() } // root and remote items do not have parentReference if (!isItemRoot(driveItem) && ("parentReference" in driveItem) != null) { item.driveId = driveItem["parentReference"]["driveId"].str; if (hasParentReferenceId(driveItem)) { item.parentId = driveItem["parentReference"]["id"].str; } } // extract the file hash if (isItemFile(driveItem) && ("hashes" in driveItem["file"])) { // Get quickXorHash if ("quickXorHash" in driveItem["file"]["hashes"]) { item.quickXorHash = driveItem["file"]["hashes"]["quickXorHash"].str; } else { log.vdebug("quickXorHash is missing from ", driveItem["id"].str); } // sha256Hash if ("sha256Hash" in driveItem["file"]["hashes"]) { item.sha256Hash = driveItem["file"]["hashes"]["sha256Hash"].str; } else { log.vdebug("sha256Hash is missing from ", driveItem["id"].str); } } if (isItemRemote(driveItem)) { item.remoteDriveId = driveItem["remoteItem"]["parentReference"]["driveId"].str; item.remoteId = driveItem["remoteItem"]["id"].str; } // National Cloud Deployments do not support /delta as a query // Thus we need to track in the database that this item is in sync // As we are making an item, set the syncStatus to Y // ONLY when using a National Cloud Deployment, all the existing DB entries will get set to N // so when processing /children, it can be identified what the 'deleted' difference is item.syncStatus = "Y"; return item; } private bool testFileHash(const(string) path, const ref Item item) { // Generate QuickXORHash first before others if (item.quickXorHash) { if (item.quickXorHash == computeQuickXorHash(path)) return true; } else if (item.sha256Hash) { if (item.sha256Hash == computeSHA256Hash(path)) return true; } return false; } class SyncException: Exception { @nogc @safe pure nothrow this(string msg, string file = __FILE__, size_t line = __LINE__) { super(msg, file, line); } } final class SyncEngine { private Config cfg; private OneDriveApi onedrive; private ItemDatabase itemdb; private UploadSession session; private SelectiveSync selectiveSync; // list of items to skip while applying the changes private string[] skippedItems; // list of items to delete after the changes has been downloaded private string[2][] idsToDelete; // list of items we fake created when using --dry-run private string[2][] idsFaked; // list of directory names changed online, but not changed locally when using --dry-run private string[] pathsRenamed; // default drive id private string defaultDriveId; // default root id private string defaultRootId; // type of OneDrive account private string accountType; // free space remaining at init() private long remainingFreeSpace; // file size limit for a new file private long newSizeLimit; // is file malware flag private bool malwareDetected = false; // download filesystem issue flag private bool downloadFailed = false; // upload failure - OneDrive or filesystem issue (reading data) private bool uploadFailed = false; // initialization has been done private bool initDone = false; // sync engine dryRun flag private bool dryRun = false; // quota details available private bool quotaAvailable = true; // quota details restricted private bool quotaRestricted = false; // sync business shared folders flag private bool syncBusinessFolders = false; // single directory scope flag private bool singleDirectoryScope = false; // is sync_list configured private bool syncListConfigured = false; // sync_list new folder added, trigger delta scan override private bool oneDriveFullScanTrigger = false; // is bypass_data_preservation set via config file // Local data loss MAY occur in this scenario private bool bypassDataPreservation = false; // is National Cloud Deployments configured private bool nationalCloudDeployment = false; // has performance processing timings been requested private bool displayProcessingTime = false; // array of all OneDrive driveId's for use with OneDrive Business Folders private string[] driveIDsArray; this(Config cfg, OneDriveApi onedrive, ItemDatabase itemdb, SelectiveSync selectiveSync) { assert(onedrive && itemdb && selectiveSync); this.cfg = cfg; this.onedrive = onedrive; this.itemdb = itemdb; this.selectiveSync = selectiveSync; // session = UploadSession(onedrive, cfg.uploadStateFilePath); this.dryRun = cfg.getValueBool("dry_run"); this.newSizeLimit = cfg.getValueLong("skip_size") * 2^^20; this.newSizeLimit = (this.newSizeLimit == 0) ? long.max : this.newSizeLimit; } void reset() { initDone=false; } void init() { // Set accountType, defaultDriveId, defaultRootId & remainingFreeSpace once and reuse where possible JSONValue oneDriveDetails; JSONValue oneDriveRootDetails; if (initDone) { return; } session = UploadSession(onedrive, cfg.uploadStateFilePath); // Need to catch 400 or 5xx server side errors at initialization // Get Default Drive try { oneDriveDetails = onedrive.getDefaultDrive(); } catch (OneDriveException e) { log.vdebug("oneDriveDetails = onedrive.getDefaultDrive() generated a OneDriveException"); if (e.httpStatusCode == 400) { // OneDrive responded with 400 error: Bad Request displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Check this if (cfg.getValueString("drive_id").length) { writeln(); log.error("ERROR: Check your 'drive_id' entry in your configuration file as it may be incorrect"); writeln(); } // Must exit here onedrive.shutdown(); exit(-1); } if (e.httpStatusCode == 401) { // HTTP request returned status code 401 (Unauthorized) displayOneDriveErrorMessage(e.msg, getFunctionName!({})); handleClientUnauthorised(); } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling init();"); init(); // return back to original call return; } if (e.httpStatusCode >= 500) { // There was a HTTP 5xx Server Side Error displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Must exit here onedrive.shutdown(); exit(-1); } } // Get Default Root try { oneDriveRootDetails = onedrive.getDefaultRoot(); } catch (OneDriveException e) { log.vdebug("oneDriveRootDetails = onedrive.getDefaultRoot() generated a OneDriveException"); if (e.httpStatusCode == 400) { // OneDrive responded with 400 error: Bad Request displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Check this if (cfg.getValueString("drive_id").length) { writeln(); log.error("ERROR: Check your 'drive_id' entry in your configuration file as it may be incorrect"); writeln(); } // Must exit here onedrive.shutdown(); exit(-1); } if (e.httpStatusCode == 401) { // HTTP request returned status code 401 (Unauthorized) displayOneDriveErrorMessage(e.msg, getFunctionName!({})); handleClientUnauthorised(); } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling init();"); init(); // return back to original call return; } if (e.httpStatusCode >= 500) { // There was a HTTP 5xx Server Side Error displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Must exit here onedrive.shutdown(); exit(-1); } } if ((oneDriveDetails.type() == JSONType.object) && (oneDriveRootDetails.type() == JSONType.object) && (hasId(oneDriveDetails)) && (hasId(oneDriveRootDetails))) { // JSON elements are valid // Debug OneDrive Account details response log.vdebug("OneDrive Account Details: ", oneDriveDetails); log.vdebug("OneDrive Account Root Details: ", oneDriveRootDetails); // Successfully got details from OneDrive without a server side error such as 'HTTP/1.1 500 Internal Server Error' or 'HTTP/1.1 504 Gateway Timeout' accountType = oneDriveDetails["driveType"].str; defaultDriveId = oneDriveDetails["id"].str; defaultRootId = oneDriveRootDetails["id"].str; // get the remaining size from OneDrive API if ("remaining" in oneDriveDetails["quota"]){ // use the value provided remainingFreeSpace = oneDriveDetails["quota"]["remaining"].integer; } else { // set at zero remainingFreeSpace = 0; } // Make sure that defaultDriveId is in our driveIDs array to use when checking if item is in database // Keep the driveIDsArray with unique entries only if (!canFind(driveIDsArray, defaultDriveId)) { // Add this drive id to the array to search with driveIDsArray ~= defaultDriveId; } // In some cases OneDrive Business configurations 'restrict' quota details thus is empty / blank / negative value / zero if (remainingFreeSpace <= 0) { // free space is <= 0 .. why ? if ("remaining" in oneDriveDetails["quota"]){ // json response contained a 'remaining' value if (accountType == "personal"){ // zero space available log.error("ERROR: OneDrive account currently has zero space available. Please free up some space online."); quotaAvailable = false; } else { // zero space available is being reported, maybe being restricted? log.error("WARNING: OneDrive quota information is being restricted or providing a zero value. Please fix by speaking to your OneDrive / Office 365 Administrator."); quotaRestricted = true; } } else { // json response was missing a 'remaining' value if (accountType == "personal"){ log.error("ERROR: OneDrive quota information is missing. Potentially your OneDrive account currently has zero space available. Please free up some space online."); quotaAvailable = false; } else { // quota details not available log.error("ERROR: OneDrive quota information is being restricted. Please fix by speaking to your OneDrive / Office 365 Administrator."); quotaRestricted = true; } } } // Display accountType, defaultDriveId, defaultRootId & remainingFreeSpace for verbose logging purposes log.vlog("Application version: ", strip(import("version"))); log.vlog("Account Type: ", accountType); log.vlog("Default Drive ID: ", defaultDriveId); log.vlog("Default Root ID: ", defaultRootId); // What do we display here if (remainingFreeSpace > 0) { // Display the actual value log.vlog("Remaining Free Space: ", remainingFreeSpace); } else { // zero or non-zero value or restricted if (!quotaRestricted){ log.vlog("Remaining Free Space: 0"); } else { log.vlog("Remaining Free Space: Not Available"); } } // If account type is documentLibrary - then most likely this is a SharePoint repository // and files 'may' be modified after upload. See: https://github.com/abraunegg/onedrive/issues/205 if(accountType == "documentLibrary") { // set this flag for SharePoint regardless of --disable-upload-validation being used setDisableUploadValidation(); } // Check the local database to ensure the OneDrive Root details are in the database checkDatabaseForOneDriveRoot(); // Check if there is an interrupted upload session if (session.restore()) { log.log("Continuing the upload session ..."); string uploadSessionLocalFilePath = session.getUploadSessionLocalFilePath(); auto item = session.upload(); // is 'item' a valid JSON response and not null if (item.type() == JSONType.object) { // Upload did not fail, JSON response contains data // Are we in an --upload-only & --remove-source-files scenario? // Use actual config values as we are doing an upload session recovery if ((cfg.getValueBool("upload_only")) && (cfg.getValueBool("remove_source_files"))) { // Log that we are deleting a local item log.log("Removing local file as --upload-only & --remove-source-files configured"); // are we in a --dry-run scenario? if (!dryRun) { // No --dry-run ... process local file delete if (!uploadSessionLocalFilePath.empty) { // only perform the delete if we have a valid file path if (exists(uploadSessionLocalFilePath)) { // file exists log.vdebug("Removing local file: ", uploadSessionLocalFilePath); safeRemove(uploadSessionLocalFilePath); } } } // as file is removed, we have nothing to add to the local database log.vdebug("Skipping adding to database as --upload-only & --remove-source-files configured"); } else { // save the item saveItem(item); } } else { // JSON response was not valid, upload failed log.error("ERROR: File failed to upload. Increase logging verbosity to determine why."); } } initDone = true; } else { // init failure initDone = false; // log why log.error("ERROR: Unable to query OneDrive to initialize application"); // Debug OneDrive Account details response log.vdebug("OneDrive Account Details: ", oneDriveDetails); log.vdebug("OneDrive Account Root Details: ", oneDriveRootDetails); // Must exit here onedrive.shutdown(); exit(-1); } } // Configure uploadOnly if function is called // By default, uploadOnly = false; void setUploadOnly() { uploadOnly = true; } // Configure noRemoteDelete if function is called // By default, noRemoteDelete = false; // Meaning we will process local deletes to delete item on OneDrive void setNoRemoteDelete() { noRemoteDelete = true; } // Configure localDeleteAfterUpload if function is called // By default, localDeleteAfterUpload = false; // Meaning we will not delete any local file after upload is successful void setLocalDeleteAfterUpload() { localDeleteAfterUpload = true; } // set the flag that we are going to sync business shared folders void setSyncBusinessFolders() { syncBusinessFolders = true; } // Configure singleDirectoryScope if function is called // By default, singleDirectoryScope = false void setSingleDirectoryScope() { singleDirectoryScope = true; } // Configure disableUploadValidation if function is called // By default, disableUploadValidation = false; // Meaning we will always validate our uploads // However, when uploading a file that can contain metadata SharePoint will associate some // metadata from the library the file is uploaded to directly in the file // which breaks this validation. See https://github.com/abraunegg/onedrive/issues/205 void setDisableUploadValidation() { disableUploadValidation = true; log.vdebug("documentLibrary account type - flagging to disable upload validation checks due to Microsoft SharePoint file modification enrichments"); } // Configure disableDownloadValidation if function is called // By default, disableDownloadValidation = false; // Meaning we will always validate our downloads // However, when downloading files from SharePoint, the OneDrive API will not advise the correct file size // which means that the application thinks the file download has failed as the size is different / hash is different // See: https://github.com/abraunegg/onedrive/discussions/1667 void setDisableDownloadValidation() { disableDownloadValidation = true; log.vdebug("Flagging to disable download validation checks due to user request"); } // Issue #658 Handling // If an existing folder is moved into a sync_list valid path (where it previously was out of scope due to sync_list), // then set this flag to true, so that on the second 'true-up' sync, we force a rescan of the OneDrive path to capture any 'files' void setOneDriveFullScanTrigger() { oneDriveFullScanTrigger = true; log.vdebug("Setting oneDriveFullScanTrigger = true due to new folder creation request in a location that is now in-scope which may have previously out of scope"); } // unset method void unsetOneDriveFullScanTrigger() { oneDriveFullScanTrigger = false; log.vdebug("Setting oneDriveFullScanTrigger = false"); } // set syncListConfigured to true void setSyncListConfigured() { syncListConfigured = true; log.vdebug("Setting syncListConfigured = true"); } // set bypassDataPreservation to true void setBypassDataPreservation() { bypassDataPreservation = true; log.vdebug("Setting bypassDataPreservation = true"); } // set nationalCloudDeployment to true void setNationalCloudDeployment() { nationalCloudDeployment = true; log.vdebug("Setting nationalCloudDeployment = true"); } // set performance timing flag void setPerformanceProcessingOutput() { displayProcessingTime = true; log.vdebug("Setting displayProcessingTime = true"); } // get performance timing flag bool getPerformanceProcessingOutput() { return displayProcessingTime; } // set cleanupLocalFiles to true void setCleanupLocalFiles() { cleanupLocalFiles = true; log.vdebug("Setting cleanupLocalFiles = true"); } // return the OneDrive Account Type auto getAccountType() { // return account type in use return accountType; } // download all new changes from OneDrive void applyDifferences(bool performFullItemScan) { // Set defaults for the root folder // Use the global's as initialised via init() rather than performing unnecessary additional HTTPS calls string driveId = defaultDriveId; string rootId = defaultRootId; applyDifferences(driveId, rootId, performFullItemScan); // Check OneDrive Personal Shared Folders if (accountType == "personal"){ // https://github.com/OneDrive/onedrive-api-docs/issues/764 Item[] items = itemdb.selectRemoteItems(); foreach (item; items) { // Only check path if config is != "" if (cfg.getValueString("skip_dir") != "") { // The path that needs to be checked needs to include the '/' // This due to if the user has specified in skip_dir an exclusive path: '/path' - that is what must be matched if (selectiveSync.isDirNameExcluded(item.name)) { // This directory name is excluded log.vlog("Skipping item - excluded by skip_dir config: ", item.name); continue; } } // Directory name is not excluded or skip_dir is not populated log.vdebug("------------------------------------------------------------------"); if (!cfg.getValueBool("monitor")) { log.log("Syncing this OneDrive Personal Shared Folder: ", item.name); } else { log.vlog("Syncing this OneDrive Personal Shared Folder: ", item.name); } // Check this OneDrive Personal Shared Folders applyDifferences(item.remoteDriveId, item.remoteId, performFullItemScan); // Keep the driveIDsArray with unique entries only if (!canFind(driveIDsArray, item.remoteDriveId)) { // Add this OneDrive Personal Shared Folder driveId array driveIDsArray ~= item.remoteDriveId; } } } // Check OneDrive Business Shared Folders, if configured to do so if (syncBusinessFolders){ // query OneDrive Business Shared Folders shared with me log.vlog("Attempting to sync OneDrive Business Shared Folders"); JSONValue graphQuery; try { graphQuery = onedrive.getSharedWithMe(); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // HTTP request returned status code 401 (Unauthorized) displayOneDriveErrorMessage(e.msg, getFunctionName!({})); handleClientUnauthorised(); } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - graphQuery = onedrive.getSharedWithMe();"); graphQuery = onedrive.getSharedWithMe(); } if (e.httpStatusCode >= 500) { // There was a HTTP 5xx Server Side Error displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Must exit here onedrive.shutdown(); exit(-1); } } if (graphQuery.type() == JSONType.object) { string sharedFolderName; foreach (searchResult; graphQuery["value"].array) { // Configure additional logging items for this array element string sharedByName; string sharedByEmail; // Extra details for verbose logging if ("sharedBy" in searchResult["remoteItem"]["shared"]) { if ("displayName" in searchResult["remoteItem"]["shared"]["sharedBy"]["user"]) { sharedByName = searchResult["remoteItem"]["shared"]["sharedBy"]["user"]["displayName"].str; } if ("email" in searchResult["remoteItem"]["shared"]["sharedBy"]["user"]) { sharedByEmail = searchResult["remoteItem"]["shared"]["sharedBy"]["user"]["email"].str; } } // is the shared item with us a 'folder' ? if (isItemFolder(searchResult)) { // item returned is a shared folder, not a shared file sharedFolderName = searchResult["name"].str; // Output Shared Folder Name early log.vdebug("Shared Folder Name: ", sharedFolderName); // Compare this to values in business_shared_folders if(selectiveSync.isSharedFolderMatched(sharedFolderName)){ // Folder name matches what we are looking for // Flags for matching bool itemInDatabase = false; bool itemLocalDirExists = false; bool itemPathIsLocal = false; // "what if" there are 2 or more folders shared with me have the "same" name? // The folder name will be the same, but driveId will be different // This will then cause these 'shared folders' to cross populate data, which may not be desirable log.vdebug("Shared Folder Name: MATCHED to any entry in 'business_shared_folders'"); log.vdebug("Parent Drive Id: ", searchResult["remoteItem"]["parentReference"]["driveId"].str); log.vdebug("Shared Item Id: ", searchResult["remoteItem"]["id"].str); Item databaseItem; // for each driveid in the existing driveIDsArray foreach (searchDriveId; driveIDsArray) { log.vdebug("searching database for: ", searchDriveId, " ", sharedFolderName); if (itemdb.idInLocalDatabase(searchDriveId, searchResult["remoteItem"]["id"].str)){ // Shared folder is present log.vdebug("Found shared folder name in database"); itemInDatabase = true; // Query the DB for the details of this item itemdb.selectByPath(sharedFolderName, searchDriveId, databaseItem); log.vdebug("databaseItem: ", databaseItem); // Does the databaseItem.driveId == defaultDriveId? if (databaseItem.driveId == defaultDriveId) { itemPathIsLocal = true; } } else { log.vdebug("Shared folder name not found in database"); // "what if" there is 'already' a local folder with this name // Check if in the database // If NOT in the database, but resides on disk, this could be a new local folder created after last sync but before this one // However we sync 'shared folders' before checking for local changes string localpath = expandTilde(cfg.getValueString("sync_dir")) ~ "/" ~ sharedFolderName; if (exists(localpath)) { // local path exists log.vdebug("Found shared folder name in local OneDrive sync_dir"); itemLocalDirExists = true; } } } // Shared Folder Evaluation Debugging log.vdebug("item in database: ", itemInDatabase); log.vdebug("path exists on disk: ", itemLocalDirExists); log.vdebug("database drive id matches defaultDriveId: ", itemPathIsLocal); log.vdebug("database data matches search data: ", ((databaseItem.driveId == searchResult["remoteItem"]["parentReference"]["driveId"].str) && (databaseItem.id == searchResult["remoteItem"]["id"].str))); if ( ((!itemInDatabase) || (!itemLocalDirExists)) || (((databaseItem.driveId == searchResult["remoteItem"]["parentReference"]["driveId"].str) && (databaseItem.id == searchResult["remoteItem"]["id"].str)) && (!itemPathIsLocal)) ) { // This shared folder does not exist in the database if (!cfg.getValueBool("monitor")) { log.log("Syncing this OneDrive Business Shared Folder: ", sharedFolderName); } else { log.vlog("Syncing this OneDrive Business Shared Folder: ", sharedFolderName); } Item businessSharedFolder = makeItem(searchResult); // Log who shared this to assist with sync data correlation if ((sharedByName != "") && (sharedByEmail != "")) { log.vlog("OneDrive Business Shared Folder - Shared By: ", sharedByName, " (", sharedByEmail, ")"); } else { if (sharedByName != "") { log.vlog("OneDrive Business Shared Folder - Shared By: ", sharedByName); } } // Do the actual sync applyDifferences(businessSharedFolder.remoteDriveId, businessSharedFolder.remoteId, performFullItemScan); // add this parent drive id to the array to search for, ready for next use string newDriveID = searchResult["remoteItem"]["parentReference"]["driveId"].str; // Keep the driveIDsArray with unique entries only if (!canFind(driveIDsArray, newDriveID)) { // Add this drive id to the array to search with driveIDsArray ~= newDriveID; } } else { // Shared Folder Name Conflict ... log.log("WARNING: Skipping shared folder due to existing name conflict: ", sharedFolderName); log.log("WARNING: Skipping changes of Path ID: ", searchResult["remoteItem"]["id"].str); log.log("WARNING: To sync this shared folder, this shared folder needs to be renamed"); // Log who shared this to assist with conflict resolution if ((sharedByName != "") && (sharedByEmail != "")) { log.vlog("WARNING: Conflict Shared By: ", sharedByName, " (", sharedByEmail, ")"); } else { if (sharedByName != "") { log.vlog("WARNING: Conflict Shared By: ", sharedByName); } } } } else { log.vdebug("Shared Folder Name: NO MATCH to any entry in 'business_shared_folders'"); } } else { // not a folder, is this a file? if (isItemFile(searchResult)) { // shared item is a file string sharedFileName = searchResult["name"].str; // log that this is not supported log.vlog("WARNING: Not syncing this OneDrive Business Shared File: ", sharedFileName); // Log who shared this to assist with sync data correlation if ((sharedByName != "") && (sharedByEmail != "")) { log.vlog("OneDrive Business Shared File - Shared By: ", sharedByName, " (", sharedByEmail, ")"); } else { if (sharedByName != "") { log.vlog("OneDrive Business Shared File - Shared By: ", sharedByName); } } } else { // something else entirely log.log("WARNING: Not syncing this OneDrive Business Shared item: ", searchResult["name"].str); } } } } else { // Log that an invalid JSON object was returned log.error("ERROR: onedrive.getSharedWithMe call returned an invalid JSON Object"); } } } // download all new changes from a specified folder on OneDrive void applyDifferencesSingleDirectory(const(string) path) { // Ensure we check the 'right' location for this directory on OneDrive // It could come from the following places: // 1. My OneDrive Root // 2. My OneDrive Root as an Office 365 Shared Library // 3. A OneDrive Business Shared Folder // If 1 & 2, the configured default items are what we need // If 3, we need to query OneDrive string driveId = defaultDriveId; string rootId = defaultRootId; string folderId; string itemId; JSONValue onedrivePathDetails; // Check OneDrive Business Shared Folders, if configured to do so if (syncBusinessFolders){ log.vlog("Attempting to sync OneDrive Business Shared Folders"); // query OneDrive Business Shared Folders shared with me JSONValue graphQuery; try { graphQuery = onedrive.getSharedWithMe(); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // HTTP request returned status code 401 (Unauthorized) displayOneDriveErrorMessage(e.msg, getFunctionName!({})); handleClientUnauthorised(); } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - graphQuery = onedrive.getSharedWithMe();"); graphQuery = onedrive.getSharedWithMe(); } if (e.httpStatusCode >= 500) { // There was a HTTP 5xx Server Side Error displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Must exit here onedrive.shutdown(); exit(-1); } } if (graphQuery.type() == JSONType.object) { // valid response from OneDrive string sharedFolderName; foreach (searchResult; graphQuery["value"].array) { // set sharedFolderName sharedFolderName = searchResult["name"].str; // Configure additional logging items for this array element string sharedByName; string sharedByEmail; // Extra details for verbose logging if ("sharedBy" in searchResult["remoteItem"]["shared"]) { if ("displayName" in searchResult["remoteItem"]["shared"]["sharedBy"]["user"]) { sharedByName = searchResult["remoteItem"]["shared"]["sharedBy"]["user"]["displayName"].str; } if ("email" in searchResult["remoteItem"]["shared"]["sharedBy"]["user"]) { sharedByEmail = searchResult["remoteItem"]["shared"]["sharedBy"]["user"]["email"].str; } } // Compare this to values in business_shared_folders if(selectiveSync.isSharedFolderMatched(sharedFolderName)){ // Matched sharedFolderName to item in business_shared_folders log.vdebug("Matched sharedFolderName in business_shared_folders: ", sharedFolderName); // But is this shared folder what we are looking for as part of --single-directory? // User could be using 'directory' or 'directory/directory1/directory2/directory3/' // Can we find 'sharedFolderName' in the given 'path' if (canFind(path, sharedFolderName)) { // Found 'sharedFolderName' in the given 'path' log.vdebug("Matched 'sharedFolderName' in the given 'path'"); // What was the matched folder JSON log.vdebug("Matched sharedFolderName in business_shared_folders JSON: ", searchResult); // Path we want to sync is on a OneDrive Business Shared Folder // Set the correct driveId driveId = searchResult["remoteItem"]["parentReference"]["driveId"].str; // Set this items id itemId = searchResult["remoteItem"]["id"].str; log.vdebug("Updated the driveId to a new value: ", driveId); log.vdebug("Updated the itemId to a new value: ", itemId); // Keep the driveIDsArray with unique entries only if (!canFind(driveIDsArray, driveId)) { // Add this drive id to the array to search with driveIDsArray ~= driveId; } // Log who shared this to assist with sync data correlation if ((sharedByName != "") && (sharedByEmail != "")) { log.vlog("OneDrive Business Shared Folder - Shared By: ", sharedByName, " (", sharedByEmail, ")"); } else { if (sharedByName != "") { log.vlog("OneDrive Business Shared Folder - Shared By: ", sharedByName); } } } } } } else { // Log that an invalid JSON object was returned log.error("ERROR: onedrive.getSharedWithMe call returned an invalid JSON Object"); } } // Test if the path we are going to sync from actually exists on OneDrive log.vlog("Getting path details from OneDrive ..."); try { // Need to use different calls here - one call for majority, another if this is a OneDrive Business Shared Folder if (!syncBusinessFolders){ // Not a OneDrive Business Shared Folder log.vdebug("Calling onedrive.getPathDetailsByDriveId(driveId, path) with: ", driveId, ", ", path); onedrivePathDetails = onedrive.getPathDetailsByDriveId(driveId, path); } else { // OneDrive Business Shared Folder - Use another API call using the folders correct driveId and itemId log.vdebug("Calling onedrive.getPathDetailsByDriveIdAndItemId(driveId, itemId) with: ", driveId, ", ", itemId); onedrivePathDetails = onedrive.getPathDetailsByDriveIdAndItemId(driveId, itemId); } } catch (OneDriveException e) { log.vdebug("onedrivePathDetails = onedrive.getPathDetails(path) generated a OneDriveException"); if (e.httpStatusCode == 404) { // The directory was not found if (syncBusinessFolders){ // 404 was returned when trying to use a specific driveId and itemId .. which 'should' work .... but didnt // Try the query with the path as a backup failsafe log.vdebug("Calling onedrive.getPathDetailsByDriveId(driveId, path) as backup with: ", driveId, ", ", path); try { // try calling using the path onedrivePathDetails = onedrive.getPathDetailsByDriveId(driveId, path); } catch (OneDriveException e) { if (e.httpStatusCode == 404) { log.error("ERROR: The requested single directory to sync was not found on OneDrive - Check folder permissions and sharing status with folder owner"); return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling applyDifferencesSingleDirectory(path);"); applyDifferencesSingleDirectory(path); // return back to original call return; } if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged return; } } } else { // Not a OneDrive Business Shared folder operation log.error("ERROR: The requested single directory to sync was not found on OneDrive"); return; } } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling applyDifferencesSingleDirectory(path);"); applyDifferencesSingleDirectory(path); // return back to original call return; } if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged return; } } // OK - the path on OneDrive should exist, get the driveId and rootId for this folder // Was the response a valid JSON Object? if (onedrivePathDetails.type() == JSONType.object) { // OneDrive Personal Shared Folder handling // Is this item a remote item? if(isItemRemote(onedrivePathDetails)){ // 2 step approach: // 1. Ensure changes for the root remote path are captured // 2. Download changes specific to the remote path // root remote applyDifferences(defaultDriveId, onedrivePathDetails["id"].str, false); // remote changes driveId = onedrivePathDetails["remoteItem"]["parentReference"]["driveId"].str; // Should give something like 66d53be8a5056eca folderId = onedrivePathDetails["remoteItem"]["id"].str; // Should give something like BC7D88EC1F539DCF!107 // Apply any differences found on OneDrive for this path (download data) applyDifferences(driveId, folderId, false); } else { // use the item id as folderId folderId = onedrivePathDetails["id"].str; // Should give something like 12345ABCDE1234A1!101 // Apply any differences found on OneDrive for this path (download data) // Use driveId rather than defaultDriveId as this will be updated if path was matched to another parent driveId applyDifferences(driveId, folderId, false); } } else { // Log that an invalid JSON object was returned log.vdebug("onedrive.getPathDetails call returned an invalid JSON Object"); } } // make sure the OneDrive root is in our database auto checkDatabaseForOneDriveRoot() { log.vlog("Fetching details for OneDrive Root"); JSONValue rootPathDetails = onedrive.getDefaultRoot(); // Returns a JSON Value // validate object is a JSON value if (rootPathDetails.type() == JSONType.object) { // valid JSON object Item rootPathItem = makeItem(rootPathDetails); // configure driveId and rootId for the OneDrive Root // Set defaults for the root folder string driveId = rootPathDetails["parentReference"]["driveId"].str; // Should give something like 12345abcde1234a1 string rootId = rootPathDetails["id"].str; // Should give something like 12345ABCDE1234A1!101 // Query the database if (!itemdb.selectById(driveId, rootId, rootPathItem)) { log.vlog("OneDrive Root does not exist in the database. We need to add it."); applyDifference(rootPathDetails, driveId, true); log.vlog("Added OneDrive Root to the local database"); } else { log.vlog("OneDrive Root exists in the database"); } } else { // Log that an invalid JSON object was returned log.error("ERROR: Unable to query OneDrive for account details"); log.vdebug("onedrive.getDefaultRoot call returned an invalid JSON Object"); // Must exit here as we cant configure our required variables onedrive.shutdown(); exit(-1); } } // create a directory on OneDrive without syncing auto createDirectoryNoSync(const(string) path) { // Attempt to create the requested path within OneDrive without performing a sync log.vlog("Attempting to create the requested path within OneDrive"); // Handle the remote folder creation and updating of the local database without performing a sync uploadCreateDir(path); } // delete a directory on OneDrive without syncing auto deleteDirectoryNoSync(const(string) path) { // Use the global's as initialised via init() rather than performing unnecessary additional HTTPS calls const(char)[] rootId = defaultRootId; // Attempt to delete the requested path within OneDrive without performing a sync log.vlog("Attempting to delete the requested path within OneDrive"); // test if the path we are going to exists on OneDrive try { onedrive.getPathDetails(path); } catch (OneDriveException e) { log.vdebug("onedrive.getPathDetails(path) generated a OneDriveException"); if (e.httpStatusCode == 404) { // The directory was not found on OneDrive - no need to delete it log.vlog("The requested directory to delete was not found on OneDrive - skipping removing the remote directory as it doesn't exist"); return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling deleteDirectoryNoSync(path);"); deleteDirectoryNoSync(path); // return back to original call return; } if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged return; } } Item item; // Need to check all driveid's we know about, not just the defaultDriveId bool itemInDB = false; foreach (searchDriveId; driveIDsArray) { if (itemdb.selectByPath(path, searchDriveId, item)) { // item was found in the DB itemInDB = true; break; } } // Was the item found in the DB if (!itemInDB) { // this is odd .. this directory is not in the local database - just go delete it log.vlog("The requested directory to delete was not found in the local database - pushing delete request direct to OneDrive"); uploadDeleteItem(item, path); } else { // the folder was in the local database // Handle the deletion and saving any update to the local database log.vlog("The requested directory to delete was found in the local database. Processing the deletion normally"); deleteByPath(path); } } // rename a directory on OneDrive without syncing auto renameDirectoryNoSync(string source, string destination) { try { // test if the local path exists on OneDrive onedrive.getPathDetails(source); } catch (OneDriveException e) { log.vdebug("onedrive.getPathDetails(source); generated a OneDriveException"); if (e.httpStatusCode == 404) { // The directory was not found log.vlog("The requested directory to rename was not found on OneDrive"); return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling renameDirectoryNoSync(source, destination);"); renameDirectoryNoSync(source, destination); // return back to original call return; } if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged return; } } // The OneDrive API returned a 200 OK status, so the folder exists // Rename the requested directory on OneDrive without performing a sync moveByPath(source, destination); } // download the new changes of a specific item // id is the root of the drive or a shared folder private void applyDifferences(string driveId, const(char)[] id, bool performFullItemScan) { log.vlog("Applying changes of Path ID: " ~ id); // function variables char[] idToQuery; JSONValue changes; JSONValue changesAvailable; JSONValue idDetails; JSONValue currentDriveQuota; string syncFolderName; string syncFolderPath; string syncFolderChildPath; string deltaLink; string deltaLinkAvailable; bool nationalCloudChildrenScan = false; // Tracking processing performance SysTime startFunctionProcessingTime; SysTime endFunctionProcessingTime; SysTime startBundleProcessingTime; SysTime endBundleProcessingTime; ulong cumulativeOneDriveItemCount = 0; if (displayProcessingTime) { writeln("============================================================"); writeln("Querying OneDrive API for relevant 'changes|items' stored online for this account"); startFunctionProcessingTime = Clock.currTime(); writeln("Start Function Processing Time: ", startFunctionProcessingTime); } // Update the quota details for this driveId, as this could have changed since we started the application - the user could have added / deleted data online, or purchased additional storage // Quota details are ONLY available for the main default driveId, as the OneDrive API does not provide quota details for shared folders try { currentDriveQuota = onedrive.getDriveQuota(driveId); } catch (OneDriveException e) { log.vdebug("currentDriveQuota = onedrive.getDriveQuota(driveId) generated a OneDriveException"); if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling applyDifferences(driveId, id, performFullItemScan);"); applyDifferences(driveId, id, performFullItemScan); // return back to original call return; } if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged return; } } // validate that currentDriveQuota is a JSON value if (currentDriveQuota.type() == JSONType.object) { // Response from API contains valid data // If 'personal' accounts, if driveId == defaultDriveId, then we will have data // If 'personal' accounts, if driveId != defaultDriveId, then we will not have quota data // If 'business' accounts, if driveId == defaultDriveId, then we will have data // If 'business' accounts, if driveId != defaultDriveId, then we will have data, but it will be 0 values if ("quota" in currentDriveQuota){ if (driveId == defaultDriveId) { // We potentially have updated quota remaining details available // However in some cases OneDrive Business configurations 'restrict' quota details thus is empty / blank / negative value / zero if ("remaining" in currentDriveQuota["quota"]){ // We have valid quota details returned for the drive id remainingFreeSpace = currentDriveQuota["quota"]["remaining"].integer; if (remainingFreeSpace <= 0) { if (accountType == "personal"){ // zero space available log.error("ERROR: OneDrive account currently has zero space available. Please free up some space online."); quotaAvailable = false; } else { // zero space available is being reported, maybe being restricted? log.error("WARNING: OneDrive quota information is being restricted or providing a zero value. Please fix by speaking to your OneDrive / Office 365 Administrator."); quotaRestricted = true; } } else { // Display the updated value log.vlog("Updated Remaining Free Space: ", remainingFreeSpace); } } } else { // quota details returned, but for a drive id that is not ours if ("remaining" in currentDriveQuota["quota"]){ // remaining is in the quota JSON response if (currentDriveQuota["quota"]["remaining"].integer <= 0) { // value returned is 0 or less than 0 log.vlog("OneDrive quota information is set at zero, as this is not our drive id, ignoring"); } } } } else { // No quota details returned if (driveId == defaultDriveId) { // no quota details returned for current drive id log.error("ERROR: OneDrive quota information is missing. Potentially your OneDrive account currently has zero space available. Please free up some space online."); } else { // quota details not available log.vdebug("OneDrive quota information is being restricted as this is not our drive id."); } } } // Query OneDrive API for the name of this folder id try { idDetails = onedrive.getPathDetailsById(driveId, id); } catch (OneDriveException e) { log.vdebug("idDetails = onedrive.getPathDetailsById(driveId, id) generated a OneDriveException"); if (e.httpStatusCode == 404) { // id was not found - possibly a remote (shared) folder log.vlog("No details returned for given Path ID"); return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling applyDifferences(driveId, id, performFullItemScan);"); applyDifferences(driveId, id, performFullItemScan); // return back to original call return; } if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged return; } } // validate that idDetails is a JSON value if (idDetails.type() == JSONType.object) { // Get the name of this 'Path ID' if (("id" in idDetails) != null) { // valid response from onedrive.getPathDetailsById(driveId, id) - a JSON item object present if ((idDetails["id"].str == id) && (!isItemFile(idDetails))){ // Is a Folder or Remote Folder syncFolderName = idDetails["name"].str; } // Debug output of path details as queried from OneDrive log.vdebug("OneDrive Path Details: ", idDetails); // OneDrive Personal Folder Item Reference (24/4/2019) // "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#drives('66d53be8a5056eca')/items/$entity", // "cTag": "adDo2NkQ1M0JFOEE1MDU2RUNBITEwMS42MzY5MTY5NjQ1ODcwNzAwMDA", // "eTag": "aNjZENTNCRThBNTA1NkVDQSExMDEuMQ", // "fileSystemInfo": { // "createdDateTime": "2018-06-06T20:45:24.436Z", // "lastModifiedDateTime": "2019-04-24T07:09:31.29Z" // }, // "folder": { // "childCount": 3, // "view": { // "sortBy": "takenOrCreatedDateTime", // "sortOrder": "ascending", // "viewType": "thumbnails" // } // }, // "id": "66D53BE8A5056ECA!101", // "name": "root", // "parentReference": { // "driveId": "66d53be8a5056eca", // "driveType": "personal" // }, // "root": {}, // "size": 0 // OneDrive Personal Remote / Shared Folder Item Reference (4/9/2019) // "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#drives('driveId')/items/$entity", // "cTag": "cTag", // "eTag": "eTag", // "id": "itemId", // "name": "shared", // "parentReference": { // "driveId": "driveId", // "driveType": "personal", // "id": "parentItemId", // "path": "/drive/root:" // }, // "remoteItem": { // "fileSystemInfo": { // "createdDateTime": "2019-01-14T18:54:43.2666667Z", // "lastModifiedDateTime": "2019-04-24T03:47:22.53Z" // }, // "folder": { // "childCount": 0, // "view": { // "sortBy": "takenOrCreatedDateTime", // "sortOrder": "ascending", // "viewType": "thumbnails" // } // }, // "id": "remoteItemId", // "parentReference": { // "driveId": "remoteDriveId", // "driveType": "personal" // "id": "id", // "name": "name", // "path": "/drives//items/:/" // }, // "size": 0, // "webUrl": "webUrl" // } // OneDrive Business Folder & Shared Folder Item Reference (24/4/2019) // "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#drives('driveId')/items/$entity", // "@odata.etag": "\"{eTag},1\"", // "cTag": "\"c:{cTag},0\"", // "eTag": "\"{eTag},1\"", // "fileSystemInfo": { // "createdDateTime": "2019-04-17T04:00:43Z", // "lastModifiedDateTime": "2019-04-17T04:00:43Z" // }, // "folder": { // "childCount": 2 // }, // "id": "itemId", // "name": "shared_folder", // "parentReference": { // "driveId": "parentDriveId", // "driveType": "business", // "id": "parentId", // "path": "/drives/driveId/root:" // }, // "size": 0 // To evaluate a change received from OneDrive, this must be set correctly if (hasParentReferencePath(idDetails)) { // Path from OneDrive has a parentReference we can use log.vdebug("Item details returned contains parent reference path - potentially shared folder object"); syncFolderPath = idDetails["parentReference"]["path"].str; syncFolderChildPath = syncFolderPath ~ "/" ~ idDetails["name"].str ~ "/"; } else { // No parentReference, set these to blank log.vdebug("Item details returned no parent reference path"); syncFolderPath = ""; syncFolderChildPath = ""; } // Debug Output log.vdebug("Sync Folder Name: ", syncFolderName); log.vdebug("Sync Folder Parent Path: ", syncFolderPath); log.vdebug("Sync Folder Child Path: ", syncFolderChildPath); } } else { // Log that an invalid JSON object was returned log.vdebug("onedrive.getPathDetailsById call returned an invalid JSON Object"); } // Issue #658 // If we are using a sync_list file, using deltaLink will actually 'miss' changes (moves & deletes) on OneDrive as using sync_list discards changes // Use the performFullItemScan boolean to control whether we perform a full object scan of use the delta link for the root folder // When using --synchronize the normal process order is: // 1. Scan OneDrive for changes // 2. Scan local folder for changes // 3. Scan OneDrive for changes // When using sync_list and performing a full scan, what this means is a full scan is performed twice, which leads to massive processing & time overheads // Control this via performFullItemScan // Get the current delta link deltaLinkAvailable = itemdb.getDeltaLink(driveId, id); // if sync_list is not configured, syncListConfigured should be false log.vdebug("syncListConfigured = ", syncListConfigured); // oneDriveFullScanTrigger should be false unless set by actions on OneDrive and only if sync_list or skip_dir is used log.vdebug("oneDriveFullScanTrigger = ", oneDriveFullScanTrigger); // should only be set if 10th scan in monitor mode or as final true up sync in stand alone mode log.vdebug("performFullItemScan = ", performFullItemScan); // do we override performFullItemScan if it is currently false and oneDriveFullScanTrigger is true? if ((!performFullItemScan) && (oneDriveFullScanTrigger)) { // forcing a full scan earlier than potentially normal // oneDriveFullScanTrigger = true due to new folder creation request in a location that is now in-scope which was previously out of scope performFullItemScan = true; log.vdebug("overriding performFullItemScan as oneDriveFullScanTrigger was set"); } // depending on the scan type (--monitor or --synchronize) performFullItemScan is set depending on the number of sync passes performed (--monitor) or ALWAYS if just --synchronize is used if (!performFullItemScan){ // performFullItemScan == false // use delta link log.vdebug("performFullItemScan is false, using the deltaLink as per database entry"); if (deltaLinkAvailable == ""){ deltaLink = ""; log.vdebug("deltaLink was requested to be used, but contains no data - resulting API query will be treated as a full scan of OneDrive"); } else { deltaLink = deltaLinkAvailable; log.vdebug("deltaLink contains valid data - resulting API query will be treated as a delta scan of OneDrive"); } } else { // performFullItemScan == true // do not use delta-link deltaLink = ""; log.vdebug("performFullItemScan is true, not using the database deltaLink so that we query all objects on OneDrive to compare against all local objects"); } for (;;) { if (displayProcessingTime) { writeln("------------------------------------------------------------"); startBundleProcessingTime = Clock.currTime(); writeln("Start 'change|item' API Response Bundle Processing Time: ", startBundleProcessingTime); } // Due to differences in OneDrive API's between personal and business we need to get changes only from defaultRootId // If we used the 'id' passed in & when using --single-directory with a business account we get: // 'HTTP request returned status code 501 (Not Implemented): view.delta can only be called on the root.' // To view changes correctly, we need to use the correct path id for the request if (driveId == defaultDriveId) { // The drive id matches our users default drive id log.vdebug("Configuring 'idToQuery' as defaultRootId duplicate"); idToQuery = defaultRootId.dup; } else { // The drive id does not match our users default drive id // Potentially the 'path id' we are requesting the details of is a Shared Folder (remote item) // Use the 'id' that was passed in (folderId) log.vdebug("Configuring 'idToQuery' as 'id' duplicate"); idToQuery = id.dup; } // what path id are we going to query? log.vdebug("Path object to query configured as 'idToQuery' = ", idToQuery); long deltaChanges = 0; // What query do we use? // National Cloud Deployments do not support /delta as a query // https://docs.microsoft.com/en-us/graph/deployments#supported-features // Are we running against a National Cloud Deployments that does not support /delta if (nationalCloudDeployment) { // National Cloud Deployment that does not support /delta query // Have to query /children and build our own /delta response nationalCloudChildrenScan = true; log.vdebug("Using /children call to query drive for items to populate 'changes' and 'changesAvailable'"); // In a OneDrive Business Shared Folder scenario + nationalCloudDeployment, if ALL items are downgraded, then this leads to local file deletion // Downgrade ONLY files associated with this driveId and idToQuery log.vdebug("Downgrading all children for this driveId (" ~ driveId ~ ") and idToQuery (" ~ idToQuery ~ ") to an out-of-sync state"); // Before we get any data, flag any object in the database as out-of-sync for this driveID & ID auto drivePathChildren = itemdb.selectChildren(driveId, idToQuery); if (count(drivePathChildren) > 0) { // Children to process and flag as out-of-sync foreach (drivePathChild; drivePathChildren) { // Flag any object in the database as out-of-sync for this driveID & ID log.vdebug("Downgrading item as out-of-sync: ", drivePathChild.id); itemdb.downgradeSyncStatusFlag(drivePathChild.driveId, drivePathChild.id); } } // Build own 'changes' response to simulate a /delta response try { // we have to 'build' our own JSON response that looks like /delta changes = generateDeltaResponse(driveId, idToQuery); if (changes.type() == JSONType.object) { log.vdebug("Query 'changes = generateDeltaResponse(driveId, idToQuery)' performed successfully"); } } catch (OneDriveException e) { // OneDrive threw an error log.vdebug("------------------------------------------------------------------"); log.vdebug("Query Error: changes = generateDeltaResponse(driveId, idToQuery)"); log.vdebug("driveId: ", driveId); log.vdebug("idToQuery: ", idToQuery); // HTTP request returned status code 404 (Not Found) if (e.httpStatusCode == 404) { // Stop application log.log("\n\nOneDrive returned a 'HTTP 404 - Item not found'"); log.log("The item id to query was not found on OneDrive"); log.log("\nRemove your '", cfg.databaseFilePath, "' file and try to sync again\n"); return; } // HTTP request returned status code 429 (Too Many Requests) if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - attempting to query OneDrive drive items"); } // HTTP request returned status code 500 (Internal Server Error) if (e.httpStatusCode == 500) { // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } // HTTP request returned status code 504 (Gateway Timeout) or 429 retry if ((e.httpStatusCode == 429) || (e.httpStatusCode == 504)) { // If an error is returned when querying 'changes' and we recall the original function, we go into a never ending loop where the sync never ends // re-try the specific changes queries if (e.httpStatusCode == 504) { log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' when attempting to query OneDrive drive items - retrying applicable request"); log.vdebug("changes = generateDeltaResponse(driveId, idToQuery) previously threw an error - retrying"); // The server, while acting as a proxy, did not receive a timely response from the upstream server it needed to access in attempting to complete the request. log.vdebug("Thread sleeping for 30 seconds as the server did not receive a timely response from the upstream server it needed to access in attempting to complete the request"); Thread.sleep(dur!"seconds"(30)); log.vdebug("Retrying Query - using original deltaLink after delay"); } // re-try original request - retried for 429 and 504 try { log.vdebug("Retrying Query: changes = generateDeltaResponse(driveId, idToQuery)"); changes = generateDeltaResponse(driveId, idToQuery); log.vdebug("Query 'changes = generateDeltaResponse(driveId, idToQuery)' performed successfully on re-try"); } catch (OneDriveException e) { // display what the error is log.vdebug("Query Error: changes = generateDeltaResponse(driveId, idToQuery) on re-try after delay"); // error was not a 504 this time displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } else { // Default operation if not 404, 410, 429, 500 or 504 errors // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } } else { log.vdebug("Using /delta call to query drive for items to populate 'changes' and 'changesAvailable'"); // query for changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink); try { // Fetch the changes relative to the path id we want to query log.vdebug("Attempting query 'changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)'"); log.vdebug("driveId: ", driveId); log.vdebug("idToQuery: ", idToQuery); log.vdebug("Previous deltaLink: ", deltaLink); // changes with or without deltaLink changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink); if (changes.type() == JSONType.object) { log.vdebug("Query 'changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)' performed successfully"); log.vdebug("OneDrive API /delta response: ", changes); } } catch (OneDriveException e) { // OneDrive threw an error log.vdebug("------------------------------------------------------------------"); log.vdebug("Query Error: changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)"); // HTTP request returned status code 404 (Not Found) if (e.httpStatusCode == 404) { // Stop application log.log("\n\nOneDrive returned a 'HTTP 404 - Item not found'"); log.log("The item id to query was not found on OneDrive"); log.log("\nRemove your '", cfg.databaseFilePath, "' file and try to sync again\n"); return; } // HTTP request returned status code 410 (The requested resource is no longer available at the server) if (e.httpStatusCode == 410) { log.vdebug("Delta link expired for 'onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)', setting 'deltaLink = null'"); deltaLink = null; continue; } // HTTP request returned status code 429 (Too Many Requests) if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - attempting to query changes from OneDrive using deltaLink"); } // HTTP request returned status code 500 (Internal Server Error) if (e.httpStatusCode == 500) { // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } // HTTP request returned status code 504 (Gateway Timeout) or 429 retry if ((e.httpStatusCode == 429) || (e.httpStatusCode == 504)) { // If an error is returned when querying 'changes' and we recall the original function, we go into a never ending loop where the sync never ends // re-try the specific changes queries if (e.httpStatusCode == 504) { log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' when attempting to query for changes - retrying applicable request"); log.vdebug("changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink) previously threw an error - retrying"); // The server, while acting as a proxy, did not receive a timely response from the upstream server it needed to access in attempting to complete the request. log.vdebug("Thread sleeping for 30 seconds as the server did not receive a timely response from the upstream server it needed to access in attempting to complete the request"); Thread.sleep(dur!"seconds"(30)); log.vdebug("Retrying Query - using original deltaLink after delay"); } // re-try original request - retried for 429 and 504 try { log.vdebug("Retrying Query: changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)"); changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink); log.vdebug("Query 'changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)' performed successfully on re-try"); } catch (OneDriveException e) { // display what the error is log.vdebug("Query Error: changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink) on re-try after delay"); if (e.httpStatusCode == 504) { log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' when attempting to query for changes - retrying applicable request"); log.vdebug("changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink) previously threw an error - retrying with empty deltaLink"); try { // try query with empty deltaLink value deltaLink = null; changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink); log.vdebug("Query 'changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)' performed successfully on re-try"); } catch (OneDriveException e) { // Tried 3 times, give up displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } else { // error was not a 504 this time displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } } else { // Default operation if not 404, 410, 429, 500 or 504 errors // Issue #1174 handling where stored deltaLink is invalid if ((e.httpStatusCode == 400) && (deltaLink != "")) { // Set deltaLink to an empty entry so invalid URL is not reused string emptyDeltaLink = ""; itemdb.setDeltaLink(driveId, idToQuery, emptyDeltaLink); } // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } // query for changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable); try { // Fetch the changes relative to the path id we want to query log.vdebug("Attempting query 'changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)'"); log.vdebug("driveId: ", driveId); log.vdebug("idToQuery: ", idToQuery); log.vdebug("deltaLinkAvailable: ", deltaLinkAvailable); // changes based on deltaLink changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable); if (changesAvailable.type() == JSONType.object) { log.vdebug("Query 'changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)' performed successfully"); // are there any delta changes? if (("value" in changesAvailable) != null) { deltaChanges = count(changesAvailable["value"].array); log.vdebug("changesAvailable query reports that there are " , deltaChanges , " changes that need processing on OneDrive"); } } } catch (OneDriveException e) { // OneDrive threw an error log.vdebug("------------------------------------------------------------------"); log.vdebug("Query Error: changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)"); // HTTP request returned status code 404 (Not Found) if (e.httpStatusCode == 404) { // Stop application log.log("\n\nOneDrive returned a 'HTTP 404 - Item not found'"); log.log("The item id to query was not found on OneDrive"); log.log("\nRemove your '", cfg.databaseFilePath, "' file and try to sync again\n"); return; } // HTTP request returned status code 410 (The requested resource is no longer available at the server) if (e.httpStatusCode == 410) { log.vdebug("Delta link expired for 'onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)', setting 'deltaLinkAvailable = null'"); deltaLinkAvailable = null; continue; } // HTTP request returned status code 429 (Too Many Requests) if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - attempting to query changes from OneDrive using deltaLinkAvailable"); } // HTTP request returned status code 500 (Internal Server Error) if (e.httpStatusCode == 500) { // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } // HTTP request returned status code 504 (Gateway Timeout) or 429 retry if ((e.httpStatusCode == 429) || (e.httpStatusCode == 504)) { // If an error is returned when querying 'changes' and we recall the original function, we go into a never ending loop where the sync never ends // re-try the specific changes queries if (e.httpStatusCode == 504) { log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' when attempting to query for changes - retrying applicable request"); log.vdebug("changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable) previously threw an error - retrying"); // The server, while acting as a proxy, did not receive a timely response from the upstream server it needed to access in attempting to complete the request. log.vdebug("Thread sleeping for 30 seconds as the server did not receive a timely response from the upstream server it needed to access in attempting to complete the request"); Thread.sleep(dur!"seconds"(30)); log.vdebug("Retrying Query - using original deltaLinkAvailable after delay"); } // re-try original request - retried for 429 and 504 try { log.vdebug("Retrying Query: changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)"); changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable); log.vdebug("Query 'changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)' performed successfully on re-try"); if (changesAvailable.type() == JSONType.object) { // are there any delta changes? if (("value" in changesAvailable) != null) { deltaChanges = count(changesAvailable["value"].array); log.vdebug("changesAvailable query reports that there are " , deltaChanges , " changes that need processing on OneDrive"); } } } catch (OneDriveException e) { // display what the error is log.vdebug("Query Error: changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable) on re-try after delay"); if (e.httpStatusCode == 504) { log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' when attempting to query for changes - retrying applicable request"); log.vdebug("changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable) previously threw an error - retrying with empty deltaLinkAvailable"); // Increase delay and wait again before retry log.vdebug("Thread sleeping for 90 seconds as the server did not receive a timely response from the upstream server it needed to access in attempting to complete the request"); Thread.sleep(dur!"seconds"(90)); log.vdebug("Retrying Query - using a null deltaLinkAvailable after delay"); try { // try query with empty deltaLinkAvailable value deltaLinkAvailable = null; changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable); log.vdebug("Query 'changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)' performed successfully on re-try"); if (changesAvailable.type() == JSONType.object) { // are there any delta changes? if (("value" in changesAvailable) != null) { deltaChanges = count(changesAvailable["value"].array); log.vdebug("changesAvailable query reports that there are " , deltaChanges , " changes that need processing on OneDrive when using a null deltaLink value"); } } } catch (OneDriveException e) { // Tried 3 times, give up displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // OK .. if this was a 504, and running with --download-only & --cleanup-local-files // need to exit to preserve local data, otherwise potential files will be deleted that should not be deleted // leading to undesirable potential data loss scenarios if ((e.httpStatusCode == 504) && (cleanupLocalFiles)) { // log why we are exiting log.log("Exiting application due to OneDrive API Gateway Timeout & --download-only & --cleanup-local-files configured to preserve local data"); // Must exit here onedrive.shutdown(); exit(-1); } return; } } else { // error was not a 504 this time displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } } else { // Default operation if not 404, 410, 429, 500 or 504 errors // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } } // In some OneDrive Business scenarios, the shared folder /delta response lacks the 'root' drive details // When this occurs, this creates the following error: A database statement execution error occurred: foreign key constraint failed // Ensure we query independently the root details for this shared folder and ensure that it is added before we process the /delta response // However, if we are using a National Cloud Deployment, these deployments do not support /delta, so we generate a /delta response via generateDeltaResponse() // This specifically adds the root drive details to the self generated /delta response if ((!nationalCloudDeployment) && (driveId!= defaultDriveId) && (syncBusinessFolders)) { // fetch this driveId root details to ensure we add this to the database for this remote drive JSONValue rootData; try { rootData = onedrive.getDriveIdRoot(driveId); } catch (OneDriveException e) { log.vdebug("rootData = onedrive.getDriveIdRoot(driveId) generated a OneDriveException"); // HTTP request returned status code 504 (Gateway Timeout) or 429 retry if ((e.httpStatusCode == 429) || (e.httpStatusCode == 504)) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. if (e.httpStatusCode == 429) { log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - retrying applicable request"); handleOneDriveThrottleRequest(); } if (e.httpStatusCode == 504) { log.vdebug("Retrying original request that generated the HTTP 504 (Gateway Timeout) - retrying applicable request"); Thread.sleep(dur!"seconds"(30)); } // Retry original request by calling function again to avoid replicating any further error handling rootData = onedrive.getDriveIdRoot(driveId); } else { // There was a HTTP 5xx Server Side Error displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Must exit here onedrive.shutdown(); exit(-1); } } // apply this root drive data applyDifference(rootData, driveId, true); } // Process /delta response from OneDrive // is changes a valid JSON response if (changes.type() == JSONType.object) { // Are there any changes to process? if ((("value" in changes) != null) && ((deltaChanges > 0) || (oneDriveFullScanTrigger) || (nationalCloudChildrenScan) || (syncBusinessFolders) )) { auto nrChanges = count(changes["value"].array); auto changeCount = 0; // Display the number of changes or OneDrive objects we are processing // OneDrive ships 'changes' in ~200 bundles. We display that we are processing X number of objects // Do not display anything unless we are doing a verbose debug as due to #658 we are essentially doing a --resync each time when using sync_list // performance logging output if (displayProcessingTime) { writeln("Number of 'change|item' in this API Response Bundle from OneDrive to process: ", nrChanges); } // is nrChanges >= min_notify_changes (default of min_notify_changes = 5) if (nrChanges >= cfg.getValueLong("min_notify_changes")) { // nrChanges is >= than min_notify_changes // verbose log, no 'notify' .. it is over the top if (!syncListConfigured) { // sync_list is not being used - lets use the right messaging here if (oneDriveFullScanTrigger) { // full scan was triggered out of cycle log.vlog("Processing ", nrChanges, " OneDrive items to ensure consistent local state due to a full scan being triggered by actions on OneDrive"); // unset now the full scan trigger if set unsetOneDriveFullScanTrigger(); } else { // no sync_list in use, oneDriveFullScanTrigger not set via sync_list or skip_dir if (performFullItemScan){ // performFullItemScan was set log.vlog("Processing ", nrChanges, " OneDrive items to ensure consistent local state due to a full scan being requested"); } else { // default processing message log.vlog("Processing ", nrChanges, " OneDrive items to ensure consistent local state"); } } } else { // sync_list is being used - why are we going through the entire OneDrive contents? log.vlog("Processing ", nrChanges, " OneDrive items to ensure consistent local state due to sync_list being used"); } } else { // There are valid changes but less than the min_notify_changes configured threshold // We will only output the number of changes being processed to debug log if this is set to assist with debugging // As this is debug logging, messaging can be the same, regardless of sync_list being used or not // is performFullItemScan set due to a full scan required? // is oneDriveFullScanTrigger set due to a potentially out-of-scope item now being in-scope if ((performFullItemScan) || (oneDriveFullScanTrigger)) { // oneDriveFullScanTrigger should be false unless set by actions on OneDrive and only if sync_list or skip_dir is used log.vdebug("performFullItemScan or oneDriveFullScanTrigger = true"); // full scan was requested or triggered // use the right message if (oneDriveFullScanTrigger) { log.vlog("Processing ", nrChanges, " OneDrive items to ensure consistent local state due to a full scan being triggered by actions on OneDrive"); // unset now the full scan trigger if set unsetOneDriveFullScanTrigger(); } else { log.vlog("Processing ", nrChanges, " OneDrive items to ensure consistent local state due to a full scan being requested"); } } else { // standard message log.vlog("Number of items from OneDrive to process: ", nrChanges); } } // Add nrChanges to cumulativeOneDriveItemCount so we can detail how may items in total were processed cumulativeOneDriveItemCount = cumulativeOneDriveItemCount + nrChanges; foreach (item; changes["value"].array) { bool isRoot = false; string thisItemParentPath; string thisItemFullPath; changeCount++; // Change as reported by OneDrive log.vdebug("------------------------------------------------------------------"); log.vdebug("Processing change ", changeCount, " of ", nrChanges); log.vdebug("OneDrive Change: ", item); // Deleted items returned from onedrive.viewChangesByItemId or onedrive.viewChangesByDriveId (/delta) do not have a 'name' attribute // Thus we cannot name check for 'root' below on deleted items if(!isItemDeleted(item)){ // This is not a deleted item log.vdebug("Not a OneDrive deleted item change"); // Test is this is the OneDrive Users Root? // Debug output of change evaluation items log.vdebug("defaultRootId = ", defaultRootId); log.vdebug("'search id' = ", id); log.vdebug("id == defaultRootId = ", (id == defaultRootId)); log.vdebug("isItemRoot(item) = ", (isItemRoot(item))); log.vdebug("item['name'].str == 'root' = ", (item["name"].str == "root")); log.vdebug("singleDirectoryScope = ", (singleDirectoryScope)); // Use the global's as initialised via init() rather than performing unnecessary additional HTTPS calls // In a --single-directory scenario however, '(id == defaultRootId) = false' for root items if ( ((id == defaultRootId) || (singleDirectoryScope)) && (isItemRoot(item)) && (item["name"].str == "root")) { // This IS a OneDrive Root item log.vdebug("Change will flagged as a 'root' item change"); isRoot = true; } } // How do we handle this change? if (isRoot || !hasParentReferenceId(item) || isItemDeleted(item)){ // Is a root item, has no id in parentReference or is a OneDrive deleted item log.vdebug("isRoot = ", isRoot); log.vdebug("!hasParentReferenceId(item) = ", (!hasParentReferenceId(item))); log.vdebug("isItemDeleted(item) = ", (isItemDeleted(item))); log.vdebug("Handling change as 'root item', has no parent reference or is a deleted item"); applyDifference(item, driveId, isRoot); } else { // What is this item's parent path? if (hasParentReferencePath(item)) { thisItemParentPath = item["parentReference"]["path"].str; thisItemFullPath = thisItemParentPath ~ "/" ~ item["name"].str; } else { thisItemParentPath = ""; } // Special case handling flags bool singleDirectorySpecialCase = false; bool sharedFoldersSpecialCase = false; // Debug output of change evaluation items log.vdebug("'parentReference id' = ", item["parentReference"]["id"].str); log.vdebug("search criteria: syncFolderName = ", syncFolderName); log.vdebug("search criteria: syncFolderPath = ", syncFolderPath); log.vdebug("search criteria: syncFolderChildPath = ", syncFolderChildPath); log.vdebug("thisItemId = ", item["id"].str); log.vdebug("thisItemParentPath = ", thisItemParentPath); log.vdebug("thisItemFullPath = ", thisItemFullPath); log.vdebug("'item id' matches search 'id' = ", (item["id"].str == id)); log.vdebug("'parentReference id' matches search 'id' = ", (item["parentReference"]["id"].str == id)); log.vdebug("'thisItemParentPath' contains 'syncFolderChildPath' = ", (canFind(thisItemParentPath, syncFolderChildPath))); log.vdebug("'thisItemParentPath' contains search 'id' = ", (canFind(thisItemParentPath, id))); // Special case handling - --single-directory // If we are in a --single-directory sync scenario, and, the DB does not contain any parent details, or --single-directory is used with --resync // all changes will be discarded as 'Remote change discarded - not in --single-directory sync scope (not in DB)' even though, some of the changes // are actually valid and required as they are part of the parental path if (singleDirectoryScope){ // What is the full path for this item from OneDrive log.vdebug("'syncFolderChildPath' contains 'thisItemFullPath' = ", (canFind(syncFolderChildPath, thisItemFullPath))); if (canFind(syncFolderChildPath, thisItemFullPath)) { singleDirectorySpecialCase = true; } } // Special case handling - Shared Business Folders // - IF we are syncing shared folders, and the shared folder is not the 'top level' folder being shared out // canFind(thisItemParentPath, syncFolderChildPath) will never match: // Syncing this OneDrive Business Shared Folder: MyFolderName // OneDrive Business Shared By: Firstname Lastname (email@address) // Applying changes of Path ID: pathId // [DEBUG] Sync Folder Name: MyFolderName // [DEBUG] Sync Folder Path: /drives/driveId/root:/TopLevel/ABCD // [DEBUG] Sync Folder Child Path: /drives/driveId/root:/TopLevel/ABCD/MyFolderName/ // ... // [DEBUG] 'item id' matches search 'id' = false // [DEBUG] 'parentReference id' matches search 'id' = false // [DEBUG] 'thisItemParentPath' contains 'syncFolderChildPath' = false // [DEBUG] 'thisItemParentPath' contains search 'id' = false // [DEBUG] Change does not match any criteria to apply // Remote change discarded - not in business shared folders sync scope if ((!canFind(thisItemParentPath, syncFolderChildPath)) && (syncBusinessFolders)) { // Syncing Shared Business folders & we dont have a path match // is this a reverse path match? log.vdebug("'thisItemParentPath' contains 'syncFolderName' = ", (canFind(thisItemParentPath, syncFolderName))); if (canFind(thisItemParentPath, syncFolderName)) { sharedFoldersSpecialCase = true; } } // Check this item's path to see if this is a change on the path we want: // 1. 'item id' matches 'id' // 2. 'parentReference id' matches 'id' // 3. 'item path' contains 'syncFolderChildPath' // 4. 'item path' contains 'id' // 5. Special Case was triggered if ( (item["id"].str == id) || (item["parentReference"]["id"].str == id) || (canFind(thisItemParentPath, syncFolderChildPath)) || (canFind(thisItemParentPath, id)) || (singleDirectorySpecialCase) || (sharedFoldersSpecialCase) ){ // This is a change we want to apply if ((!singleDirectorySpecialCase) && (!sharedFoldersSpecialCase)) { log.vdebug("Change matches search criteria to apply"); } else { if (singleDirectorySpecialCase) log.vdebug("Change matches search criteria to apply - special case criteria - reverse path matching used (--single-directory)"); if (sharedFoldersSpecialCase) log.vdebug("Change matches search criteria to apply - special case criteria - reverse path matching used (Shared Business Folders)"); } // Apply OneDrive change applyDifference(item, driveId, isRoot); } else { // No item ID match or folder sync match log.vdebug("Change does not match any criteria to apply"); // Before discarding change - does this ID still exist on OneDrive - as in IS this // potentially a --single-directory sync and the user 'moved' the file out of the 'sync-dir' to another OneDrive folder // This is a corner edge case - https://github.com/skilion/onedrive/issues/341 // What is the original local path for this ID in the database? Does it match 'syncFolderChildPath' if (itemdb.idInLocalDatabase(driveId, item["id"].str)){ // item is in the database string originalLocalPath = computeItemPath(driveId, item["id"].str); if (canFind(originalLocalPath, syncFolderChildPath)){ JSONValue oneDriveMovedNotDeleted; try { oneDriveMovedNotDeleted = onedrive.getPathDetailsById(driveId, item["id"].str); } catch (OneDriveException e) { log.vdebug("oneDriveMovedNotDeleted = onedrive.getPathDetailsById(driveId, item['id'].str); generated a OneDriveException"); if (e.httpStatusCode == 404) { // No .. that ID is GONE log.vlog("Remote change discarded - item cannot be found"); } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry request after delay log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling oneDriveMovedNotDeleted = onedrive.getPathDetailsById(driveId, item['id'].str);"); try { oneDriveMovedNotDeleted = onedrive.getPathDetailsById(driveId, item["id"].str); } catch (OneDriveException e) { // A further error was generated // Rather than retry original function, retry the actual call and replicate error handling if (e.httpStatusCode == 404) { // No .. that ID is GONE log.vlog("Remote change discarded - item cannot be found"); } else { // not a 404 displayOneDriveErrorMessage(e.msg, getFunctionName!({})); } } } else { // not a 404 or a 429 displayOneDriveErrorMessage(e.msg, getFunctionName!({})); } } // Yes .. ID is still on OneDrive but elsewhere .... #341 edge case handling // This 'change' relates to an item that WAS in 'syncFolderChildPath' but is now // stored elsewhere on OneDrive - outside the path we are syncing from // Remove this item locally as it's local path is now obsolete idsToDelete ~= [driveId, item["id"].str]; } else { // out of scope for some other reason if (singleDirectoryScope){ log.vlog("Remote change discarded - not in --single-directory sync scope (in DB)"); } else { log.vlog("Remote change discarded - not in sync scope"); } log.vdebug("Remote change discarded: ", item); } } else { // item is not in the database if (singleDirectoryScope){ // We are syncing a single directory, so this is the reason why it is out of scope log.vlog("Remote change discarded - not in --single-directory sync scope (not in DB)"); log.vdebug("Remote change discarded: ", item); } else { // Not a single directory sync if (syncBusinessFolders) { // if we are syncing shared business folders, a 'change' may be out of scope as we are not syncing that 'folder' // but we are sent all changes from the 'parent root' as we cannot query the 'delta' for this folder // as that is a 501 error - not implemented log.vlog("Remote change discarded - not in business shared folders sync scope"); log.vdebug("Remote change discarded: ", item); } else { // out of scope for some other reason log.vlog("Remote change discarded - not in sync scope"); log.vdebug("Remote change discarded: ", item); } } } } } } } else { // No changes reported on OneDrive log.vdebug("OneDrive Reported no delta changes - Local path and OneDrive in-sync"); } // the response may contain either @odata.deltaLink or @odata.nextLink if ("@odata.deltaLink" in changes) { deltaLink = changes["@odata.deltaLink"].str; log.vdebug("Setting next deltaLink to (@odata.deltaLink): ", deltaLink); } if (deltaLink != "") { // we initialise deltaLink to a blank string - if it is blank, dont update the DB to be empty log.vdebug("Updating completed deltaLink in DB to: ", deltaLink); itemdb.setDeltaLink(driveId, id, deltaLink); } // Processing Timing for this bundle if (displayProcessingTime) { endBundleProcessingTime = Clock.currTime(); writeln("End 'change|item' API Response Bundle Processing Time: ", endBundleProcessingTime); writeln("Elapsed Processing Time: ", (endBundleProcessingTime - startBundleProcessingTime)); } if ("@odata.nextLink" in changes) { // Update deltaLink to next changeSet bundle deltaLink = changes["@odata.nextLink"].str; // Update deltaLinkAvailable to next changeSet bundle to quantify how many changes we have to process deltaLinkAvailable = changes["@odata.nextLink"].str; log.vdebug("Setting next deltaLink & deltaLinkAvailable to (@odata.nextLink): ", deltaLink); } else break; } else { // Log that an invalid JSON object was returned if ((driveId == defaultDriveId) || (!syncBusinessFolders)) { log.vdebug("onedrive.viewChangesByItemId call returned an invalid JSON Object"); } else { log.vdebug("onedrive.viewChangesByDriveId call returned an invalid JSON Object"); } } } // delete items in idsToDelete if (idsToDelete.length > 0) deleteItems(); // empty the skipped items skippedItems.length = 0; assumeSafeAppend(skippedItems); // Processing timing and metrics for everything that was processed if (displayProcessingTime) { endFunctionProcessingTime = Clock.currTime(); // complete the bundle output writeln("------------------------------------------------------------"); writeln("Start Function Processing Time: ", startFunctionProcessingTime); writeln("End Function Processing Time: ", endFunctionProcessingTime); writeln("Elapsed Function Processing Time: ", (endFunctionProcessingTime - startFunctionProcessingTime)); writeln("Total number of OneDrive items processed: ", cumulativeOneDriveItemCount); writeln("============================================================"); } } // process the change of a single DriveItem private void applyDifference(JSONValue driveItem, string driveId, bool isRoot) { // Format the OneDrive change into a consumable object for the database Item item = makeItem(driveItem); // Reset the malwareDetected flag for this item malwareDetected = false; // Reset the downloadFailed flag for this item downloadFailed = false; // Path we will be using string path = ""; if(isItemDeleted(driveItem)){ // Change is to delete an item log.vdebug("Remote deleted item"); } else { // Is the change from OneDrive a 'root' item // The change should be considered a 'root' item if: // 1. Contains a ["root"] element // 2. Has no ["parentReference"]["id"] ... #323 & #324 highlighted that this is false as some 'root' shared objects now can have an 'id' element .. OneDrive API change // 2. Has no ["parentReference"]["path"] // 3. Was detected by an input flag as to be handled as a root item regardless of actual status if (isItemRoot(driveItem) || !hasParentReferencePath(driveItem) || isRoot) { log.vdebug("Handing a OneDrive 'root' change"); item.parentId = null; // ensures that it has no parent item.driveId = driveId; // HACK: makeItem() cannot set the driveId property of the root log.vdebug("Update/Insert local database with item details"); itemdb.upsert(item); log.vdebug("item details: ", item); return; } } bool unwanted; // Check if the parent id is something we need to skip if (skippedItems.find(item.parentId).length != 0) { // Potentially need to flag as unwanted log.vdebug("Flagging as unwanted: find(item.parentId).length != 0"); unwanted = true; // Is this item id in the database? if (itemdb.idInLocalDatabase(item.driveId, item.id)){ // item exists in database, most likely moved out of scope for current client configuration log.vdebug("This item was previously synced / seen by the client"); if (("name" in driveItem["parentReference"]) != null) { // How is this out of scope? // is sync_list configured if (syncListConfigured) { // sync_list configured and in use if (selectiveSync.isPathExcludedViaSyncList(driveItem["parentReference"]["name"].str)) { // Previously synced item is now out of scope as it has been moved out of what is included in sync_list log.vdebug("This previously synced item is now excluded from being synced due to sync_list exclusion"); } } // flag to delete local file as it now is no longer in sync with OneDrive log.vdebug("Flagging to delete item locally"); idsToDelete ~= [item.driveId, item.id]; } } } // Check if this is excluded by config option: skip_dir if (!unwanted) { // Only check path if config is != "" if (cfg.getValueString("skip_dir") != "") { // Is the item a folder and not a deleted item? if ((isItemFolder(driveItem)) && (!isItemDeleted(driveItem))) { // work out the 'snippet' path where this folder would be created string simplePathToCheck = ""; string complexPathToCheck = ""; string matchDisplay = ""; if (hasParentReference(driveItem)) { // we need to workout the FULL path for this item string parentDriveId = driveItem["parentReference"]["driveId"].str; string parentItem = driveItem["parentReference"]["id"].str; // simple path if (("name" in driveItem["parentReference"]) != null) { simplePathToCheck = driveItem["parentReference"]["name"].str ~ "/" ~ driveItem["name"].str; } else { simplePathToCheck = driveItem["name"].str; } log.vdebug("skip_dir path to check (simple): ", simplePathToCheck); // complex path if (itemdb.idInLocalDatabase(parentDriveId, parentItem)){ // build up complexPathToCheck complexPathToCheck = computeItemPath(parentDriveId, parentItem) ~ "/" ~ driveItem["name"].str; complexPathToCheck = buildNormalizedPath(complexPathToCheck); } else { log.vdebug("Parent details not in database - unable to compute complex path to check"); } log.vdebug("skip_dir path to check (complex): ", complexPathToCheck); } else { simplePathToCheck = driveItem["name"].str; } // If 'simplePathToCheck' or 'complexPathToCheck' is of the following format: root:/folder // then isDirNameExcluded matching will not work // Clean up 'root:' if present if (startsWith(simplePathToCheck, "root:")){ log.vdebug("Updating simplePathToCheck to remove 'root:'"); simplePathToCheck = strip(simplePathToCheck, "root:"); } if (startsWith(complexPathToCheck, "root:")){ log.vdebug("Updating complexPathToCheck to remove 'root:'"); complexPathToCheck = strip(complexPathToCheck, "root:"); } // OK .. what checks are we doing? if ((simplePathToCheck != "") && (complexPathToCheck == "")) { // just a simple check log.vdebug("Performing a simple check only"); unwanted = selectiveSync.isDirNameExcluded(simplePathToCheck); } else { // simple and complex log.vdebug("Performing a simple & complex path match if required"); // simple first unwanted = selectiveSync.isDirNameExcluded(simplePathToCheck); matchDisplay = simplePathToCheck; if (!unwanted) { log.vdebug("Simple match was false, attempting complex match"); // simple didnt match, perform a complex check unwanted = selectiveSync.isDirNameExcluded(complexPathToCheck); matchDisplay = complexPathToCheck; } } log.vdebug("Result: ", unwanted); if (unwanted) log.vlog("Skipping item - excluded by skip_dir config: ", matchDisplay); } } } // Check if this is excluded by config option: skip_file if (!unwanted) { // Is the item a file and not a deleted item? if ((isItemFile(driveItem)) && (!isItemDeleted(driveItem))) { // skip_file can contain 4 types of entries: // - wildcard - *.txt // - text + wildcard - name*.txt // - full path + combination of any above two - /path/name*.txt // - full path to file - /path/to/file.txt // is the parent id in the database? if (itemdb.idInLocalDatabase(item.driveId, item.parentId)){ // Compute this item path & need the full path for this file path = computeItemPath(item.driveId, item.parentId) ~ "/" ~ item.name; // The path that needs to be checked needs to include the '/' // This due to if the user has specified in skip_file an exclusive path: '/path/file' - that is what must be matched // However, as 'path' used throughout, use a temp variable with this modification so that we use the temp variable for exclusion checks string exclusionTestPath = ""; if (!startsWith(path, "/")){ // Add '/' to the path exclusionTestPath = '/' ~ path; } log.vdebug("skip_file item to check: ", exclusionTestPath); unwanted = selectiveSync.isFileNameExcluded(exclusionTestPath); log.vdebug("Result: ", unwanted); if (unwanted) log.vlog("Skipping item - excluded by skip_file config: ", item.name); } else { // parent id is not in the database unwanted = true; log.vlog("Skipping file - parent path not present in local database"); } } } // check the item type if (!unwanted) { if (isItemFile(driveItem)) { log.vdebug("The item we are syncing is a file"); } else if (isItemFolder(driveItem)) { log.vdebug("The item we are syncing is a folder"); } else if (isItemRemote(driveItem)) { log.vdebug("The item we are syncing is a remote item"); assert(isItemFolder(driveItem["remoteItem"]), "The remote item is not a folder"); } else { // Why was this unwanted? if (path.empty) { // Compute this item path & need the full path for this file path = computeItemPath(item.driveId, item.parentId) ~ "/" ~ item.name; } // Microsoft OneNote container objects present as neither folder or file but has file size if ((!isItemFile(driveItem)) && (!isItemFolder(driveItem)) && (hasFileSize(driveItem))) { // Log that this was skipped as this was a Microsoft OneNote item and unsupported log.vlog("The Microsoft OneNote Notebook '", path, "' is not supported by this client"); } else { // Log that this item was skipped as unsupported log.vlog("The OneDrive item '", path, "' is not supported by this client"); } unwanted = true; log.vdebug("Flagging as unwanted: item type is not supported"); } } // Check if this is included by use of sync_list if (!unwanted) { // Is the item parent in the local database? if (itemdb.idInLocalDatabase(item.driveId, item.parentId)){ // parent item is in the local database // compute the item path if empty if (path.empty) { path = computeItemPath(item.driveId, item.parentId) ~ "/" ~ item.name; } // what path are we checking log.vdebug("sync_list item to check: ", path); // Unfortunatly there is no avoiding this call to check if the path is excluded|included via sync_list if (selectiveSync.isPathExcludedViaSyncList(path)) { // selective sync advised to skip, however is this a file and are we configured to upload / download files in the root? if ((isItemFile(driveItem)) && (cfg.getValueBool("sync_root_files")) && (rootName(path) == "") ) { // This is a file // We are configured to sync all files in the root // This is a file in the logical root unwanted = false; } else { // path is unwanted unwanted = true; log.vlog("Skipping item - excluded by sync_list config: ", path); // flagging to skip this file now, but does this exist in the DB thus needs to be removed / deleted? if (itemdb.idInLocalDatabase(item.driveId, item.id)){ log.vlog("Flagging item for local delete as item exists in database: ", path); // flag to delete idsToDelete ~= [item.driveId, item.id]; } } } } else { // Parent not in the database // Is the parent a 'folder' from another user? ie - is this a 'shared folder' that has been shared with us? if (defaultDriveId == item.driveId){ // Flagging as unwanted log.vdebug("Flagging as unwanted: item.driveId (", item.driveId,"), item.parentId (", item.parentId,") not in local database"); unwanted = true; } else { // Edge case as the parent (from another users OneDrive account) will never be in the database log.vdebug("The reported parentId is not in the database. This potentially is a shared folder as 'item.driveId' != 'defaultDriveId'. Relevant Details: item.driveId (", item.driveId,"), item.parentId (", item.parentId,")"); // If we are syncing OneDrive Business Shared Folders, a 'folder' shared with us, has a 'parent' that is not shared with us hence the above message // What we need to do is query the DB for this 'item.driveId' and use the response from the DB to set the 'item.parentId' for this new item we are trying to add to the database if (syncBusinessFolders) { foreach(dbItem; itemdb.selectByDriveId(item.driveId)) { if (dbItem.name == "root") { // Ensure that this item uses the root id as parent log.vdebug("Falsifying item.parentId to be ", dbItem.id); item.parentId = dbItem.id; } } } else { // Ensure that this item has no parent log.vdebug("Setting item.parentId to be null"); item.parentId = null; } log.vdebug("Update/Insert local database with item details"); itemdb.upsert(item); log.vdebug("item details: ", item); return; } } } // skip downloading dot files if configured if (cfg.getValueBool("skip_dotfiles")) { if (isDotFile(path)) { log.vlog("Skipping item - .file or .folder: ", path); unwanted = true; } } // skip unwanted items early if (unwanted) { log.vdebug("Skipping OneDrive change as this is determined to be unwanted"); skippedItems ~= item.id; return; } // check if the item has been seen before Item oldItem; bool cached = itemdb.selectById(item.driveId, item.id, oldItem); // check if the item is going to be deleted if (isItemDeleted(driveItem)) { // item.name is not available, so we get a bunch of meaningless log output // Item name we will attempt to delete will be printed out later if (cached) { // flag to delete log.vdebug("Flagging item for deletion: ", item); idsToDelete ~= [item.driveId, item.id]; } else { // flag to ignore log.vdebug("Flagging item to skip: ", item); skippedItems ~= item.id; } return; } // rename the local item if it is unsynced and there is a new version of it on OneDrive string oldPath; if (cached && item.eTag != oldItem.eTag) { // Is the item in the local database if (itemdb.idInLocalDatabase(item.driveId, item.id)){ log.vdebug("OneDrive item ID is present in local database"); // Compute this item path oldPath = computeItemPath(item.driveId, item.id); // Query DB for existing local item in specified path string itemSource = "database"; if (!isItemSynced(oldItem, oldPath, itemSource)) { if (exists(oldPath)) { // Is the local file technically 'newer' based on UTC timestamp? SysTime localModifiedTime = timeLastModified(oldPath).toUTC(); localModifiedTime.fracSecs = Duration.zero; item.mtime.fracSecs = Duration.zero; // debug the output of time comparison log.vdebug("localModifiedTime (local file): ", localModifiedTime); log.vdebug("item.mtime (OneDrive item): ", item.mtime); // Compare file on disk modified time with modified time provided by OneDrive API if (localModifiedTime >= item.mtime) { // local file is newer or has the same time than the item on OneDrive log.vdebug("Skipping OneDrive change as this is determined to be unwanted due to local item modified time being newer or equal to item modified time from OneDrive"); // no local rename // no download needed if (localModifiedTime == item.mtime) { log.vlog("Local item modified time is equal to OneDrive item modified time based on UTC time conversion - keeping local item"); } else { log.vlog("Local item modified time is newer than OneDrive item modified time based on UTC time conversion - keeping local item"); } skippedItems ~= item.id; return; } else { // remote file is newer than local item log.vlog("Remote item modified time is newer based on UTC time conversion"); // correct message, remote item is newer auto ext = extension(oldPath); auto newPath = path.chomp(ext) ~ "-" ~ deviceName ~ ext; // has the user configured to IGNORE local data protection rules? if (bypassDataPreservation) { // The user has configured to ignore data safety checks and overwrite local data rather than preserve & rename log.vlog("WARNING: Local Data Protection has been disabled. You may experience data loss on this file: ", oldPath); } else { // local data protection is configured, renaming local file log.vlog("The local item is out-of-sync with OneDrive, renaming to preserve existing file and prevent data loss: ", oldPath, " -> ", newPath); // perform the rename action if (!dryRun) { safeRename(oldPath); } else { // Expectation here is that there is a new file locally (newPath) however as we don't create this, the "new file" will not be uploaded as it does not exist log.vdebug("DRY-RUN: Skipping local file rename"); } } } } cached = false; } } } // update the item if (cached) { // the item is in the items.sqlite3 database log.vdebug("OneDrive change is an update to an existing local item"); applyChangedItem(oldItem, oldPath, item, path); } else { log.vdebug("OneDrive change is potentially a new local item"); // Check if file should be skipped based on size limit if (isItemFile(driveItem)) { if (cfg.getValueLong("skip_size") != 0) { if (driveItem["size"].integer >= this.newSizeLimit) { log.vlog("Skipping item - excluded by skip_size config: ", item.name, " (", driveItem["size"].integer/2^^20, " MB)"); return; } } } // apply this new item applyNewItem(item, path); } if ((malwareDetected == false) && (downloadFailed == false)){ // save the item in the db // if the file was detected as malware and NOT downloaded, we dont want to falsify the DB as downloading it as otherwise the next pass will think it was deleted, thus delete the remote item // Likewise if the download failed, we dont want to falsify the DB as downloading it as otherwise the next pass will think it was deleted, thus delete the remote item if (cached) { // the item is in the items.sqlite3 database // Do we need to update the database with the details that were provided by the OneDrive API? // Is the last modified timestamp in the DB the same as the API data? SysTime localModifiedTime = oldItem.mtime; localModifiedTime.fracSecs = Duration.zero; SysTime remoteModifiedTime = item.mtime; remoteModifiedTime.fracSecs = Duration.zero; // If the timestamp is different, or we are running on a National Cloud Deployment that does not support /delta queries - we have to update the DB with the details from OneDrive // Unfortunatly because of the consequence of Nataional Cloud Deployments not supporting /delta queries, the application uses the local database to flag what is out-of-date / track changes // This means that the constant disk writing to the database fix implemented with https://github.com/abraunegg/onedrive/pull/2004 cannot be utilised when using Nataional Cloud Deployments // as all records are touched / updated when performing the OneDrive sync operations. The only way to change this, is for Microsoft to support /delta queries for Nataional Cloud Deployments if ((localModifiedTime != remoteModifiedTime) || (nationalCloudDeployment)) { // Database update needed for this item because our local record is out-of-date log.vdebug("Updating local database with item details from OneDrive as local record needs to be updated"); itemdb.update(item); } } else { // item is not in the items.sqlite3 database log.vdebug("Inserting new item details to local database"); itemdb.insert(item); } // What was the item that was saved log.vdebug("item details: ", item); } else { // flag was tripped, which was it if (downloadFailed) { log.vdebug("Download or creation of local directory failed"); } if (malwareDetected) { log.vdebug("OneDrive reported that file contained malware"); } } } // download an item that was not synced before private void applyNewItem(const ref Item item, const(string) path) { // Test for the local path existence if (exists(path)) { // Issue #2209 fix - test if path is a bad symbolic link if (isSymlink(path)) { log.vdebug("Path on local disk is a symbolic link ........"); if (!exists(readLink(path))) { // reading the symbolic link failed log.vdebug("Reading the symbolic link target failed ........ "); log.logAndNotify("Skipping item - invalid symbolic link: ", path); return; } } // path exists locally, is not a bad symbolic link // Query DB for new remote item in specified path string itemSource = "remote"; if (isItemSynced(item, path, itemSource)) { // file details from OneDrive and local file details in database are in-sync log.vdebug("The item to sync is already present on the local file system and is in-sync with the local database"); return; } else { // file is not in sync with the database // is the local file technically 'newer' based on UTC timestamp? SysTime localModifiedTime = timeLastModified(path).toUTC(); SysTime itemModifiedTime = item.mtime; // HACK: reduce time resolution to seconds before comparing localModifiedTime.fracSecs = Duration.zero; itemModifiedTime.fracSecs = Duration.zero; // is the local modified time greater than that from OneDrive? if (localModifiedTime > itemModifiedTime) { // local file is newer than item on OneDrive based on file modified time // Is this item id in the database? if (itemdb.idInLocalDatabase(item.driveId, item.id)){ // item id is in the database // no local rename // no download needed log.vlog("Local item modified time is newer based on UTC time conversion - keeping local item as this exists in the local database"); log.vdebug("Skipping OneDrive change as this is determined to be unwanted due to local item modified time being newer than OneDrive item and present in the sqlite database"); return; } else { // item id is not in the database .. maybe a --resync ? // Should this 'download' be skipped? // Do we need to check for .nosync? Only if --check-for-nosync was passed in if (cfg.getValueBool("check_nosync")) { // need the parent path for this object string parentPath = dirName(path); if (exists(parentPath ~ "/.nosync")) { log.vlog("Skipping downloading item - .nosync found in parent folder & --check-for-nosync is enabled: ", path); // flag that this download failed, otherwise the 'item' is added to the database - then, as not present on the local disk, would get deleted from OneDrive downloadFailed = true; // clean up this partial file, otherwise every sync we will get theis warning log.vlog("Removing previous partial file download due to .nosync found in parent folder & --check-for-nosync is enabled"); safeRemove(path); return; } } // file exists locally but is not in the sqlite database - maybe a failed download? log.vlog("Local item does not exist in local database - replacing with file from OneDrive - failed download?"); // in a --resync scenario or if items.sqlite3 was deleted before startup we have zero way of knowing IF the local file is meant to be the right file // we have passed the following checks: // 1. file exists locally // 2. local modified time > remote modified time // 3. id is not in the database auto ext = extension(path); auto newPath = path.chomp(ext) ~ "-" ~ deviceName ~ ext; // has the user configured to IGNORE local data protection rules? if (bypassDataPreservation) { // The user has configured to ignore data safety checks and overwrite local data rather than preserve & rename log.vlog("WARNING: Local Data Protection has been disabled. You may experience data loss on this file: ", path); } else { // local data protection is configured, renaming local file log.vlog("The local item is out-of-sync with OneDrive, renaming to preserve existing file and prevent local data loss: ", path, " -> ", newPath); // perform the rename action of the local file if (!dryRun) { safeRename(path); } else { // Expectation here is that there is a new file locally (newPath) however as we don't create this, the "new file" will not be uploaded as it does not exist log.vdebug("DRY-RUN: Skipping local file rename"); } } } } else { // remote file is newer than local item log.vlog("Remote item modified time is newer based on UTC time conversion"); // correct message, remote item is newer log.vdebug("localModifiedTime (local file): ", localModifiedTime); log.vdebug("itemModifiedTime (OneDrive item): ", itemModifiedTime); auto ext = extension(path); auto newPath = path.chomp(ext) ~ "-" ~ deviceName ~ ext; // has the user configured to IGNORE local data protection rules? if (bypassDataPreservation) { // The user has configured to ignore data safety checks and overwrite local data rather than preserve & rename log.vlog("WARNING: Local Data Protection has been disabled. You may experience data loss on this file: ", path); } else { // local data protection is configured, renaming local file log.vlog("The local item is out-of-sync with OneDrive, renaming to preserve existing file and prevent data loss: ", path, " -> ", newPath); // perform the rename action of the local file if (!dryRun) { safeRename(path); } else { // Expectation here is that there is a new file locally (newPath) however as we don't create this, the "new file" will not be uploaded as it does not exist log.vdebug("DRY-RUN: Skipping local file rename"); } } } } } else { // Path does not exist locally - this will be a new file download or folder creation // Should this 'download' be skipped due to 'skip_dir' directive if (cfg.getValueString("skip_dir") != "") { string pathToCheck; // does the path start with '/'? if (!startsWith(path, "/")){ // path does not start with '/', but we need to check skip_dir entries with and without '/' // so always make sure we are checking a path with '/' // If this is a file, we need to check the parent path if (item.type == ItemType.file) { // use parent path and add '/' pathToCheck = '/' ~ dirName(path); } else { // use path and add '/' pathToCheck = '/' ~ path; } } // perform the check if (selectiveSync.isDirNameExcluded(pathToCheck)) { // this path should be skipped if (item.type == ItemType.file) { log.vlog("Skipping item - file path is excluded by skip_dir config: ", path); } else { log.vlog("Skipping item - excluded by skip_dir config: ", path); } // flag that this download failed, otherwise the 'item' is added to the database - then, as not present on the local disk, would get deleted from OneDrive downloadFailed = true; return; } } // Should this 'download' be skipped due to nosync directive? // Do we need to check for .nosync? Only if --check-for-nosync was passed in if (cfg.getValueBool("check_nosync")) { // need the parent path for this object string parentPath = dirName(path); if (exists(parentPath ~ "/.nosync")) { log.vlog("Skipping downloading item - .nosync found in parent folder & --check-for-nosync is enabled: ", path); // flag that this download failed, otherwise the 'item' is added to the database - then, as not present on the local disk, would get deleted from OneDrive downloadFailed = true; return; } } } // how to handle this item? final switch (item.type) { case ItemType.file: downloadFileItem(item, path); if (dryRun) { // we dont download the file, but we need to track that we 'faked it' idsFaked ~= [item.driveId, item.id]; } break; case ItemType.dir: case ItemType.remote: log.log("Creating local directory: ", path); // Issue #658 handling - is sync_list in use? if (syncListConfigured) { // sync_list configured and in use // path to create was previously checked if this should be included / excluded. No need to check again. log.vdebug("Issue #658 handling"); setOneDriveFullScanTrigger(); } // Issue #865 handling - is skip_dir in use? if (cfg.getValueString("skip_dir") != "") { // we have some entries in skip_dir // path to create was previously checked if this should be included / excluded. No need to check again. log.vdebug("Issue #865 handling"); setOneDriveFullScanTrigger(); } if (!dryRun) { try { // Does the path exist locally? if (!exists(path)) { // Create the new directory log.vdebug("Requested path does not exist, creating directory structure: ", path); mkdirRecurse(path); // Configure the applicable permissions for the folder log.vdebug("Setting directory permissions for: ", path); path.setAttributes(cfg.returnRequiredDirectoryPermisions()); // Update the time of the folder to match the last modified time as is provided by OneDrive // If there are any files then downloaded into this folder, the last modified time will get // updated by the local Operating System with the latest timestamp - as this is normal operation // as the directory has been modified log.vdebug("Setting directory lastModifiedDateTime for: ", path , " to ", item.mtime); setTimes(path, item.mtime, item.mtime); } } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); // flag that this failed downloadFailed = true; return; } } else { // we dont create the directory, but we need to track that we 'faked it' idsFaked ~= [item.driveId, item.id]; } break; } } // update a local item // the local item is assumed to be in sync with the local db private void applyChangedItem(Item oldItem, string oldPath, Item newItem, string newPath) { assert(oldItem.driveId == newItem.driveId); assert(oldItem.id == newItem.id); assert(oldItem.type == newItem.type); assert(oldItem.remoteDriveId == newItem.remoteDriveId); assert(oldItem.remoteId == newItem.remoteId); if (oldItem.eTag != newItem.eTag) { // handle changed name/path if (oldPath != newPath) { log.log("Moving ", oldPath, " to ", newPath); if (exists(newPath)) { Item localNewItem; if (itemdb.selectByPath(newPath, defaultDriveId, localNewItem)) { // Query DB for new local item in specified path string itemSource = "database"; if (isItemSynced(localNewItem, newPath, itemSource)) { log.vlog("Destination is in sync and will be overwritten"); } else { // TODO: force remote sync by deleting local item log.vlog("The destination is occupied, renaming the conflicting file..."); if (!dryRun) { safeRename(newPath); } } } else { // to be overwritten item is not already in the itemdb, so it should // be synced. Do a safe rename here, too. // TODO: force remote sync by deleting local item log.vlog("The destination is occupied by new file, renaming the conflicting file..."); if (!dryRun) { safeRename(newPath); } } } // try and rename path, catch exception try { log.vdebug("Calling rename(oldPath, newPath)"); if (!dryRun) { // rename physical path on disk rename(oldPath, newPath); } else { // track this as a faked id item idsFaked ~= [newItem.driveId, newItem.id]; // we also need to track that we did not rename this path pathsRenamed ~= [oldPath]; } } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); } } // handle changed content and mtime // HACK: use mtime+hash instead of cTag because of https://github.com/OneDrive/onedrive-api-docs/issues/765 if (newItem.type == ItemType.file && oldItem.mtime != newItem.mtime && !testFileHash(newPath, newItem)) { downloadFileItem(newItem, newPath); } // handle changed time if (newItem.type == ItemType.file && oldItem.mtime != newItem.mtime) { try { log.vdebug("Calling setTimes() for this file: ", newPath); setTimes(newPath, newItem.mtime, newItem.mtime); } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); } } } } // downloads a File resource private void downloadFileItem(const ref Item item, const(string) path) { static import std.exception; assert(item.type == ItemType.file); write("Downloading file ", path, " ... "); JSONValue fileDetails; try { fileDetails = onedrive.getFileDetails(item.driveId, item.id); } catch (OneDriveException e) { log.error("ERROR: Query of OneDrive for file details failed"); if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged downloadFailed = true; return; } } // fileDetails has to be a valid JSON object if (fileDetails.type() == JSONType.object){ if (isMalware(fileDetails)){ // OneDrive reports that this file is malware log.error("ERROR: MALWARE DETECTED IN FILE - DOWNLOAD SKIPPED"); // set global flag malwareDetected = true; return; } } else { // Issue #550 handling log.error("ERROR: Query of OneDrive for file details failed"); log.vdebug("onedrive.getFileDetails call returned an invalid JSON Object"); // We want to return, cant download downloadFailed = true; return; } if (!dryRun) { ulong onlineFileSize = 0; string OneDriveFileHash; // fileDetails should be a valid JSON due to prior check if (hasFileSize(fileDetails)) { // Use the configured onlineFileSize as reported by OneDrive onlineFileSize = fileDetails["size"].integer; } else { // filesize missing log.vdebug("WARNING: fileDetails['size'] is missing"); } if (hasHashes(fileDetails)) { // File details returned hash details // QuickXorHash if (hasQuickXorHash(fileDetails)) { // Use the configured quickXorHash as reported by OneDrive if (fileDetails["file"]["hashes"]["quickXorHash"].str != "") { OneDriveFileHash = fileDetails["file"]["hashes"]["quickXorHash"].str; } } else { // Check for sha256Hash as quickXorHash did not exist if (hasSHA256Hash(fileDetails)) { // Use the configured sha256Hash as reported by OneDrive if (fileDetails["file"]["hashes"]["sha256Hash"].str != "") { OneDriveFileHash = fileDetails["file"]["hashes"]["sha256Hash"].str; } } } } else { // file hash data missing log.vdebug("WARNING: fileDetails['file']['hashes'] is missing - unable to compare file hash after download"); } // Is there enough free space locally to download the file // - We can use '.' here as we change the current working directory to the configured 'sync_dir' ulong localActualFreeSpace = to!ulong(getAvailableDiskSpace(".")); // So that we are not responsible in making the disk 100% full if we can download the file, compare the current available space against the reservation set and file size // The reservation value is user configurable in the config file, 50MB by default ulong freeSpaceReservation = cfg.getValueLong("space_reservation"); // debug output log.vdebug("Local Disk Space Actual: ", localActualFreeSpace); log.vdebug("Free Space Reservation: ", freeSpaceReservation); log.vdebug("File Size to Download: ", onlineFileSize); // calculate if we can download file if ((localActualFreeSpace < freeSpaceReservation) || (onlineFileSize > localActualFreeSpace)) { // localActualFreeSpace is less than freeSpaceReservation .. insufficient free space // onlineFileSize is greater than localActualFreeSpace .. insufficient free space writeln("failed!"); log.log("Insufficient local disk space to download file"); downloadFailed = true; return; } // Attempt to download the file try { onedrive.downloadById(item.driveId, item.id, path, onlineFileSize); } catch (OneDriveException e) { log.vdebug("onedrive.downloadById(item.driveId, item.id, path, onlineFileSize); generated a OneDriveException"); // 408 = Request Time Out // 429 = Too Many Requests - need to delay if (e.httpStatusCode == 408) { // 408 error handling - request time out // https://github.com/abraunegg/onedrive/issues/694 // Back off & retry with incremental delay int retryCount = 10; int retryAttempts = 1; int backoffInterval = 2; while (retryAttempts < retryCount){ // retry in 2,4,8,16,32,64,128,256,512,1024 seconds Thread.sleep(dur!"seconds"(retryAttempts*backoffInterval)); try { onedrive.downloadById(item.driveId, item.id, path, onlineFileSize); // successful download retryAttempts = retryCount; } catch (OneDriveException e) { log.vdebug("onedrive.downloadById(item.driveId, item.id, path, onlineFileSize); generated a OneDriveException"); if ((e.httpStatusCode == 429) || (e.httpStatusCode == 408)) { // If another 408 .. if (e.httpStatusCode == 408) { // Increment & loop around log.vdebug("HTTP 408 generated - incrementing retryAttempts"); retryAttempts++; } // If a 429 .. if (e.httpStatusCode == 429) { // Increment & loop around handleOneDriveThrottleRequest(); log.vdebug("HTTP 429 generated - incrementing retryAttempts"); retryAttempts++; } } else { displayOneDriveErrorMessage(e.msg, getFunctionName!({})); } } } } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests) // https://github.com/abraunegg/onedrive/issues/133 int retryCount = 10; int retryAttempts = 1; while (retryAttempts < retryCount){ // retry after waiting the timeout value from the 429 HTTP response header Retry-After handleOneDriveThrottleRequest(); try { onedrive.downloadById(item.driveId, item.id, path, onlineFileSize); // successful download retryAttempts = retryCount; } catch (OneDriveException e) { log.vdebug("onedrive.downloadById(item.driveId, item.id, path, onlineFileSize); generated a OneDriveException"); if ((e.httpStatusCode == 429) || (e.httpStatusCode == 408)) { // If another 408 .. if (e.httpStatusCode == 408) { // Increment & loop around log.vdebug("HTTP 408 generated - incrementing retryAttempts"); retryAttempts++; } // If a 429 .. if (e.httpStatusCode == 429) { // Increment & loop around handleOneDriveThrottleRequest(); log.vdebug("HTTP 429 generated - incrementing retryAttempts"); retryAttempts++; } } else { displayOneDriveErrorMessage(e.msg, getFunctionName!({})); } } } } } catch (FileException e) { // There was a file system error // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); downloadFailed = true; return; } catch (std.exception.ErrnoException e) { // There was a file system error // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); downloadFailed = true; return; } // file has to have downloaded in order to set the times / data for the file if (exists(path)) { // When downloading some files from SharePoint, the OneDrive API reports one file size, but the SharePoint HTTP Server sends a totally different byte count // for the same file // we have implemented --disable-download-validation to disable these checks if (!disableDownloadValidation) { // A 'file' was downloaded - does what we downloaded = reported onlineFileSize or if there is some sort of funky local disk compression going on // does the file hash OneDrive reports match what we have locally? string quickXorHash = computeQuickXorHash(path); // Compute the local file size ulong localFileSize = getSize(path); if ((localFileSize == onlineFileSize) || (OneDriveFileHash == quickXorHash)) { // downloaded matches either size or hash log.vdebug("Downloaded file matches reported size and or reported file hash"); try { log.vdebug("Calling setTimes() for this file: ", path); setTimes(path, item.mtime, item.mtime); } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); } } else { // size error? if (localFileSize != onlineFileSize) { // downloaded file size does not match log.vdebug("Actual file size on disk: ", localFileSize); log.vdebug("OneDrive API reported size: ", onlineFileSize); log.error("ERROR: File download size mis-match. Increase logging verbosity to determine why."); } // hash error? if (OneDriveFileHash != quickXorHash) { // downloaded file hash does not match log.vdebug("Actual local file hash: ", quickXorHash); log.vdebug("OneDrive API reported hash: ", OneDriveFileHash); log.error("ERROR: File download hash mis-match. Increase logging verbosity to determine why."); } // add some workaround messaging if (accountType == "documentLibrary"){ // It has been seen where SharePoint / OneDrive API reports one size via the JSON // but the content length and file size written to disk is totally different - example: // From JSON: "size": 17133 // From HTTPS Server: < Content-Length: 19340 // with no logical reason for the difference, except for a 302 redirect before file download log.error("INFO: It is most likely that a SharePoint OneDrive API issue is the root cause. Add --disable-download-validation to work around this issue but downloaded data integrity cannot be guaranteed."); } else { // other account types log.error("INFO: Potentially add --disable-download-validation to work around this issue but downloaded data integrity cannot be guaranteed."); } // we do not want this local file to remain on the local file system safeRemove(path); downloadFailed = true; return; } } else { // download checks have been disabled log.vdebug("Downloaded file validation disabled due to --disable-download-validation "); } } else { log.error("ERROR: File failed to download. Increase logging verbosity to determine why."); downloadFailed = true; return; } } if (!downloadFailed) { writeln("done."); log.fileOnly("Downloading file ", path, " ... done."); } else { writeln("failed!"); log.fileOnly("Downloading file ", path, " ... failed!"); } } // returns true if the given item corresponds to the local one private bool isItemSynced(const ref Item item, const(string) path, string itemSource) { if (!exists(path)) return false; final switch (item.type) { case ItemType.file: if (isFile(path)) { // can we actually read the local file? if (readLocalFile(path)){ // local file is readable SysTime localModifiedTime = timeLastModified(path).toUTC(); SysTime itemModifiedTime = item.mtime; // HACK: reduce time resolution to seconds before comparing localModifiedTime.fracSecs = Duration.zero; itemModifiedTime.fracSecs = Duration.zero; if (localModifiedTime == itemModifiedTime) { return true; } else { log.vlog("The local item has a different modified time ", localModifiedTime, " when compared to ", itemSource, " modified time ", itemModifiedTime); // The file has been modified ... is the hash the same? // Test the file hash as the date / time stamp is different // Generating a hash is computationally expensive - only generate the hash if timestamp was modified if (testFileHash(path, item)) { return true; } else { log.vlog("The local item has a different hash when compared to ", itemSource, " item hash"); } } } else { // Unable to read local file log.log("Unable to determine the sync state of this file as it cannot be read (file permissions or file corruption): ", path); return false; } } else { log.vlog("The local item is a directory but should be a file"); } break; case ItemType.dir: case ItemType.remote: if (isDir(path)) { return true; } else { log.vlog("The local item is a file but should be a directory"); } break; } return false; } private void deleteItems() { foreach_reverse (i; idsToDelete) { Item item; string path; if (!itemdb.selectById(i[0], i[1], item)) continue; // check if the item is in the db // Compute this item path path = computeItemPath(i[0], i[1]); // Try to delete item object log.log("Trying to delete item ", path); if (!dryRun) { // Actually process the database entry removal itemdb.deleteById(item.driveId, item.id); if (item.remoteDriveId != null) { // delete the linked remote folder itemdb.deleteById(item.remoteDriveId, item.remoteId); } } bool needsRemoval = false; if (exists(path)) { // path exists on the local system // make sure that the path refers to the correct item Item pathItem; if (itemdb.selectByPath(path, item.driveId, pathItem)) { if (pathItem.id == item.id) { needsRemoval = true; } else { log.log("Skipped due to id difference!"); } } else { // item has disappeared completely needsRemoval = true; } } if (needsRemoval) { log.log("Deleting item ", path); if (!dryRun) { if (isFile(path)) { remove(path); } else { try { // Remove any children of this path if they still exist // Resolve 'Directory not empty' error when deleting local files foreach (DirEntry child; dirEntries(path, SpanMode.depth, false)) { attrIsDir(child.linkAttributes) ? rmdir(child.name) : remove(child.name); } // Remove the path now that it is empty of children rmdirRecurse(path); } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); } } } } } if (!dryRun) { // clean up idsToDelete idsToDelete.length = 0; assumeSafeAppend(idsToDelete); } } // scan the given directory for differences and new items - for use with --synchronize void scanForDifferences(const(string) path) { // To improve logging output for this function, what is the 'logical path' we are scanning for file & folder differences? string logPath; if (path == ".") { // get the configured sync_dir logPath = buildNormalizedPath(cfg.getValueString("sync_dir")); } else { // use what was passed in logPath = path; } // If we are using --upload-only & --sync-shared-folders there is a possability that a 'new' local folder might // be misinterpreted that it needs to be uploaded to the users default OneDrive DriveID rather than the requested / configured // Shared Business Folder. In --resync scenarios, the DB information that tells that this Business Shared Folder does not exist, // and in a --upload-only scenario will never exist, so the correct lookups are unable to be performed. if ((exists(cfg.businessSharedFolderFilePath)) && (syncBusinessFolders) && (cfg.getValueBool("upload_only"))){ // business_shared_folders file exists, --sync-shared-folders is enabled, --upload-only is enabled log.vdebug("OneDrive Business --upload-only & --sync-shared-folders edge case triggered"); handleUploadOnlyBusinessSharedFoldersEdgeCase(); } // Are we configured to use a National Cloud Deployment if (nationalCloudDeployment) { // Select items that have a out-of-sync flag set flagNationalCloudDeploymentOutOfSyncItems(); } // scan for changes in the path provided if (isDir(path)) { // if this path is a directory, output this message. // if a file, potentially leads to confusion as to what the client is actually doing log.log("Uploading differences of ", logPath); } Item item; // For each unique OneDrive driveID we know about foreach (driveId; driveIDsArray) { log.vdebug("Processing DB entries for this driveId: ", driveId); // Database scan of every item in DB for the given driveId based on the root parent for that drive if ((syncBusinessFolders) && (driveId != defaultDriveId)) { // There could be multiple shared folders all from this same driveId - are we doing a single directory sync? if (cfg.getValueString("single_directory") != ""){ // Limit the local filesystem check to just the requested directory if (itemdb.selectByPath(path, driveId, item)) { // Does it still exist on disk in the location the DB thinks it is log.vdebug("Calling uploadDifferences(dbItem) as item is present in local cache DB"); uploadDifferences(item); } } else { // check everything associated with each driveId we know about foreach(dbItem; itemdb.selectByDriveId(driveId)) { // Does it still exist on disk in the location the DB thinks it is log.vdebug("Calling uploadDifferences(dbItem) as item is present in local cache DB"); uploadDifferences(dbItem); } } } else { if (itemdb.selectByPath(path, driveId, item)) { // Does it still exist on disk in the location the DB thinks it is log.vdebug("Calling uploadDifferences(dbItem) as item is present in local cache DB"); uploadDifferences(item); } } } // scan for changes in the path provided if (isDir(path)) { // if this path is a directory, output this message. // if a file, potentially leads to confusion as to what the client is actually doing log.log("Uploading new items of ", logPath); } // Filesystem walk to find new files not uploaded uploadNewItems(path); // clean up idsToDelete only if --dry-run is set if (dryRun) { idsToDelete.length = 0; assumeSafeAppend(idsToDelete); } } // scan the given directory for differences only - for use with --monitor void scanForDifferencesDatabaseScan(const(string) path) { // To improve logging output for this function, what is the 'logical path' we are scanning for file & folder differences? string logPath; if (path == ".") { // get the configured sync_dir logPath = buildNormalizedPath(cfg.getValueString("sync_dir")); } else { // use what was passed in logPath = path; } // If we are using --upload-only & --sync-shared-folders there is a possability that a 'new' local folder might // be misinterpreted that it needs to be uploaded to the users default OneDrive DriveID rather than the requested / configured // Shared Business Folder. In --resync scenarios, the DB information that tells that this Business Shared Folder does not exist, // and in a --upload-only scenario will never exist, so the correct lookups are unable to be performed. if ((exists(cfg.businessSharedFolderFilePath)) && (syncBusinessFolders) && (cfg.getValueBool("upload_only"))){ // business_shared_folders file exists, --sync-shared-folders is enabled, --upload-only is enabled log.vdebug("OneDrive Business --upload-only & --sync-shared-folders edge case triggered"); handleUploadOnlyBusinessSharedFoldersEdgeCase(); } // Are we configured to use a National Cloud Deployment if (nationalCloudDeployment) { // Select items that have a out-of-sync flag set flagNationalCloudDeploymentOutOfSyncItems(); } // scan for changes in the path provided if (isDir(path)) { // if this path is a directory, output this message. // if a file, potentially leads to confusion as to what the client is actually doing log.vlog("Uploading differences of ", logPath); } Item item; // For each unique OneDrive driveID we know about foreach (driveId; driveIDsArray) { log.vdebug("Processing DB entries for this driveId: ", driveId); // Database scan of every item in DB for the given driveId based on the root parent for that drive if ((syncBusinessFolders) && (driveId != defaultDriveId)) { // There could be multiple shared folders all from this same driveId - are we doing a single directory sync? if (cfg.getValueString("single_directory") != ""){ // Limit the local filesystem check to just the requested directory if (itemdb.selectByPath(path, driveId, item)) { // Does it still exist on disk in the location the DB thinks it is log.vdebug("Calling uploadDifferences(dbItem) as item is present in local cache DB"); uploadDifferences(item); } } else { // check everything associated with each driveId we know about foreach(dbItem; itemdb.selectByDriveId(driveId)) { // Does it still exist on disk in the location the DB thinks it is log.vdebug("Calling uploadDifferences(dbItem) as item is present in local cache DB"); uploadDifferences(dbItem); } } } else { if (itemdb.selectByPath(path, driveId, item)) { // Does it still exist on disk in the location the DB thinks it is log.vdebug("Calling uploadDifferences(dbItem) as item is present in local cache DB"); uploadDifferences(item); } } } } void flagNationalCloudDeploymentOutOfSyncItems() { // Any entry in the DB than is flagged as out-of-sync needs to be cleaned up locally first before we scan the entire DB // Normally, this is done at the end of processing all /delta queries, however National Cloud Deployments do not support /delta as a query // https://docs.microsoft.com/en-us/graph/deployments#supported-features // Select items that have a out-of-sync flag set foreach (driveId; driveIDsArray) { // For each unique OneDrive driveID we know about Item[] outOfSyncItems = itemdb.selectOutOfSyncItems(driveId); foreach (item; outOfSyncItems) { if (!dryRun) { // clean up idsToDelete idsToDelete.length = 0; assumeSafeAppend(idsToDelete); // flag to delete local file as it now is no longer in sync with OneDrive log.vdebug("Flagging to delete local item as it now is no longer in sync with OneDrive"); log.vdebug("item: ", item); idsToDelete ~= [item.driveId, item.id]; // delete items in idsToDelete if (idsToDelete.length > 0) deleteItems(); } } } } void handleUploadOnlyBusinessSharedFoldersEdgeCase() { // read in the business_shared_folders file contents string[] businessSharedFoldersList; // open file as read only auto file = File(cfg.businessSharedFolderFilePath, "r"); auto range = file.byLine(); foreach (line; range) { // Skip comments in file if (line.length == 0 || line[0] == ';' || line[0] == '#') continue; businessSharedFoldersList ~= buildNormalizedPath(line); } file.close(); // Query the GET /me/drive/sharedWithMe API JSONValue graphQuery = onedrive.getSharedWithMe(); if (graphQuery.type() == JSONType.object) { if (count(graphQuery["value"].array) != 0) { // Shared items returned log.vdebug("onedrive.getSharedWithMe API Response: ", graphQuery); foreach (searchResult; graphQuery["value"].array) { // loop variables string sharedFolderName; string remoteParentDriveId; string remoteParentItemId; Item remoteItemRoot; Item remoteItem; // is the shared item with us a 'folder' ? // we only handle folders, not files or other items if (isItemFolder(searchResult)) { // Debug response output log.vdebug("shared folder entry: ", searchResult); sharedFolderName = searchResult["name"].str; remoteParentDriveId = searchResult["remoteItem"]["parentReference"]["driveId"].str; remoteParentItemId = searchResult["remoteItem"]["parentReference"]["id"].str; if (canFind(businessSharedFoldersList, sharedFolderName)) { // Shared Folder matches what is in the shared folder list log.vdebug("shared folder name matches business_shared_folders list item: ", sharedFolderName); // Actions: // 1. Add this remote item to the DB so that it can be queried // 2. Add remoteParentDriveId to driveIDsArray so we have a record of it // Make JSON item DB compatible remoteItem = makeItem(searchResult); // Fix up entries, as we are manipulating the data remoteItem.driveId = remoteParentDriveId; remoteItem.eTag = ""; remoteItem.cTag = ""; remoteItem.parentId = defaultRootId; remoteItem.remoteDriveId = ""; remoteItem.remoteId = ""; // Build the remote root DB item remoteItemRoot.driveId = remoteParentDriveId; remoteItemRoot.id = defaultRootId; remoteItemRoot.name = "root"; remoteItemRoot.type = ItemType.dir; remoteItemRoot.mtime = remoteItem.mtime; remoteItemRoot.syncStatus = "Y"; // Add root remote item to the local database log.vdebug("Adding remote folder root to database: ", remoteItemRoot); itemdb.upsert(remoteItemRoot); // Add shared folder item to the local database log.vdebug("Adding remote folder to database: ", remoteItem); itemdb.upsert(remoteItem); // Keep the driveIDsArray with unique entries only if (!canFind(driveIDsArray, remoteParentDriveId)) { // Add this drive id to the array to search with driveIDsArray ~= remoteParentDriveId; } } } } } } } // scan the given directory for new items - for use with --monitor or --cleanup-local-files void scanForDifferencesFilesystemScan(const(string) path) { // To improve logging output for this function, what is the 'logical path' we are scanning for file & folder differences? string logPath; if (path == ".") { // get the configured sync_dir logPath = buildNormalizedPath(cfg.getValueString("sync_dir")); } else { // use what was passed in logPath = path; } // scan for changes in the path provided if (isDir(path)) { // if this path is a directory, output this message. // if a file, potentially leads to confusion as to what the client is actually doing if (!cleanupLocalFiles) { // if --cleanup-local-files was set, we will not be uploading data log.vlog("Uploading new items of ", logPath); } } // Filesystem walk to find extra files that reside locally. // If --cleanup-local-files is not used, these will be uploaded (normal operation) // If --download-only --cleanup-local-files is being used, extra files found locally will be deleted from the local filesystem uploadNewItems(path); } private void uploadDifferences(const ref Item item) { // see if this item.id we were supposed to have deleted // match early and return if (dryRun) { foreach (i; idsToDelete) { if (i[1] == item.id) { return; } } } bool unwanted = false; string path; // Compute this item path early as we we use this path often path = computeItemPath(item.driveId, item.id); // item.id was in the database associated with the item.driveId specified log.vlog("Processing ", buildNormalizedPath(path)); // What type of DB item are we processing // Is this item excluded by user configuration of skip_dir or skip_file? // Is this item a directory or 'remote' type? A 'remote' type is a folder DB tie so should be compared as directory for exclusion if ((item.type == ItemType.dir)||(item.type == ItemType.remote)) { // Do we need to check for .nosync? Only if --check-for-nosync was passed in if (cfg.getValueBool("check_nosync")) { if (exists(path ~ "/.nosync")) { log.vlog("Skipping item - .nosync found & --check-for-nosync enabled: ", path); return; } } // Is the path excluded? unwanted = selectiveSync.isDirNameExcluded(item.name); } // Is this item a file? if (item.type == ItemType.file) { // Is the filename excluded? unwanted = selectiveSync.isFileNameExcluded(item.name); } // If path or filename does not exclude, is this excluded due to use of selective sync? if (!unwanted) { // is sync_list configured if (syncListConfigured) { // sync_list configured and in use // Is the path excluded via sync_list? unwanted = selectiveSync.isPathExcludedViaSyncList(path); } } // skip unwanted items if (unwanted) { //log.vlog("Filtered out"); return; } // Check against Microsoft OneDrive restriction and limitations about Windows naming files if (!isValidName(path)) { log.logAndNotify("Skipping item - invalid name (Microsoft Naming Convention): ", path); return; } // Check for bad whitespace items if (!containsBadWhiteSpace(path)) { log.logAndNotify("Skipping item - invalid name (Contains an invalid whitespace item): ", path); return; } // Check for HTML ASCII Codes as part of file name if (!containsASCIIHTMLCodes(path)) { log.logAndNotify("Skipping item - invalid name (Contains HTML ASCII Code): ", path); return; } final switch (item.type) { case ItemType.dir: uploadDirDifferences(item, path); break; case ItemType.file: uploadFileDifferences(item, path); break; case ItemType.remote: uploadRemoteDirDifferences(item, path); break; } } private void uploadDirDifferences(const ref Item item, const(string) path) { assert(item.type == ItemType.dir); if (exists(path)) { // Fix https://github.com/abraunegg/onedrive/issues/1915 try { if (!isDir(path)) { log.vlog("The item was a directory but now it is a file"); uploadDeleteItem(item, path); uploadNewFile(path); } else { log.vlog("The directory has not changed"); // loop through the children foreach (Item child; itemdb.selectChildren(item.driveId, item.id)) { uploadDifferences(child); } } } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); return; } } else { // Directory does not exist locally // If we are in a --dry-run situation - this directory may never have existed as we never downloaded it if (!dryRun) { // Not --dry-run situation if (!cfg.getValueBool("monitor")) { // Not in --monitor mode log.vlog("The directory has been deleted locally"); } else { // Appropriate message as we are in --monitor mode log.vlog("The directory appears to have been deleted locally .. but we are running in --monitor mode. This may have been 'moved' on the local filesystem rather than being 'deleted'"); log.vdebug("Most likely cause - 'inotify' event was missing for whatever action was taken locally or action taken when application was stopped"); } // A moved file will be uploaded as 'new', delete the old file and reference if (noRemoteDelete) { // do not process remote directory delete log.vlog("Skipping remote directory delete as --upload-only & --no-remote-delete configured"); } else { uploadDeleteItem(item, path); } } else { // we are in a --dry-run situation, directory appears to have deleted locally - this directory may never have existed as we never downloaded it .. // Check if path does not exist in database Item databaseItem; if (!itemdb.selectByPath(path, defaultDriveId, databaseItem)) { // Path not found in database log.vlog("The directory has been deleted locally"); if (noRemoteDelete) { // do not process remote directory delete log.vlog("Skipping remote directory delete as --upload-only & --no-remote-delete configured"); } else { uploadDeleteItem(item, path); } } else { // Path was found in the database // Did we 'fake create it' as part of --dry-run ? foreach (i; idsFaked) { if (i[1] == item.id) { log.vdebug("Matched faked dir which is 'supposed' to exist but not created due to --dry-run use"); log.vlog("The directory has not changed"); return; } } // item.id did not match a 'faked' download new directory creation log.vlog("The directory has been deleted locally"); uploadDeleteItem(item, path); } } } } private void uploadRemoteDirDifferences(const ref Item item, const(string) path) { assert(item.type == ItemType.remote); if (exists(path)) { if (!isDir(path)) { log.vlog("The item was a directory but now it is a file"); uploadDeleteItem(item, path); uploadNewFile(path); } else { log.vlog("The directory has not changed"); // continue through the linked folder assert(item.remoteDriveId && item.remoteId); Item remoteItem; bool found = itemdb.selectById(item.remoteDriveId, item.remoteId, remoteItem); if(found){ // item was found in the database uploadDifferences(remoteItem); } } } else { // are we in a dry-run scenario if (!dryRun) { // no dry-run log.vlog("The directory has been deleted locally"); if (noRemoteDelete) { // do not process remote directory delete log.vlog("Skipping remote directory delete as --upload-only & --no-remote-delete configured"); } else { uploadDeleteItem(item, path); } } else { // we are in a --dry-run situation, directory appears to have deleted locally - this directory may never have existed as we never downloaded it .. // Check if path does not exist in database Item databaseItem; if (!itemdb.selectByPathWithoutRemote(path, defaultDriveId, databaseItem)) { // Path not found in database log.vlog("The directory has been deleted locally"); if (noRemoteDelete) { // do not process remote directory delete log.vlog("Skipping remote directory delete as --upload-only & --no-remote-delete configured"); } else { uploadDeleteItem(item, path); } } else { // Path was found in the database // Did we 'fake create it' as part of --dry-run ? foreach (i; idsFaked) { if (i[1] == item.id) { log.vdebug("Matched faked dir which is 'supposed' to exist but not created due to --dry-run use"); log.vlog("The directory has not changed"); return; } } // item.id did not match a 'faked' download new directory creation log.vlog("The directory has been deleted locally"); uploadDeleteItem(item, path); } } } } // upload local file system differences to OneDrive private void uploadFileDifferences(const ref Item item, const(string) path) { // Reset upload failure - OneDrive or filesystem issue (reading data) uploadFailed = false; // uploadFileDifferences is called when processing DB entries to compare against actual files on disk string itemSource = "database"; assert(item.type == ItemType.file); if (exists(path)) { if (isFile(path)) { // can we actually read the local file? if (readLocalFile(path)){ // file is readable SysTime localModifiedTime = timeLastModified(path).toUTC(); SysTime itemModifiedTime = item.mtime; // HACK: reduce time resolution to seconds before comparing itemModifiedTime.fracSecs = Duration.zero; localModifiedTime.fracSecs = Duration.zero; if (localModifiedTime != itemModifiedTime) { log.vlog("The file last modified time has changed"); log.vdebug("The local item has a different modified time ", localModifiedTime, " when compared to ", itemSource, " modified time ", itemModifiedTime); string eTag = item.eTag; // perform file hash tests - has the content of the file changed? if (!testFileHash(path, item)) { log.vlog("The file content has changed"); log.vdebug("The local item has a different hash when compared to ", itemSource, " item hash"); write("Uploading modified file ", path, " ... "); JSONValue response; if (!dryRun) { // Get the file size long thisFileSize = getSize(path); // Are we using OneDrive Personal or OneDrive Business? // To solve 'Multiple versions of file shown on website after single upload' (https://github.com/abraunegg/onedrive/issues/2) // check what 'account type' this is as this issue only affects OneDrive Business so we need some extra logic here if (accountType == "personal"){ // Original file upload logic if (thisFileSize <= thresholdFileSize) { try { response = onedrive.simpleUploadReplace(path, item.driveId, item.id, item.eTag); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } if (e.httpStatusCode == 404) { // HTTP request returned status code 404 - the eTag provided does not exist // Delete record from the local database - file will be uploaded as a new file writeln("skipped."); log.vlog("OneDrive returned a 'HTTP 404 - eTag Issue' - gracefully handling error"); itemdb.deleteById(item.driveId, item.id); uploadFailed = true; return; } // Resolve https://github.com/abraunegg/onedrive/issues/36 if ((e.httpStatusCode == 409) || (e.httpStatusCode == 423)) { // The file is currently checked out or locked for editing by another user // We cant upload this file at this time writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); write("", path, " is currently checked out or locked for editing by another user."); log.fileOnly(path, " is currently checked out or locked for editing by another user."); uploadFailed = true; return; } if (e.httpStatusCode == 412) { // HTTP request returned status code 412 - ETag does not match current item's value // Delete record from the local database - file will be uploaded as a new file writeln("skipped."); log.vdebug("Simple Upload Replace Failed - OneDrive eTag / cTag match issue (Personal Account)"); log.vlog("OneDrive returned a 'HTTP 412 - Precondition Failed' - gracefully handling error. Will upload as new file."); itemdb.deleteById(item.driveId, item.id); uploadFailed = true; return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying upload request as a session"); // Try upload as a session response = session.upload(path, item.driveId, item.parentId, baseName(path), item.eTag); } else { // display what the error is writeln("skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } // upload done without error writeln("done."); } else { writeln(""); try { response = session.upload(path, item.driveId, item.parentId, baseName(path), item.eTag); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } if (e.httpStatusCode == 412) { // HTTP request returned status code 412 - ETag does not match current item's value // Delete record from the local database - file will be uploaded as a new file writeln("skipped."); log.vdebug("Session Upload Replace Failed - OneDrive eTag / cTag match issue (Personal Account)"); log.vlog("OneDrive returned a 'HTTP 412 - Precondition Failed' - gracefully handling error. Will upload as new file."); itemdb.deleteById(item.driveId, item.id); uploadFailed = true; return; } else { // display what the error is writeln("skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } // upload done without error writeln("done."); } } else { // OneDrive Business Account // We need to always use a session to upload, but handle the changed file correctly if (accountType == "business"){ try { // is this a zero-byte file? if (thisFileSize == 0) { // the file we are trying to upload as a session is a zero byte file - we cant use a session to upload or replace the file // as OneDrive technically does not support zero byte files writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); log.vlog("Skip Reason: Microsoft OneDrive does not support 'zero-byte' files as a modified upload. Will upload as new file."); // delete file on OneDrive onedrive.deleteById(item.driveId, item.id, item.eTag); // delete file from local database itemdb.deleteById(item.driveId, item.id); return; } else { if ((!syncBusinessFolders) || (item.driveId == defaultDriveId)) { // For logging consistency writeln(""); // If we are not syncing Shared Business Folders, or this change is going to the 'users' default drive, handle normally // Perform a normal session upload response = session.upload(path, item.driveId, item.parentId, baseName(path), item.eTag); } else { // If we are uploading to a shared business folder, there are a couple of corner cases here: // 1. Shared Folder is a 'users' folder // 2. Shared Folder is a 'SharePoint Library' folder, meaning we get hit by this stupidity: https://github.com/OneDrive/onedrive-api-docs/issues/935 response = handleSharePointMetadataAdditionBug(item, path); } } } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } // Resolve https://github.com/abraunegg/onedrive/issues/36 if ((e.httpStatusCode == 409) || (e.httpStatusCode == 423)) { // The file is currently checked out or locked for editing by another user // We cant upload this file at this time writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); writeln("", path, " is currently checked out or locked for editing by another user."); log.fileOnly(path, " is currently checked out or locked for editing by another user."); uploadFailed = true; return; } if (e.httpStatusCode == 412) { // HTTP request returned status code 412 - ETag does not match current item's value // Delete record from the local database - file will be uploaded as a new file writeln("skipped."); log.vdebug("Session Upload Replace Failed - OneDrive eTag / cTag match issue (Business Account)"); log.vlog("OneDrive returned a 'HTTP 412 - Precondition Failed' - gracefully handling error. Will upload as new file."); itemdb.deleteById(item.driveId, item.id); uploadFailed = true; return; } else { // display what the error is writeln("skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } // Did the upload fail? if (!uploadFailed){ // upload done without error or failure writeln("done."); // As the session.upload includes the last modified time, save the response // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); } else { // uploadFailed, return return; } } // OneDrive documentLibrary if (accountType == "documentLibrary"){ // is this a zero-byte file? if (thisFileSize == 0) { // the file we are trying to upload as a session is a zero byte file - we cant use a session to upload or replace the file // as OneDrive technically does not support zero byte files writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); log.vlog("Skip Reason: Microsoft OneDrive does not support 'zero-byte' files as a modified upload. Will upload as new file."); // delete file on OneDrive onedrive.deleteById(item.driveId, item.id, item.eTag); // delete file from local database itemdb.deleteById(item.driveId, item.id); return; } else { // Due to https://github.com/OneDrive/onedrive-api-docs/issues/935 Microsoft modifies all PDF, MS Office & HTML files with added XML content. It is a 'feature' of SharePoint. // This means, as a session upload, on 'completion' the file is 'moved' and generates a 404 ...... response = handleSharePointMetadataAdditionBug(item, path); // Did the upload fail? if (!uploadFailed){ // upload done without error or failure writeln("done."); // As the session.upload includes the last modified time, save the response // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); } else { // uploadFailed, return return; } } } } // Update etag with ctag from response if ("cTag" in response) { // use the cTag instead of the eTag because OneDrive may update the metadata of files AFTER they have been uploaded via simple upload eTag = response["cTag"].str; } else { // Is there an eTag in the response? if ("eTag" in response) { // use the eTag from the response as there was no cTag eTag = response["eTag"].str; } else { // no tag available - set to nothing eTag = ""; } } // log that the modified file was uploaded successfully log.fileOnly("Uploading modified file ", path, " ... done."); // update free space tracking if this is our drive id if (item.driveId == defaultDriveId) { // how much space is left on OneDrive after upload? remainingFreeSpace = (remainingFreeSpace - thisFileSize); log.vlog("Remaining free space on OneDrive: ", remainingFreeSpace); } } else { // we are --dry-run - simulate the file upload writeln("done."); response = createFakeResponse(path); // Log action to log file log.fileOnly("Uploading modified file ", path, " ... done."); // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); return; } } if (accountType == "personal"){ // If Personal, call to update the modified time as stored on OneDrive if (!dryRun) { uploadLastModifiedTime(item.driveId, item.id, eTag, localModifiedTime.toUTC()); } } } else { log.vlog("The file has not changed"); } } else { //The file is not readable - skipped log.log("Skipping processing this file as it cannot be read (file permissions or file corruption): ", path); uploadFailed = true; } } else { log.vlog("The item was a file but now is a directory"); uploadDeleteItem(item, path); uploadCreateDir(path); } } else { // File does not exist locally // If we are in a --dry-run situation - this file may never have existed as we never downloaded it if (!dryRun) { // Not --dry-run situation if (!cfg.getValueBool("monitor")) { log.vlog("The file has been deleted locally"); } else { // Appropriate message as we are in --monitor mode log.vlog("The file appears to have been deleted locally .. but we are running in --monitor mode. This may have been 'moved' on the local filesystem rather than being 'deleted'"); log.vdebug("Most likely cause - 'inotify' event was missing for whatever action was taken locally or action taken when application was stopped"); } // A moved file will be uploaded as 'new', delete the old file and reference if (noRemoteDelete) { // do not process remote file delete log.vlog("Skipping remote file delete as --upload-only & --no-remote-delete configured"); } else { uploadDeleteItem(item, path); } } else { // We are in a --dry-run situation, file appears to have deleted locally - this file may never have existed as we never downloaded it .. // Check if path does not exist in database Item databaseItem; if (!itemdb.selectByPath(path, defaultDriveId, databaseItem)) { // file not found in database log.vlog("The file has been deleted locally"); if (noRemoteDelete) { // do not process remote file delete log.vlog("Skipping remote file delete as --upload-only & --no-remote-delete configured"); } else { uploadDeleteItem(item, path); } } else { // file was found in the database // Did we 'fake create it' as part of --dry-run ? foreach (i; idsFaked) { if (i[1] == item.id) { log.vdebug("Matched faked file which is 'supposed' to exist but not created due to --dry-run use"); log.vlog("The file has not changed"); return; } } // item.id did not match a 'faked' download new file creation log.vlog("The file has been deleted locally"); if (noRemoteDelete) { // do not process remote file delete log.vlog("Skipping remote file delete as --upload-only & --no-remote-delete configured"); } else { uploadDeleteItem(item, path); } } } } } private JSONValue handleSharePointMetadataAdditionBug(const ref Item item, const(string) path) { // Explicit function for handling https://github.com/OneDrive/onedrive-api-docs/issues/935 JSONValue response; // Handle certain file types differently if ((extension(path) == ".txt") || (extension(path) == ".csv")) { // .txt and .csv are unaffected by https://github.com/OneDrive/onedrive-api-docs/issues/935 // For logging consistency writeln(""); try { response = session.upload(path, item.driveId, item.parentId, baseName(path), item.eTag); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return response; } // Resolve https://github.com/abraunegg/onedrive/issues/36 if ((e.httpStatusCode == 409) || (e.httpStatusCode == 423)) { // The file is currently checked out or locked for editing by another user // We cant upload this file at this time writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); writeln("", path, " is currently checked out or locked for editing by another user."); log.fileOnly(path, " is currently checked out or locked for editing by another user."); uploadFailed = true; return response; } if (e.httpStatusCode == 412) { // HTTP request returned status code 412 - ETag does not match current item's value // Delete record from the local database - file will be uploaded as a new file writeln("skipped."); log.vdebug("Session Upload Replace Failed - OneDrive eTag / cTag match issue (Sharepoint Library)"); log.vlog("OneDrive returned a 'HTTP 412 - Precondition Failed' - gracefully handling error. Will upload as new file."); itemdb.deleteById(item.driveId, item.id); uploadFailed = true; return response; } else { // display what the error is writeln("skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return response; } } catch (FileException e) { // display the error message writeln("skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return response; } // upload done without error writeln("done."); } else { // Due to https://github.com/OneDrive/onedrive-api-docs/issues/935 Microsoft modifies all PDF, MS Office & HTML files with added XML content. It is a 'feature' of SharePoint. // This means, as a session upload, on 'completion' the file is 'moved' and generates a 404 ...... writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); log.vlog("Skip Reason: Microsoft Sharepoint 'enrichment' after upload issue"); log.vlog("See: https://github.com/OneDrive/onedrive-api-docs/issues/935 for further details"); // Delete record from the local database - file will be uploaded as a new file itemdb.deleteById(item.driveId, item.id); uploadFailed = true; return response; } // return a JSON response so that it can be used and saved return response; } // upload new items to OneDrive private void uploadNewItems(const(string) path) { static import std.utf; import std.range : walkLength; import std.uni : byGrapheme; // https://support.microsoft.com/en-us/help/3125202/restrictions-and-limitations-when-you-sync-files-and-folders // If the path is greater than allowed characters, then one drive will return a '400 - Bad Request' // Need to ensure that the URI is encoded before the check is made: // - 400 Character Limit for OneDrive Business / Office 365 // - 430 Character Limit for OneDrive Personal long maxPathLength = 0; long pathWalkLength = 0; // Configure maxPathLength based on account type if (accountType == "personal"){ // Personal Account maxPathLength = 430; } else { // Business Account / Office365 maxPathLength = 400; } // A short lived file that has disappeared will cause an error - is the path valid? if (!exists(path)) { log.log("Skipping item - path has disappeared: ", path); return; } // Calculate the path length by walking the path, catch any UTF-8 character errors // https://github.com/abraunegg/onedrive/issues/487 // https://github.com/abraunegg/onedrive/issues/1192 try { pathWalkLength = path.byGrapheme.walkLength; } catch (std.utf.UTFException e) { // path contains characters which generate a UTF exception log.vlog("Skipping item - invalid UTF sequence: ", path); log.vdebug(" Error Reason:", e.msg); return; } // check the std.encoding of the path // https://github.com/skilion/onedrive/issues/57 // https://github.com/abraunegg/onedrive/issues/487 if(!isValid(path)) { // Path is not valid according to https://dlang.org/phobos/std_encoding.html log.vlog("Skipping item - invalid character encoding sequence: ", path); return; } // Is the path length is less than maxPathLength if(pathWalkLength < maxPathLength){ // skip dot files if configured if (cfg.getValueBool("skip_dotfiles")) { if (isDotFile(path)) { log.vlog("Skipping item - .file or .folder: ", path); return; } } // Do we need to check for .nosync? Only if --check-for-nosync was passed in if (cfg.getValueBool("check_nosync")) { if (exists(path ~ "/.nosync")) { log.vlog("Skipping item - .nosync found & --check-for-nosync enabled: ", path); return; } } // Is the path a symbolic link if (isSymlink(path)) { // if config says so we skip all symlinked items if (cfg.getValueBool("skip_symlinks")) { log.vlog("Skipping item - skip symbolic links configured: ", path); return; } // skip unexisting symbolic links else if (!exists(readLink(path))) { // reading the symbolic link failed - is the link a relative symbolic link // drwxrwxr-x. 2 alex alex 46 May 30 09:16 . // drwxrwxr-x. 3 alex alex 35 May 30 09:14 .. // lrwxrwxrwx. 1 alex alex 61 May 30 09:16 absolute.txt -> /home/alex/OneDrivePersonal/link_tests/intercambio/prueba.txt // lrwxrwxrwx. 1 alex alex 13 May 30 09:16 relative.txt -> ../prueba.txt // // absolute links will be able to be read, but 'relative' links will fail, because they cannot be read based on the current working directory 'sync_dir' string currentSyncDir = getcwd(); string fullLinkPath = buildNormalizedPath(absolutePath(path)); string fileName = baseName(fullLinkPath); string parentLinkPath = dirName(fullLinkPath); // test if this is a 'relative' symbolic link chdir(parentLinkPath); auto relativeLink = readLink(fileName); auto relativeLinkTest = exists(readLink(fileName)); // reset back to our 'sync_dir' chdir(currentSyncDir); // results if (relativeLinkTest) { log.vdebug("Not skipping item - symbolic link is a 'relative link' to target ('", relativeLink, "') which can be supported: ", path); } else { log.logAndNotify("Skipping item - invalid symbolic link: ", path); return; } } } // Check for bad whitespace items if (!containsBadWhiteSpace(path)) { log.logAndNotify("Skipping item - invalid name (Contains an invalid whitespace item): ", path); return; } // Check for HTML ASCII Codes as part of file name if (!containsASCIIHTMLCodes(path)) { log.logAndNotify("Skipping item - invalid name (Contains HTML ASCII Code): ", path); return; } // Is this item excluded by user configuration of skip_dir or skip_file? if (path != ".") { if (isDir(path)) { log.vdebug("Checking local path: ", path); // Only check path if config is != "" if (cfg.getValueString("skip_dir") != "") { // The path that needs to be checked needs to include the '/' // This due to if the user has specified in skip_dir an exclusive path: '/path' - that is what must be matched if (selectiveSync.isDirNameExcluded(path.strip('.'))) { log.vlog("Skipping item - excluded by skip_dir config: ", path); return; } } // In the event that this 'new item' is actually a OneDrive Business Shared Folder // however the user may have omitted --sync-shared-folders, thus 'technically' this is a new item // for this account OneDrive root, however this then would cause issues if --sync-shared-folders // is added again after this sync if ((exists(cfg.businessSharedFolderFilePath)) && (!syncBusinessFolders)){ // business_shared_folders file exists, but we are not using / syncing them // The file contents can only contain 'folder' names, so we need to strip './' from any path we are checking if(selectiveSync.isSharedFolderMatched(strip(path,"./"))){ // path detected as a 'new item' is matched as a path in business_shared_folders log.vlog("Skipping item - excluded as included in business_shared_folders config: ", path); log.vlog("To sync this directory to your OneDrive Account update your business_shared_folders config"); return; } } } if (isFile(path)) { log.vdebug("Checking file: ", path); // The path that needs to be checked needs to include the '/' // This due to if the user has specified in skip_file an exclusive path: '/path/file' - that is what must be matched if (selectiveSync.isFileNameExcluded(path.strip('.'))) { log.vlog("Skipping item - excluded by skip_file config: ", path); return; } } // is sync_list configured if (syncListConfigured) { // sync_list configured and in use if (selectiveSync.isPathExcludedViaSyncList(path)) { if ((isFile(path)) && (cfg.getValueBool("sync_root_files")) && (rootName(path.strip('.').strip('/')) == "")) { log.vdebug("Not skipping path due to sync_root_files inclusion: ", path); } else { string userSyncList = cfg.configDirName ~ "/sync_list"; if (exists(userSyncList)){ // skipped most likely due to inclusion in sync_list log.vlog("Skipping item - excluded by sync_list config: ", path); return; } else { // skipped for some other reason log.vlog("Skipping item - path excluded by user config: ", path); return; } } } } } // Check against Microsoft OneDrive restriction and limitations about Windows naming files if (!isValidName(path)) { log.logAndNotify("Skipping item - invalid name (Microsoft Naming Convention): ", path); return; } // If we are in a --dry-run scenario, we may have renamed a folder - but it is technically not renamed locally // Thus, that entire path may be attemtped to be uploaded as new data to OneDrive if (dryRun) { // check the pathsRenamed array for this path // if any match - we need to exclude this path foreach (thisRenamedPath; pathsRenamed) { log.vdebug("Renamed Path to evaluate: ", thisRenamedPath); // Can we find 'thisRenamedPath' in the given 'path' if (canFind(path, thisRenamedPath)) { log.vdebug("Renamed Path MATCH - DONT UPLOAD AS NEW"); return; } } } // We want to upload this new local data if (isDir(path)) { Item item; bool pathFoundInDB = false; foreach (driveId; driveIDsArray) { if (itemdb.selectByPath(path, driveId, item)) { pathFoundInDB = true; } } // Was the path found in the database? if (!pathFoundInDB) { // Path not found in database when searching all drive id's if (!cleanupLocalFiles) { // --download-only --cleanup-local-files not used uploadCreateDir(path); } else { // we need to clean up this directory log.log("Removing local directory as --download-only & --cleanup-local-files configured"); // Remove any children of this path if they still exist // Resolve 'Directory not empty' error when deleting local files try { foreach (DirEntry child; dirEntries(path, SpanMode.depth, false)) { // what sort of child is this? if (isDir(child.name)) { log.log("Removing local directory: ", child.name); } else { log.log("Removing local file: ", child.name); } // are we in a --dry-run scenario? if (!dryRun) { // No --dry-run ... process local delete try { attrIsDir(child.linkAttributes) ? rmdir(child.name) : remove(child.name); } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); } } } // Remove the path now that it is empty of children log.log("Removing local directory: ", path); // are we in a --dry-run scenario? if (!dryRun) { // No --dry-run ... process local delete try { rmdirRecurse(path); } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); } } } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); return; } } } // recursively traverse children // the above operation takes time and the directory might have // disappeared in the meantime if (!exists(path)) { if (!cleanupLocalFiles) { // --download-only --cleanup-local-files not used log.vlog("Directory disappeared during upload: ", path); } return; } // Try and access the directory and any path below try { auto entries = dirEntries(path, SpanMode.shallow, false); foreach (DirEntry entry; entries) { string thisPath = entry.name; uploadNewItems(thisPath); } } catch (FileException e) { // display the error message displayFileSystemErrorMessage(e.msg, getFunctionName!({})); return; } } else { // path is not a directory, is it a valid file? // pipes - whilst technically valid files, are not valid for this client // prw-rw-r--. 1 user user 0 Jul 7 05:55 my_pipe if (isFile(path)) { // Path is a valid file bool fileFoundInDB = false; Item item; // Search the database for this file foreach (driveId; driveIDsArray) { if (itemdb.selectByPath(path, driveId, item)) { fileFoundInDB = true; } } // Was the file found in the database? if (!fileFoundInDB) { // File not found in database when searching all drive id's // Do we upload the file or clean up the file? if (!cleanupLocalFiles) { // --download-only --cleanup-local-files not used uploadNewFile(path); // Did the upload fail? if (!uploadFailed) { // Upload did not fail // Issue #763 - Delete local files after sync handling // are we in an --upload-only & --remove-source-files scenario? if ((uploadOnly) && (localDeleteAfterUpload)) { // Log that we are deleting a local item log.log("Removing local file as --upload-only & --remove-source-files configured"); // are we in a --dry-run scenario? log.vdebug("Removing local file: ", path); if (!dryRun) { // No --dry-run ... process local file delete safeRemove(path); } } } } else { // we need to clean up this file log.log("Removing local file as --download-only & --cleanup-local-files configured"); // are we in a --dry-run scenario? log.log("Removing local file: ", path); if (!dryRun) { // No --dry-run ... process local file delete safeRemove(path); } } } } else { // path is not a valid file log.log("Skipping item - item is not a valid file: ", path); } } } else { // This path was skipped - why? log.log("Skipping item '", path, "' due to the full path exceeding ", maxPathLength, " characters (Microsoft OneDrive limitation)"); } } // create new directory on OneDrive private void uploadCreateDir(const(string) path) { log.vlog("OneDrive Client requested to create remote path: ", path); JSONValue onedrivePathDetails; Item parent; // Was the path entered the root path? if (path != "."){ // What parent path to use? string parentPath = dirName(path); // will be either . or something else if (parentPath == "."){ // Assume this is a new 'local' folder in the users configured sync_dir // Use client defaults parent.id = defaultRootId; // Should give something like 12345ABCDE1234A1!101 parent.driveId = defaultDriveId; // Should give something like 12345abcde1234a1 } else { // Query the database using each of the driveId's we are using foreach (driveId; driveIDsArray) { // Query the database for this parent path using each driveId Item dbResponse; if(itemdb.selectByPathWithoutRemote(parentPath, driveId, dbResponse)){ // parent path was found in the database parent = dbResponse; } } } // If this is still null or empty - we cant query the database properly later on // Query OneDrive API for parent details if ((parent.driveId == "") && (parent.id == "")){ try { log.vdebug("Attempting to query OneDrive for this parent path: ", parentPath); onedrivePathDetails = onedrive.getPathDetails(parentPath); } catch (OneDriveException e) { log.vdebug("onedrivePathDetails = onedrive.getPathDetails(parentPath); generated a OneDriveException"); // exception - set onedriveParentRootDetails to a blank valid JSON onedrivePathDetails = parseJSON("{}"); if (e.httpStatusCode == 404) { // Parent does not exist ... need to create parent log.vdebug("Parent path does not exist: ", parentPath); uploadCreateDir(parentPath); } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling uploadCreateDir(path);"); uploadCreateDir(path); // return back to original call return; } if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged return; } } // configure the parent item data if (hasId(onedrivePathDetails) && hasParentReference(onedrivePathDetails)){ log.vdebug("Parent path found, configuring parent item"); parent.id = onedrivePathDetails["id"].str; // This item's ID. Should give something like 12345ABCDE1234A1!101 parent.driveId = onedrivePathDetails["parentReference"]["driveId"].str; // Should give something like 12345abcde1234a1 } else { // OneDrive API query failed // Assume client defaults log.vdebug("Parent path could not be queried, using OneDrive account defaults"); parent.id = defaultRootId; // Should give something like 12345ABCDE1234A1!101 parent.driveId = defaultDriveId; // Should give something like 12345abcde1234a1 } } JSONValue response; // test if the path we are going to create already exists on OneDrive try { log.vdebug("Attempting to query OneDrive for this path: ", path); response = onedrive.getPathDetailsByDriveId(parent.driveId, path); } catch (OneDriveException e) { log.vdebug("response = onedrive.getPathDetails(path); generated a OneDriveException"); if (e.httpStatusCode == 404) { // The directory was not found on the drive id we queried log.vlog("The requested directory to create was not found on OneDrive - creating remote directory: ", path); if (!dryRun) { // Perform the database lookup - is the parent in the database? if (!itemdb.selectByPath(dirName(path), parent.driveId, parent)) { // parent is not in the database log.vdebug("Parent path is not in the database - need to add it: ", dirName(path)); uploadCreateDir(dirName(path)); } // Is the parent a 'folder' from another user? ie - is this a 'shared folder' that has been shared with us? if (defaultDriveId == parent.driveId){ // enforce check of parent path. if the above was triggered, the below will generate a sync retry and will now be sucessful enforce(itemdb.selectByPath(dirName(path), parent.driveId, parent), "The parent item id is not in the database"); } else { log.vdebug("Parent drive ID is not our drive ID - parent most likely a shared folder"); } JSONValue driveItem = [ "name": JSONValue(baseName(path)), "folder": parseJSON("{}") ]; // Submit the creation request // Fix for https://github.com/skilion/onedrive/issues/356 try { // Attempt to create a new folder on the configured parent driveId & parent id response = onedrive.createById(parent.driveId, parent.id, driveItem); } catch (OneDriveException e) { if (e.httpStatusCode == 409) { // OneDrive API returned a 404 (above) to say the directory did not exist // but when we attempted to create it, OneDrive responded that it now already exists log.vlog("OneDrive reported that ", path, " already exists .. OneDrive API race condition"); return; } else { // some other error from OneDrive was returned - display what it is log.error("OneDrive generated an error when creating this path: ", path); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); } else { // Simulate a successful 'directory create' & save it to the dryRun database copy // The simulated response has to pass 'makeItem' as part of saveItem auto fakeResponse = createFakeResponse(path); saveItem(fakeResponse); } log.vlog("Successfully created the remote directory ", path, " on OneDrive"); return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling uploadCreateDir(path);"); uploadCreateDir(path); // return back to original call return; } if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged return; } } // response from OneDrive has to be a valid JSON object if (response.type() == JSONType.object){ // https://docs.microsoft.com/en-us/windows/desktop/FileIO/naming-a-file // Do not assume case sensitivity. For example, consider the names OSCAR, Oscar, and oscar to be the same, // even though some file systems (such as a POSIX-compliant file system) may consider them as different. // Note that NTFS supports POSIX semantics for case sensitivity but this is not the default behavior. if (response["name"].str == baseName(path)){ // OneDrive 'name' matches local path name log.vlog("The requested directory to create was found on OneDrive - skipping creating the directory: ", path ); // Check that this path is in the database if (!itemdb.selectById(parent.driveId, parent.id, parent)){ // parent for 'path' is NOT in the database log.vlog("The parent for this path is not in the local database - need to add parent to local database"); parentPath = dirName(path); // add the parent into the database uploadCreateDir(parentPath); // save this child item into the database log.vlog("The parent for this path has been added to the local database - adding requested path (", path ,") to database"); if (!dryRun) { // save the live data saveItem(response); } else { // need to fake this data auto fakeResponse = createFakeResponse(path); saveItem(fakeResponse); } } else { // parent is in database log.vlog("The parent for this path is in the local database - adding requested path (", path ,") to database"); // are we in a --dry-run scenario? if (!dryRun) { // get the live data JSONValue pathDetails; try { pathDetails = onedrive.getPathDetailsByDriveId(parent.driveId, path); } catch (OneDriveException e) { log.vdebug("pathDetails = onedrive.getPathDetailsByDriveId(parent.driveId, path) generated a OneDriveException"); if (e.httpStatusCode == 404) { // The directory was not found log.error("ERROR: The requested single directory to sync was not found on OneDrive"); return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling onedrive.getPathDetailsByDriveId(parent.driveId, path);"); pathDetails = onedrive.getPathDetailsByDriveId(parent.driveId, path); } if (e.httpStatusCode >= 500) { // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged return; } } // Is the response a valid JSON object - validation checking done in saveItem saveItem(pathDetails); // OneDrive Personal Shared Folder edgecase handling // In a: // --resync --upload-only --single-directory 'dir' scenario, and where the root 'dir' for --single-directory is a 'shared folder' // OR // --resync --upload-only scenario, and where the root 'dir' to upload is a 'shared folder' // // We will not have the 'tie' DB entry created because of --upload-only because we do not download the folder structure from OneDrive // to know what the remoteDriveId actually is if (accountType == "personal"){ // are we in a --resync --upload-only scenario ? if ((cfg.getValueBool("resync")) && (cfg.getValueBool("upload_only"))) { // Create a temp item // Takes a JSON input and formats to an item which can be used by the database Item tempItem = makeItem(pathDetails); // New DB Tie item due to edge case Item tieDBItem; // Set the name tieDBItem.name = tempItem.name; // Set the correct item type tieDBItem.type = ItemType.dir; //parent.type = ItemType.remote; if ((tempItem.type == ItemType.remote) && (!tempItem.remoteDriveId.empty)) { // set the right elements tieDBItem.driveId = tempItem.remoteDriveId; tieDBItem.id = tempItem.remoteId; // Set the correct mtime tieDBItem.mtime = tempItem.mtime; // Add tie DB record to the local database log.vdebug("Adding tie DB record to database: ", tieDBItem); itemdb.upsert(tieDBItem); } } } } else { // need to fake this data auto fakeResponse = createFakeResponse(path); saveItem(fakeResponse); } } } else { // They are the "same" name wise but different in case sensitivity log.error("ERROR: Current directory has a 'case-insensitive match' to an existing directory on OneDrive"); log.error("ERROR: To resolve, rename this local directory: ", buildNormalizedPath(absolutePath(path))); log.error("ERROR: Remote OneDrive directory: ", response["name"].str); log.log("Skipping: ", buildNormalizedPath(absolutePath(path))); return; } } else { // response is not valid JSON, an error was returned from OneDrive log.error("ERROR: There was an error performing this operation on OneDrive"); log.error("ERROR: Increase logging verbosity to assist determining why."); log.log("Skipping: ", buildNormalizedPath(absolutePath(path))); return; } } } // upload a new file to OneDrive private void uploadNewFile(const(string) path) { // Reset upload failure - OneDrive or filesystem issue (reading data) uploadFailed = false; Item parent; bool parentPathFoundInDB = false; // Check the database for the parent path // What parent path to use? string parentPath = dirName(path); // will be either . or something else if (parentPath == "."){ // Assume this is a new file in the users configured sync_dir root // Use client defaults parent.id = defaultRootId; // Should give something like 12345ABCDE1234A1!101 parent.driveId = defaultDriveId; // Should give something like 12345abcde1234a1 parentPathFoundInDB = true; } else { // Query the database using each of the driveId's we are using foreach (driveId; driveIDsArray) { // Query the database for this parent path using each driveId Item dbResponse; if(itemdb.selectByPath(parentPath, driveId, dbResponse)){ // parent path was found in the database parent = dbResponse; parentPathFoundInDB = true; } } } // Get the file size long thisFileSize = getSize(path); // Can we upload this file - is there enough free space? - https://github.com/skilion/onedrive/issues/73 // We can only use 'remainingFreeSpace' if we are uploading to our driveId ... if this is a shared folder, we have no visibility of space available, as quota details are not provided by the OneDrive API if (parent.driveId == defaultDriveId) { // the file will be uploaded to my driveId log.vdebug("File upload destination is users default driveId .."); // are quota details being restricted? if (!quotaRestricted) { // quota is not being restricted - we can track drive space allocation to determine if it is possible to upload the file if ((remainingFreeSpace - thisFileSize) < 0) { // no space to upload file, based on tracking of quota values quotaAvailable = false; } else { // there is free space to upload file, based on tracking of quota values quotaAvailable = true; } } else { // set quotaAvailable as true, even though we have zero way to validate that this is correct or not quotaAvailable = true; } } else { // the file will be uploaded to a shared folder // we can't track if there is enough free space to upload the file log.vdebug("File upload destination is a shared folder - the upload may fail if not enough space on OneDrive .."); // set quotaAvailable as true, even though we have zero way to validate that this is correct or not quotaAvailable = true; } // If performing a dry-run or parentPath is found in the database & there is quota available to upload file if ((dryRun) || (parentPathFoundInDB && quotaAvailable)) { // Maximum file size upload // https://support.microsoft.com/en-us/office/invalid-file-names-and-file-types-in-onedrive-and-sharepoint-64883a5d-228e-48f5-b3d2-eb39e07630fa?ui=en-us&rs=en-us&ad=us // July 2020, maximum file size for all accounts is 100GB // January 2021, maximum file size for all accounts is 250GB auto maxUploadFileSize = 268435456000; // 250GB // Can we read the file - as a permissions issue or file corruption will cause a failure // https://github.com/abraunegg/onedrive/issues/113 if (readLocalFile(path)){ // we are able to read the file // To avoid a 409 Conflict error - does the file actually exist on OneDrive already? JSONValue fileDetailsFromOneDrive; if (thisFileSize <= maxUploadFileSize){ // Resolves: https://github.com/skilion/onedrive/issues/121, https://github.com/skilion/onedrive/issues/294, https://github.com/skilion/onedrive/issues/329 // Does this 'file' already exist on OneDrive? try { // test if the local path exists on OneDrive // if parent.driveId is invalid, then API call will generate a 'HTTP 400 - Bad Request' - make sure we at least have a valid parent.driveId if (!parent.driveId.empty) { // use configured value for parent.driveId fileDetailsFromOneDrive = onedrive.getPathDetailsByDriveId(parent.driveId, path); } else { // switch to using defaultDriveId log.vdebug("parent.driveId is empty - using defaultDriveId for API call"); fileDetailsFromOneDrive = onedrive.getPathDetailsByDriveId(defaultDriveId, path); } } catch (OneDriveException e) { // log that we generated an exception log.vdebug("fileDetailsFromOneDrive = onedrive.getPathDetailsByDriveId(parent.driveId, path); generated a OneDriveException"); // OneDrive returned a 'HTTP/1.1 400 Bad Request' // If the 'path', when encoded, cannot be interpreted by the OneDrive API, the API will generate a 400 error if (e.httpStatusCode == 400) { log.log("Skipping uploading this new file: ", buildNormalizedPath(absolutePath(path))); log.vlog("Skipping item - OneDrive returned a 'HTTP 400 - Bad Request' when attempting to query if file exists"); log.error("ERROR: To resolve, rename this local file: ", buildNormalizedPath(absolutePath(path))); uploadFailed = true; return; } // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' if (e.httpStatusCode == 401) { log.vlog("Skipping item - OneDrive returned a 'HTTP 401 - Unauthorized' when attempting to query if file exists"); uploadFailed = true; return; } // A 404 is the expected response if the file was not present if (e.httpStatusCode == 404) { // The file was not found on OneDrive, need to upload it // Check if file should be skipped based on skip_size config if (thisFileSize >= this.newSizeLimit) { log.vlog("Skipping item - excluded by skip_size config: ", path, " (", thisFileSize/2^^20," MB)"); return; } // start of upload file write("Uploading new file ", path, " ... "); JSONValue response; // Calculate upload speed auto uploadStartTime = Clock.currTime(); if (!dryRun) { // Resolve https://github.com/abraunegg/onedrive/issues/37 if (thisFileSize == 0){ // We can only upload zero size files via simpleFileUpload regardless of account type // https://github.com/OneDrive/onedrive-api-docs/issues/53 try { response = onedrive.simpleUpload(path, parent.driveId, parent.id, baseName(path)); } catch (OneDriveException e) { // error uploading file if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying upload request"); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } else { // display what the error is writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } else { // File is not a zero byte file // Are we using OneDrive Personal or OneDrive Business? // To solve 'Multiple versions of file shown on website after single upload' (https://github.com/abraunegg/onedrive/issues/2) // check what 'account type' this is as this issue only affects OneDrive Business so we need some extra logic here if (accountType == "personal"){ // Original file upload logic if (thisFileSize <= thresholdFileSize) { try { response = onedrive.simpleUpload(path, parent.driveId, parent.id, baseName(path)); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying upload request as a session"); // Try upload as a session try { response = session.upload(path, parent.driveId, parent.id, baseName(path)); } catch (OneDriveException e) { // error uploading file if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } else { // display what the error is writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } } else { // display what the error is writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } else { // File larger than threshold - use a session to upload writeln(""); try { response = session.upload(path, parent.driveId, parent.id, baseName(path)); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying upload request"); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } else { // display what the error is writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } } else { // OneDrive Business Account - always use a session to upload writeln(""); try { response = session.upload(path, parent.driveId, parent.id, baseName(path)); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying upload request"); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } else { // display what the error is writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } } // response from OneDrive has to be a valid JSON object if (response.type() == JSONType.object){ // upload done without error writeln("done."); // upload finished auto uploadFinishTime = Clock.currTime(); auto uploadDuration = uploadFinishTime - uploadStartTime; log.vdebug("File Size: ", thisFileSize, " Bytes"); log.vdebug("Upload Duration: ", (uploadDuration.total!"msecs"/1e3), " Seconds"); auto uploadSpeed = (thisFileSize / (uploadDuration.total!"msecs"/1e3)/ 1024 / 1024); log.vdebug("Upload Speed: ", uploadSpeed, " Mbps (approx)"); // Log upload action to log file log.fileOnly("Uploading new file ", path, " ... done."); // The file was uploaded, or a 4xx / 5xx error was generated if ("size" in response){ // The response JSON contains size, high likelihood valid response returned ulong uploadFileSize = response["size"].integer; // In some cases the file that was uploaded was not complete, but 'completed' without errors on OneDrive // This has been seen with PNG / JPG files mainly, which then contributes to generating a 412 error when we attempt to update the metadata // Validate here that the file uploaded, at least in size, matches in the response to what the size is on disk if (thisFileSize != uploadFileSize){ // Upload size did not match local size // There are 2 scenarios where this happens: // 1. Failed Transfer // 2. Upload file is going to a SharePoint Site, where Microsoft enriches the file with additional metadata with no way to disable // For this client: // - If a SharePoint Library, disableUploadValidation gets flagged as True // - If we are syncing a business shared folder, this folder could reside on a Users Path (there should be no upload issue) or SharePoint (upload issue) if ((disableUploadValidation)|| (syncBusinessFolders && (parent.driveId != defaultDriveId))){ // Print a warning message - should only be triggered if: // - disableUploadValidation gets flagged (documentLibrary account type) // - syncBusinessFolders is being used & parent.driveId != defaultDriveId log.log("WARNING: Uploaded file size does not match local file - skipping upload validation"); log.vlog("WARNING: Due to Microsoft Sharepoint 'enrichment' of files, this file is now technically different to your local copy"); log.vlog("See: https://github.com/OneDrive/onedrive-api-docs/issues/935 for further details"); } else { // OK .. the uploaded file does not match and we did not disable this validation log.log("Uploaded file size does not match local file - upload failure - retrying"); // Delete uploaded bad file onedrive.deleteById(response["parentReference"]["driveId"].str, response["id"].str, response["eTag"].str); // Re-upload uploadNewFile(path); return; } } // File validation is OK if ((accountType == "personal") || (thisFileSize == 0)){ // Update the item's metadata on OneDrive string id = response["id"].str; string cTag; // Is there a valid cTag in the response? if ("cTag" in response) { // use the cTag instead of the eTag because OneDrive may update the metadata of files AFTER they have been uploaded cTag = response["cTag"].str; } else { // Is there an eTag in the response? if ("eTag" in response) { // use the eTag from the response as there was no cTag cTag = response["eTag"].str; } else { // no tag available - set to nothing cTag = ""; } } // check if the path exists locally before we try to set the file times if (exists(path)) { SysTime mtime = timeLastModified(path).toUTC(); // update the file modified time on OneDrive and save item details to database uploadLastModifiedTime(parent.driveId, id, cTag, mtime); } else { // will be removed in different event! log.log("File disappeared after upload: ", path); } } else { // OneDrive Business Account - always use a session to upload // The session includes a Request Body element containing lastModifiedDateTime // which negates the need for a modify event against OneDrive // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); } } // update free space tracking if this is our drive id if (parent.driveId == defaultDriveId) { // how much space is left on OneDrive after upload? remainingFreeSpace = (remainingFreeSpace - thisFileSize); log.vlog("Remaining free space on OneDrive: ", remainingFreeSpace); } // File uploaded successfully, space details updated if required return; } else { // response is not valid JSON, an error was returned from OneDrive log.fileOnly("Uploading new file ", path, " ... error"); uploadFailed = true; return; } } else { // we are --dry-run - simulate the file upload writeln("done."); response = createFakeResponse(path); // Log action to log file log.fileOnly("Uploading new file ", path, " ... done."); // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); return; } } // OneDrive returned a '429 - Too Many Requests' if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling uploadNewFile(path);"); uploadNewFile(path); // return back to original call return; } // OneDrive returned a 'HTTP 5xx Server Side Error' - gracefully handling error - error message already logged if (e.httpStatusCode >= 500) { uploadFailed = true; return; } } // Check that the filename that is returned is actually the file we wish to upload // https://docs.microsoft.com/en-us/windows/desktop/FileIO/naming-a-file // Do not assume case sensitivity. For example, consider the names OSCAR, Oscar, and oscar to be the same, // even though some file systems (such as a POSIX-compliant file system) may consider them as different. // Note that NTFS supports POSIX semantics for case sensitivity but this is not the default behavior. // fileDetailsFromOneDrive has to be a valid object if (fileDetailsFromOneDrive.type() == JSONType.object){ // fileDetailsFromOneDrive = onedrive.getPathDetails(path) returned a valid JSON, meaning the file exists on OneDrive // Check that 'name' is in the JSON response (validates data) and that 'name' == the path we are looking for if (("name" in fileDetailsFromOneDrive) && (fileDetailsFromOneDrive["name"].str == baseName(path))) { // OneDrive 'name' matches local path name log.vlog("Requested file to upload exists on OneDrive - local database is out of sync for this file: ", path); // Is the local file newer than the uploaded file? SysTime localFileModifiedTime = timeLastModified(path).toUTC(); SysTime remoteFileModifiedTime = SysTime.fromISOExtString(fileDetailsFromOneDrive["fileSystemInfo"]["lastModifiedDateTime"].str); localFileModifiedTime.fracSecs = Duration.zero; if (localFileModifiedTime > remoteFileModifiedTime){ // local file is newer log.vlog("Requested file to upload is newer than existing file on OneDrive"); write("Uploading modified file ", path, " ... "); JSONValue response; if (!dryRun) { if (accountType == "personal"){ // OneDrive Personal account upload handling if (thisFileSize <= thresholdFileSize) { try { response = onedrive.simpleUpload(path, parent.driveId, parent.id, baseName(path)); writeln("done."); } catch (OneDriveException e) { log.vdebug("response = onedrive.simpleUpload(path, parent.driveId, parent.id, baseName(path)); generated a OneDriveException"); if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling uploadNewFile(path);"); uploadNewFile(path); // return back to original call return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying upload request as a session"); // Try upload as a session try { response = session.upload(path, parent.driveId, parent.id, baseName(path)); writeln("done."); } catch (OneDriveException e) { if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } else { // error uploading file // display what the error is writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } } else { // display what the error is writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } else { // File larger than threshold - use a session to upload writeln(""); try { response = session.upload(path, parent.driveId, parent.id, baseName(path)); writeln("done."); } catch (OneDriveException e) { log.vdebug("response = session.upload(path, parent.driveId, parent.id, baseName(path)); generated a OneDriveException"); if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling uploadNewFile(path);"); uploadNewFile(path); // return back to original call return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying upload request"); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } else { // error uploading file // display what the error is writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } // response from OneDrive has to be a valid JSON object if (response.type() == JSONType.object){ // response is a valid JSON object string id = response["id"].str; string cTag; // Is there a valid cTag in the response? if ("cTag" in response) { // use the cTag instead of the eTag because Onedrive may update the metadata of files AFTER they have been uploaded cTag = response["cTag"].str; } else { // Is there an eTag in the response? if ("eTag" in response) { // use the eTag from the response as there was no cTag cTag = response["eTag"].str; } else { // no tag available - set to nothing cTag = ""; } } // validate if path exists so mtime can be calculated if (exists(path)) { SysTime mtime = timeLastModified(path).toUTC(); uploadLastModifiedTime(parent.driveId, id, cTag, mtime); } else { // will be removed in different event! log.log("File disappeared after upload: ", path); } } else { // Log that an invalid JSON object was returned log.vdebug("onedrive.simpleUpload or session.upload call returned an invalid JSON Object"); return; } } else { // OneDrive Business account modified file upload handling if (accountType == "business"){ // OneDrive Business Account if ((!syncBusinessFolders) || (parent.driveId == defaultDriveId)) { // If we are not syncing Shared Business Folders, or this change is going to the 'users' default drive, handle normally // For logging consistency writeln(""); try { response = session.upload(path, parent.driveId, parent.id, baseName(path), fileDetailsFromOneDrive["eTag"].str); } catch (OneDriveException e) { log.vdebug("response = session.upload(path, parent.driveId, parent.id, baseName(path), fileDetailsFromOneDrive['eTag'].str); generated a OneDriveException"); if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling uploadNewFile(path);"); uploadNewFile(path); // return back to original call return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying upload request"); // Retry original request by calling function again to avoid replicating any further error handling uploadNewFile(path); // return back to original call return; } else { // error uploading file // display what the error is writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } } catch (FileException e) { // display the error message writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return; } // upload complete writeln("done."); saveItem(response); } else { // If we are uploading to a shared business folder, there are a couple of corner cases here: // 1. Shared Folder is a 'users' folder // 2. Shared Folder is a 'SharePoint Library' folder, meaning we get hit by this stupidity: https://github.com/OneDrive/onedrive-api-docs/issues/935 // Need try{} & catch (OneDriveException e) { & catch (FileException e) { handler for this query response = handleSharePointMetadataAdditionBugReplaceFile(fileDetailsFromOneDrive, parent, path); if (!uploadFailed){ // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); } else { // uploadFailed, return return; } } } // OneDrive SharePoint account modified file upload handling if (accountType == "documentLibrary"){ // Depending on the file size, this will depend on how best to handle the modified local file // as if too large, the following error will be generated by OneDrive: // HTTP request returned status code 413 (Request Entity Too Large) // We also cant use a session to upload the file, we have to use simpleUploadReplace // Need try{} & catch (OneDriveException e) { & catch (FileException e) { handler for this query response = handleSharePointMetadataAdditionBugReplaceFile(fileDetailsFromOneDrive, parent, path); if (!uploadFailed){ // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); } else { // uploadFailed, return return; } } } // Log action to log file log.fileOnly("Uploading modified file ", path, " ... done."); // update free space tracking if this is our drive id if (parent.driveId == defaultDriveId) { // how much space is left on OneDrive after upload? remainingFreeSpace = (remainingFreeSpace - thisFileSize); log.vlog("Remaining free space on OneDrive: ", remainingFreeSpace); } } else { // we are --dry-run - simulate the file upload writeln("done."); response = createFakeResponse(path); // Log action to log file log.fileOnly("Uploading modified file ", path, " ... done."); // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); return; } } else { // Save the details of the file that we got from OneDrive // --dry-run safe log.vlog("Updating the local database with details for this file: ", path); if (!dryRun) { // use the live data saveItem(fileDetailsFromOneDrive); } else { // need to fake this data auto fakeResponse = createFakeResponse(path); saveItem(fakeResponse); } } } else { // The files are the "same" name wise but different in case sensitivity log.error("ERROR: A local file has the same name as another local file."); log.error("ERROR: To resolve, rename this local file: ", buildNormalizedPath(absolutePath(path))); log.log("Skipping uploading this new file: ", buildNormalizedPath(absolutePath(path))); } } else { // fileDetailsFromOneDrive is not valid JSON, an error was returned from OneDrive log.error("ERROR: An error was returned from OneDrive and the resulting response is not a valid JSON object"); log.error("ERROR: Increase logging verbosity to assist determining why."); uploadFailed = true; return; } } else { // Skip file - too large log.log("Skipping uploading this new file as it exceeds the maximum size allowed by OneDrive: ", path); uploadFailed = true; return; } } else { // unable to read local file log.log("Skipping uploading this file as it cannot be read (file permissions or file corruption): ", path); } } else { // Upload of the new file did not occur .. why? if (!parentPathFoundInDB) { // Parent path was not found log.log("Skipping uploading this new file as parent path is not in the database: ", path); uploadFailed = true; return; } if (!quotaAvailable) { // Not enough free space log.log("Skipping item '", path, "' due to insufficient free space available on OneDrive"); uploadFailed = true; return; } } } private JSONValue handleSharePointMetadataAdditionBugReplaceFile(JSONValue fileDetailsFromOneDrive, const ref Item parent, const(string) path) { // Explicit function for handling https://github.com/OneDrive/onedrive-api-docs/issues/935 // Replace existing file JSONValue response; // Depending on the file size, this will depend on how best to handle the modified local file // as if too large, the following error will be generated by OneDrive: // HTTP request returned status code 413 (Request Entity Too Large) // We also cant use a session to upload the file, we have to use simpleUploadReplace // Calculate existing hash for this file string existingFileHash = computeQuickXorHash(path); if (getSize(path) <= thresholdFileSize) { // Upload file via simpleUploadReplace as below threshold size try { response = onedrive.simpleUploadReplace(path, fileDetailsFromOneDrive["parentReference"]["driveId"].str, fileDetailsFromOneDrive["id"].str, fileDetailsFromOneDrive["eTag"].str); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return response; } else { // display what the error is writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return response; } } catch (FileException e) { // display the error message writeln("skipped."); log.fileOnly("Uploading modified file ", path, " ... skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return response; } } else { // Have to upload via a session, however we have to delete the file first otherwise this will generate a 404 error post session upload // Remove the existing file onedrive.deleteById(fileDetailsFromOneDrive["parentReference"]["driveId"].str, fileDetailsFromOneDrive["id"].str, fileDetailsFromOneDrive["eTag"].str); // Upload as a session, as a new file writeln(""); try { response = session.upload(path, parent.driveId, parent.id, baseName(path)); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // OneDrive returned a 'HTTP/1.1 401 Unauthorized Error' - file failed to be uploaded writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); log.vlog("OneDrive returned a 'HTTP 401 - Unauthorized' - gracefully handling error"); uploadFailed = true; return response; } else { // display what the error is writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return response; } } catch (FileException e) { // display the error message writeln("skipped."); log.fileOnly("Uploading new file ", path, " ... skipped."); displayFileSystemErrorMessage(e.msg, getFunctionName!({})); uploadFailed = true; return response; } } writeln("done."); // Due to https://github.com/OneDrive/onedrive-api-docs/issues/935 Microsoft modifies all PDF, MS Office & HTML files with added XML content. It is a 'feature' of SharePoint. // So - now the 'local' and 'remote' file is technically DIFFERENT ... thanks Microsoft .. NO way to disable this stupidity string uploadNewFileHash; if (hasQuickXorHash(response)) { // use the response json hash detail to compare uploadNewFileHash = response["file"]["hashes"]["quickXorHash"].str; } if (existingFileHash != uploadNewFileHash) { // file was modified by Microsoft post upload to SharePoint site log.vdebug("Existing Local File Hash: ", existingFileHash); log.vdebug("New Remote File Hash: ", uploadNewFileHash); if(!uploadOnly){ // Download the Microsoft 'modified' file so 'local' is now in sync log.vlog("Due to Microsoft Sharepoint 'enrichment' of files, downloading 'enriched' file to ensure local file is in-sync"); log.vlog("See: https://github.com/OneDrive/onedrive-api-docs/issues/935 for further details"); auto fileSize = response["size"].integer; onedrive.downloadById(response["parentReference"]["driveId"].str, response["id"].str, path, fileSize); } else { // we are not downloading a file, warn that file differences will exist log.vlog("WARNING: Due to Microsoft Sharepoint 'enrichment' of files, this file is now technically different to your local copy"); log.vlog("See: https://github.com/OneDrive/onedrive-api-docs/issues/935 for further details"); } } // return a JSON response so that it can be used and saved return response; } // delete an item on OneDrive private void uploadDeleteItem(Item item, const(string) path) { log.log("Deleting item from OneDrive: ", path); bool flagAsBigDelete = false; // query the database - how many objects will this remove? auto children = getChildren(item.driveId, item.id); long itemsToDelete = count(children); log.vdebug("Number of items to delete: ", itemsToDelete); // Are we running in monitor mode? A local delete of a file will issue a inotify event, which will trigger the local & remote data immediately if (!cfg.getValueBool("monitor")) { // not running in monitor mode if (itemsToDelete > cfg.getValueLong("classify_as_big_delete")) { // A big delete detected flagAsBigDelete = true; if (!cfg.getValueBool("force")) { log.error("ERROR: An attempt to remove a large volume of data from OneDrive has been detected. Exiting client to preserve data on OneDrive"); log.error("ERROR: To delete a large volume of data use --force or increase the config value 'classify_as_big_delete' to a larger value"); // Must exit here to preserve data on OneDrive onedrive.shutdown(); exit(-1); } } } if (!dryRun) { // we are not in a --dry-run situation, process deletion to OneDrive if ((item.driveId == "") && (item.id == "") && (item.eTag == "")){ // These are empty ... we cannot delete if this is empty .... log.vdebug("item.driveId, item.id & item.eTag are empty ... need to query OneDrive for values"); log.vdebug("Checking OneDrive for path: ", path); JSONValue onedrivePathDetails = onedrive.getPathDetails(path); // Returns a JSON String for the OneDrive Path log.vdebug("OneDrive path details: ", onedrivePathDetails); item.driveId = onedrivePathDetails["parentReference"]["driveId"].str; // Should give something like 12345abcde1234a1 item.id = onedrivePathDetails["id"].str; // This item's ID. Should give something like 12345ABCDE1234A1!101 item.eTag = onedrivePathDetails["eTag"].str; // Should be something like aNjM2NjJFRUVGQjY2NjJFMSE5MzUuMA } // do the delete try { // what item are we trying to delete? log.vdebug("Attempting to delete item from drive: ", item.driveId); log.vdebug("Attempting to delete this item id: ", item.id); // perform the delete via the API onedrive.deleteById(item.driveId, item.id, item.eTag); } catch (OneDriveException e) { if (e.httpStatusCode == 404) { // item.id, item.eTag could not be found on driveId log.vlog("OneDrive reported: The resource could not be found."); } else { // Not a 404 response .. is this a 401 response due to some sort of OneDrive Business security policy? if ((e.httpStatusCode == 401) && (accountType != "personal")) { log.vdebug("onedrive.deleteById generated a 401 error response when attempting to delete object by item id"); auto errorArray = splitLines(e.msg); JSONValue errorMessage = parseJSON(replace(e.msg, errorArray[0], "")); if (errorMessage["error"]["message"].str == "Access denied. You do not have permission to perform this action or access this resource.") { // Issue #1041 - Unable to delete OneDrive content when permissions prevent deletion try { log.vdebug("Attempting a reverse delete of all child objects from OneDrive"); foreach_reverse (Item child; children) { log.vdebug("Delete child item from drive: ", child.driveId); log.vdebug("Delete this child item id: ", child.id); onedrive.deleteById(child.driveId, child.id, child.eTag); // delete the child reference in the local database itemdb.deleteById(child.driveId, child.id); } log.vdebug("Delete parent item from drive: ", item.driveId); log.vdebug("Delete this parent item id: ", item.id); onedrive.deleteById(item.driveId, item.id, item.eTag); } catch (OneDriveException e) { // display what the error is log.vdebug("A further error was generated when attempting a reverse delete of objects from OneDrive"); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } } // Not a 404 response .. is this a 403 response due to OneDrive Business Retention Policy being enabled? if ((e.httpStatusCode == 403) && (accountType != "personal")) { log.vdebug("onedrive.deleteById generated a 403 error response when attempting to delete object by item id"); auto errorArray = splitLines(e.msg); JSONValue errorMessage = parseJSON(replace(e.msg, errorArray[0], "")); if (errorMessage["error"]["message"].str == "Request was cancelled by event received. If attempting to delete a non-empty folder, it's possible that it's on hold") { // Issue #338 - Unable to delete OneDrive content when OneDrive Business Retention Policy is enabled try { log.vdebug("Attempting a reverse delete of all child objects from OneDrive"); foreach_reverse (Item child; children) { log.vdebug("Delete child item from drive: ", child.driveId); log.vdebug("Delete this child item id: ", child.id); onedrive.deleteById(child.driveId, child.id, child.eTag); // delete the child reference in the local database itemdb.deleteById(child.driveId, child.id); } log.vdebug("Delete parent item from drive: ", item.driveId); log.vdebug("Delete this parent item id: ", item.id); onedrive.deleteById(item.driveId, item.id, item.eTag); } catch (OneDriveException e) { // display what the error is log.vdebug("A further error was generated when attempting a reverse delete of objects from OneDrive"); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } } else { // Not a 403 response & OneDrive Business Account / O365 Shared Folder / Library log.vdebug("onedrive.deleteById generated an error response when attempting to delete object by item id"); // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } } // delete the reference in the local database itemdb.deleteById(item.driveId, item.id); if (item.remoteId != null) { // If the item is a remote item, delete the reference in the local database itemdb.deleteById(item.remoteDriveId, item.remoteId); } } } // get the children of an item id from the database private Item[] getChildren(string driveId, string id) { Item[] children; children ~= itemdb.selectChildren(driveId, id); foreach (Item child; children) { if (child.type != ItemType.file) { // recursively get the children of this child children ~= getChildren(child.driveId, child.id); } } return children; } // update the item's last modified time private void uploadLastModifiedTime(const(char)[] driveId, const(char)[] id, const(char)[] eTag, SysTime mtime) { string itemModifiedTime; itemModifiedTime = mtime.toISOExtString(); JSONValue data = [ "fileSystemInfo": JSONValue([ "lastModifiedDateTime": itemModifiedTime ]) ]; JSONValue response; try { response = onedrive.updateById(driveId, id, data, eTag); } catch (OneDriveException e) { if (e.httpStatusCode == 412) { // OneDrive threw a 412 error, most likely: ETag does not match current item's value // Retry without eTag log.vdebug("File Metadata Update Failed - OneDrive eTag / cTag match issue"); log.vlog("OneDrive returned a 'HTTP 412 - Precondition Failed' when attempting file time stamp update - gracefully handling error"); string nullTag = null; response = onedrive.updateById(driveId, id, data, nullTag); } } // save the updated response from OneDrive in the database // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); } // save item details into database private void saveItem(JSONValue jsonItem) { // jsonItem has to be a valid object if (jsonItem.type() == JSONType.object){ // Check if the response JSON has an 'id', otherwise makeItem() fails with 'Key not found: id' if (hasId(jsonItem)) { // Are we in a --upload-only & --remove-source-files scenario? // We do not want to add the item to the database in this situation as there is no local reference to the file post file deletion // If the item is a directory, we need to add this to the DB, if this is a file, we dont add this, the parent path is not in DB, thus any new files in this directory are not added if ((uploadOnly) && (localDeleteAfterUpload) && (isItemFile(jsonItem))) { // Log that we skipping adding item to the local DB and the reason why log.vdebug("Skipping adding to database as --upload-only & --remove-source-files configured"); } else { // What is the JSON item we are trying to create a DB record with? log.vdebug("Creating DB item from this JSON: ", jsonItem); // Takes a JSON input and formats to an item which can be used by the database Item item = makeItem(jsonItem); // Add to the local database log.vdebug("Adding to database: ", item); itemdb.upsert(item); // If we have a remote drive ID, add this to our list of known drive id's if (!item.remoteDriveId.empty) { // Keep the driveIDsArray with unique entries only if (!canFind(driveIDsArray, item.remoteDriveId)) { // Add this drive id to the array to search with driveIDsArray ~= item.remoteDriveId; } } } } else { // log error log.error("ERROR: OneDrive response missing required 'id' element"); log.error("ERROR: ", jsonItem); } } else { // log error log.error("ERROR: An error was returned from OneDrive and the resulting response is not a valid JSON object"); log.error("ERROR: Increase logging verbosity to assist determining why."); } } // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_move // This function is only called in monitor mode when an move event is coming from // inotify and we try to move the item. void uploadMoveItem(string from, string to) { log.log("Moving ", from, " to ", to); // 'to' file validation .. is the 'to' file valid for upload? if (isSymlink(to)) { // if config says so we skip all symlinked items if (cfg.getValueBool("skip_symlinks")) { log.vlog("Skipping item - skip symbolic links configured: ", to); return; } // skip unexisting symbolic links else if (!exists(readLink(to))) { log.logAndNotify("Skipping item - invalid symbolic link: ", to); return; } } // Check against Microsoft OneDrive restriction and limitations about Windows naming files if (!isValidName(to)) { log.logAndNotify("Skipping item - invalid name (Microsoft Naming Convention): ", to); return; } // Check for bad whitespace items if (!containsBadWhiteSpace(to)) { log.logAndNotify("Skipping item - invalid name (Contains an invalid whitespace item): ", to); return; } // Check for HTML ASCII Codes as part of file name if (!containsASCIIHTMLCodes(to)) { log.logAndNotify("Skipping item - invalid name (Contains HTML ASCII Code): ", to); return; } // 'to' file has passed file validation Item fromItem, toItem, parentItem; if (!itemdb.selectByPath(from, defaultDriveId, fromItem)) { if (cfg.getValueBool("skip_dotfiles") && isDotFile(to)){ log.log("Skipping upload due to skip_dotfile = true"); return; } else { uploadNewFile(to); return; } } if (fromItem.parentId == null) { // the item is a remote folder, need to do the operation on the parent enforce(itemdb.selectByPathWithoutRemote(from, defaultDriveId, fromItem)); } if (itemdb.selectByPath(to, defaultDriveId, toItem)) { // the destination has been overwritten uploadDeleteItem(toItem, to); } if (!itemdb.selectByPath(dirName(to), defaultDriveId, parentItem)) { // the parent item is not in the database // is the destination a .folder that is being skipped? if (cfg.getValueBool("skip_dotfiles")) { if (isDotFile(dirName(to))) { // target location is a .folder log.vdebug("Target location is excluded from sync due to skip_dotfiles = true"); // item will have been moved locally, but as this is now to a location that is not synced, needs to be removed from OneDrive log.log("Item has been moved to a location that is excluded from sync operations. Removing item from OneDrive"); uploadDeleteItem(fromItem, from); return; } } // some other error throw new SyncException("Can't move an item to an unsynced directory"); } if (cfg.getValueBool("skip_dotfiles") && isDotFile(to)){ log.log("Removing item from OneDrive due to skip_dotfiles = true"); uploadDeleteItem(fromItem, from); return; } if (fromItem.driveId != parentItem.driveId) { // items cannot be moved between drives uploadDeleteItem(fromItem, from); uploadNewFile(to); } else { if (!exists(to)) { log.vlog("uploadMoveItem target has disappeared: ", to); return; } SysTime mtime = timeLastModified(to).toUTC(); JSONValue diff = [ "name": JSONValue(baseName(to)), "parentReference": JSONValue([ "id": parentItem.id ]), "fileSystemInfo": JSONValue([ "lastModifiedDateTime": mtime.toISOExtString() ]) ]; // Perform the move operation on OneDrive JSONValue response; try { response = onedrive.updateById(fromItem.driveId, fromItem.id, diff, fromItem.eTag); } catch (OneDriveException e) { if (e.httpStatusCode == 412) { // OneDrive threw a 412 error, most likely: ETag does not match current item's value // Retry without eTag log.vdebug("File Move Failed - OneDrive eTag / cTag match issue"); log.vlog("OneDrive returned a 'HTTP 412 - Precondition Failed' when attempting to move the file - gracefully handling error"); string nullTag = null; // move the file but without the eTag response = onedrive.updateById(fromItem.driveId, fromItem.id, diff, nullTag); } } // save the move response from OneDrive in the database // Is the response a valid JSON object - validation checking done in saveItem saveItem(response); } } // delete an item by it's path void deleteByPath(const(string) path) { Item item; // Need to check all driveid's we know about, not just the defaultDriveId bool itemInDB = false; foreach (searchDriveId; driveIDsArray) { if (itemdb.selectByPath(path, searchDriveId, item)) { // item was found in the DB itemInDB = true; break; } } if (!itemInDB) { throw new SyncException("The item to delete is not in the local database"); } if (item.parentId == null) { // the item is a remote folder, need to do the operation on the parent enforce(itemdb.selectByPathWithoutRemote(path, defaultDriveId, item)); } try { if (noRemoteDelete) { // do not process remote delete log.vlog("Skipping remote delete as --upload-only & --no-remote-delete configured"); } else { uploadDeleteItem(item, path); } } catch (OneDriveException e) { if (e.httpStatusCode == 404) { log.log(e.msg); } else { // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); } } } // move a OneDrive folder from one name to another void moveByPath(const(string) source, const(string) destination) { log.vlog("Moving remote folder: ", source, " -> ", destination); // Source and Destination are relative to ~/OneDrive string sourcePath = source; string destinationBasePath = dirName(destination).idup; // if destinationBasePath == '.' then destinationBasePath needs to be "" if (destinationBasePath == ".") { destinationBasePath = ""; } string newFolderName = baseName(destination).idup; string destinationPathString = "/drive/root:/" ~ destinationBasePath; // Build up the JSON changes JSONValue moveData = ["name": newFolderName]; JSONValue destinationPath = ["path": destinationPathString]; moveData["parentReference"] = destinationPath; // Make the change on OneDrive auto res = onedrive.moveByPath(sourcePath, moveData); } // Query Office 365 SharePoint Shared Library site to obtain it's Drive ID void querySiteCollectionForDriveID(string o365SharedLibraryName) { // Steps to get the ID: // 1. Query https://graph.microsoft.com/v1.0/sites?search= with the name entered // 2. Evaluate the response. A valid response will contain the description and the id. If the response comes back with nothing, the site name cannot be found or no access // 3. If valid, use the returned ID and query the site drives // https://graph.microsoft.com/v1.0/sites//drives // 4. Display Shared Library Name & Drive ID string site_id; string drive_id; bool found = false; JSONValue siteQuery; string nextLink; string[] siteSearchResults; // The account type must not be a personal account type if (accountType == "personal"){ log.error("ERROR: A OneDrive Personal Account cannot be used with --get-O365-drive-id. Please re-authenticate your client using a OneDrive Business Account."); return; } // What query are we performing? log.log("Office 365 Library Name Query: ", o365SharedLibraryName); for (;;) { try { siteQuery = onedrive.o365SiteSearch(nextLink); } catch (OneDriveException e) { log.error("ERROR: Query of OneDrive for Office 365 Library Name failed"); // Forbidden - most likely authentication scope needs to be updated if (e.httpStatusCode == 403) { log.error("ERROR: Authentication scope needs to be updated. Use --reauth and re-authenticate client."); return; } // Requested resource cannot be found if (e.httpStatusCode == 404) { string siteSearchUrl; if (nextLink.empty) { siteSearchUrl = onedrive.getSiteSearchUrl(); } else { siteSearchUrl = nextLink; } // log the error log.error("ERROR: Your OneDrive Account and Authentication Scope cannot access this OneDrive API: ", siteSearchUrl); log.error("ERROR: To resolve, please discuss this issue with whomever supports your OneDrive and SharePoint environment."); return; } // HTTP request returned status code 429 (Too Many Requests) if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - attempting to query OneDrive drive children"); } // HTTP request returned status code 504 (Gateway Timeout) or 429 retry if ((e.httpStatusCode == 429) || (e.httpStatusCode == 504)) { // re-try the specific changes queries if (e.httpStatusCode == 504) { log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' when attempting to query Sharepoint Sites - retrying applicable request"); log.vdebug("siteQuery = onedrive.o365SiteSearch(nextLink) previously threw an error - retrying"); // The server, while acting as a proxy, did not receive a timely response from the upstream server it needed to access in attempting to complete the request. log.vdebug("Thread sleeping for 30 seconds as the server did not receive a timely response from the upstream server it needed to access in attempting to complete the request"); Thread.sleep(dur!"seconds"(30)); } // re-try original request - retried for 429 and 504 try { log.vdebug("Retrying Query: siteQuery = onedrive.o365SiteSearch(nextLink)"); siteQuery = onedrive.o365SiteSearch(nextLink); log.vdebug("Query 'siteQuery = onedrive.o365SiteSearch(nextLink)' performed successfully on re-try"); } catch (OneDriveException e) { // display what the error is log.vdebug("Query Error: siteQuery = onedrive.o365SiteSearch(nextLink) on re-try after delay"); // error was not a 504 this time displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } else { // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } // is siteQuery a valid JSON object & contain data we can use? if ((siteQuery.type() == JSONType.object) && ("value" in siteQuery)) { // valid JSON object log.vdebug("O365 Query Response: ", siteQuery); foreach (searchResult; siteQuery["value"].array) { // Need an 'exclusive' match here with o365SharedLibraryName as entered log.vdebug("Found O365 Site: ", searchResult); // 'displayName' and 'id' have to be present in the search result record in order to query the site if (("displayName" in searchResult) && ("id" in searchResult)) { if (o365SharedLibraryName == searchResult["displayName"].str){ // 'displayName' matches search request site_id = searchResult["id"].str; JSONValue siteDriveQuery; try { siteDriveQuery = onedrive.o365SiteDrives(site_id); } catch (OneDriveException e) { log.error("ERROR: Query of OneDrive for Office Site ID failed"); // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } // is siteDriveQuery a valid JSON object & contain data we can use? if ((siteDriveQuery.type() == JSONType.object) && ("value" in siteDriveQuery)) { // valid JSON object foreach (driveResult; siteDriveQuery["value"].array) { // Display results writeln("-----------------------------------------------"); log.vdebug("Site Details: ", driveResult); found = true; writeln("Site Name: ", searchResult["displayName"].str); writeln("Library Name: ", driveResult["name"].str); writeln("drive_id: ", driveResult["id"].str); writeln("Library URL: ", driveResult["webUrl"].str); } // closeout writeln("-----------------------------------------------"); } else { // not a valid JSON object log.error("ERROR: There was an error performing this operation on OneDrive"); log.error("ERROR: Increase logging verbosity to assist determining why."); return; } } } else { // 'displayName', 'id' or ''webUrl' not present in JSON results for a specific site string siteNameAvailable = "Site 'name' was restricted by OneDrive API permissions"; bool displayNameAvailable = false; bool idAvailable = false; if ("name" in searchResult) siteNameAvailable = searchResult["name"].str; if ("displayName" in searchResult) displayNameAvailable = true; if ("id" in searchResult) idAvailable = true; // Display error details for this site data writeln(); log.error("ERROR: SharePoint Site details not provided for: ", siteNameAvailable); log.error("ERROR: The SharePoint Site results returned from OneDrive API do not contain the required items to match. Please check your permissions with your site administrator."); log.error("ERROR: Your site security settings is preventing the following details from being accessed: 'displayName' or 'id'"); log.vlog(" - Is 'displayName' available = ", displayNameAvailable); log.vlog(" - Is 'id' available = ", idAvailable); log.error("ERROR: To debug this further, please increase verbosity (--verbose or --verbose --verbose) to provide further insight as to what details are actually being returned."); } } if(!found) { // The SharePoint site we are searching for was not found in this bundle set // Add to siteSearchResults so we can display what we did find string siteSearchResultsEntry; foreach (searchResult; siteQuery["value"].array) { // We can only add the displayName if it is available if ("displayName" in searchResult) { // Use the displayName siteSearchResultsEntry = " * " ~ searchResult["displayName"].str; siteSearchResults ~= siteSearchResultsEntry; } else { // Add, but indicate displayName unavailable, use id if ("id" in searchResult) { siteSearchResultsEntry = " * " ~ "Unknown displayName (Data not provided by API), Site ID: " ~ searchResult["id"].str; siteSearchResults ~= siteSearchResultsEntry; } else { // displayName and id unavailable, display in debug log the entry log.vdebug("Bad SharePoint Data for site: ", searchResult); } } } } } else { // not a valid JSON object log.error("ERROR: There was an error performing this operation on OneDrive"); log.error("ERROR: Increase logging verbosity to assist determining why."); return; } // If a collection exceeds the default page size (200 items), the @odata.nextLink property is returned in the response // to indicate more items are available and provide the request URL for the next page of items. if ("@odata.nextLink" in siteQuery) { // Update nextLink to next set of SharePoint library names nextLink = siteQuery["@odata.nextLink"].str; log.vdebug("Setting nextLink to (@odata.nextLink): ", nextLink); } else break; } // Was the intended target found? if(!found) { writeln(); log.error("ERROR: The requested SharePoint site could not be found. Please check it's name and your permissions to access the site."); // List all sites returned to assist user writeln(); log.log("The following SharePoint site names were returned:"); foreach (searchResultEntry; siteSearchResults) { // list the display name that we use to match against the user query log.log(searchResultEntry); } } } // Create an anonymous read-only shareable link for an existing file on OneDrive void createShareableLinkForFile(string filePath, bool writeablePermissions) { JSONValue onedrivePathDetails; JSONValue createShareableLinkResponse; string driveId; string itemId; string fileShareLink; // Get the path details from OneDrive try { onedrivePathDetails = onedrive.getPathDetails(filePath); // Returns a JSON String for the OneDrive Path } catch (OneDriveException e) { log.vdebug("onedrivePathDetails = onedrive.getPathDetails(filePath); generated a OneDriveException"); if (e.httpStatusCode == 404) { // Requested path could not be found log.error("ERROR: The requested path to query was not found on OneDrive"); log.error("ERROR: Cannot create a shareable link for a file that does not exist on OneDrive"); return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling queryDriveForChanges(path);"); createShareableLinkForFile(filePath, writeablePermissions); // return back to original call return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying request"); // Retry original request by calling function again to avoid replicating any further error handling createShareableLinkForFile(filePath, writeablePermissions); // return back to original call return; } else { // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } // Was a valid JSON response received? if (onedrivePathDetails.type() == JSONType.object) { // valid JSON response for the file was received // Configure the required variables driveId = onedrivePathDetails["parentReference"]["driveId"].str; itemId = onedrivePathDetails["id"].str; // What sort of shareable link is required? JSONValue accessScope; if (writeablePermissions) { // configure the read-write access scope accessScope = [ "type": "edit", "scope": "anonymous" ]; } else { // configure the read-only access scope (default) accessScope = [ "type": "view", "scope": "anonymous" ]; } // Create the shareable file link createShareableLinkResponse = onedrive.createShareableLink(driveId, itemId, accessScope); if ((createShareableLinkResponse.type() == JSONType.object) && ("link" in createShareableLinkResponse)) { // Extract the file share link from the JSON response fileShareLink = createShareableLinkResponse["link"]["webUrl"].str; writeln("File Shareable Link: ", fileShareLink); if (writeablePermissions) { writeln("Shareable Link has read-write permissions - use and provide with caution"); } } else { // not a valid JSON object log.error("ERROR: There was an error performing this operation on OneDrive"); log.error("ERROR: Increase logging verbosity to assist determining why."); return; } } else { // not a valid JSON object log.error("ERROR: There was an error performing this operation on OneDrive"); log.error("ERROR: Increase logging verbosity to assist determining why."); return; } } // Query OneDrive for file details of a given path void queryOneDriveForFileDetails(string localFilePath, string syncDir, string outputType) { // Query if file is valid locally if (exists(localFilePath)) { // File exists locally, does it exist in the database // Path needs to be relative to sync_dir path Item item; string[] distinctDriveIds = itemdb.selectDistinctDriveIds(); string relativePath = relativePath(localFilePath, syncDir); bool fileInDB = false; foreach (searchDriveId; distinctDriveIds) { if (itemdb.selectByPath(relativePath, searchDriveId, item)) { // File is in the local database cache fileInDB = true; JSONValue fileDetails; try { fileDetails = onedrive.getFileDetails(item.driveId, item.id); } catch (OneDriveException e) { // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } // debug output of response log.vdebug("API Response: ", fileDetails); // What sort of response to we generate // --get-file-link response if (outputType == "URL") { if ((fileDetails.type() == JSONType.object) && ("webUrl" in fileDetails)) { // Valid JSON object writeln(fileDetails["webUrl"].str); } } // --modified-by response if (outputType == "ModifiedBy") { if ((fileDetails.type() == JSONType.object) && ("lastModifiedBy" in fileDetails)) { // Valid JSON object writeln("Last modified: ", fileDetails["lastModifiedDateTime"].str); writeln("Last modified by: ", fileDetails["lastModifiedBy"]["user"]["displayName"].str); // if 'email' provided, add this to the output if ("email" in fileDetails["lastModifiedBy"]["user"]) { writeln("Email Address: ", fileDetails["lastModifiedBy"]["user"]["email"].str); } } } } } // was path found? if (!fileInDB) { // File has not been synced with OneDrive log.error("Path has not been synced with OneDrive: ", localFilePath); } } else { // File does not exist locally log.error("Path not found on local system: ", localFilePath); } } // Query the OneDrive 'drive' to determine if we are 'in sync' or if there are pending changes void queryDriveForChanges(const(string) path) { // Function variables int validChanges = 0; long downloadSize = 0; string driveId; string folderId; string deltaLink; string thisItemId; string thisItemParentPath; string syncFolderName; string syncFolderPath; string syncFolderChildPath; JSONValue changes; JSONValue onedrivePathDetails; // Get the path details from OneDrive try { onedrivePathDetails = onedrive.getPathDetails(path); // Returns a JSON String for the OneDrive Path } catch (OneDriveException e) { log.vdebug("onedrivePathDetails = onedrive.getPathDetails(path); generated a OneDriveException"); if (e.httpStatusCode == 404) { // Requested path could not be found log.error("ERROR: The requested path to query was not found on OneDrive"); return; } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling queryDriveForChanges(path);"); queryDriveForChanges(path); // return back to original call return; } if (e.httpStatusCode == 504) { // HTTP request returned status code 504 (Gateway Timeout) log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' - retrying request"); // Retry original request by calling function again to avoid replicating any further error handling queryDriveForChanges(path); // return back to original call return; } else { // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } if(isItemRemote(onedrivePathDetails)){ // remote changes driveId = onedrivePathDetails["remoteItem"]["parentReference"]["driveId"].str; // Should give something like 66d53be8a5056eca folderId = onedrivePathDetails["remoteItem"]["id"].str; // Should give something like BC7D88EC1F539DCF!107 syncFolderName = onedrivePathDetails["name"].str; // A remote drive item will not have ["parentReference"]["path"] syncFolderPath = ""; syncFolderChildPath = ""; } else { driveId = defaultDriveId; folderId = onedrivePathDetails["id"].str; // Should give something like 12345ABCDE1234A1!101 syncFolderName = onedrivePathDetails["name"].str; if (hasParentReferencePath(onedrivePathDetails)) { syncFolderPath = onedrivePathDetails["parentReference"]["path"].str; syncFolderChildPath = syncFolderPath ~ "/" ~ syncFolderName ~ "/"; } else { // root drive item will not have ["parentReference"]["path"] syncFolderPath = ""; syncFolderChildPath = ""; } } // Query Database for the deltaLink deltaLink = itemdb.getDeltaLink(driveId, folderId); const(char)[] idToQuery; if (driveId == defaultDriveId) { // The drive id matches our users default drive id idToQuery = defaultRootId.dup; } else { // The drive id does not match our users default drive id // Potentially the 'path id' we are requesting the details of is a Shared Folder (remote item) // Use folderId idToQuery = folderId; } // Query OneDrive changes try { changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink); } catch (OneDriveException e) { if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - calling queryDriveForChanges(path);"); queryDriveForChanges(path); // return back to original call return; } else { // OneDrive threw an error log.vdebug("Error query: changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink)"); log.vdebug("OneDrive threw an error when querying for these changes:"); log.vdebug("driveId: ", driveId); log.vdebug("idToQuery: ", idToQuery); log.vdebug("Previous deltaLink: ", deltaLink); displayOneDriveErrorMessage(e.msg, getFunctionName!({})); return; } } // Are there any changes on OneDrive? if (count(changes["value"].array) != 0) { // Were we given a remote path to check if we are in sync for, or the root? if (path != "/") { // we were given a directory to check, we need to validate the list of changes against this path only foreach (item; changes["value"].array) { // Is this change valid for the 'path' we are checking? if (hasParentReferencePath(item)) { thisItemId = item["parentReference"]["id"].str; thisItemParentPath = item["parentReference"]["path"].str; } else { thisItemId = item["id"].str; // Is the defaultDriveId == driveId if (driveId == defaultDriveId){ // 'root' items will not have ["parentReference"]["path"] if (isItemRoot(item)){ thisItemParentPath = ""; } else { thisItemParentPath = item["parentReference"]["path"].str; } } else { // A remote drive item will not have ["parentReference"]["path"] thisItemParentPath = ""; } } if ( (thisItemId == folderId) || (canFind(thisItemParentPath, syncFolderChildPath)) || (canFind(thisItemParentPath, folderId)) ){ // This is a change we want count validChanges++; if ((isItemFile(item)) && (hasFileSize(item))) { downloadSize = downloadSize + item["size"].integer; } } } // Are there any valid changes? if (validChanges != 0){ writeln("Selected directory is out of sync with OneDrive"); if (downloadSize > 0){ downloadSize = downloadSize / 1000; writeln("Approximate data to download from OneDrive: ", downloadSize, " KB"); } } else { writeln("No pending remote changes - selected directory is in sync"); } } else { writeln("Local directory is out of sync with OneDrive"); foreach (item; changes["value"].array) { if ((isItemFile(item)) && (hasFileSize(item))) { downloadSize = downloadSize + item["size"].integer; } } if (downloadSize > 0){ downloadSize = downloadSize / 1000; writeln("Approximate data to download from OneDrive: ", downloadSize, " KB"); } } } else { writeln("No pending remote changes - in sync"); } } // Create a fake OneDrive response suitable for use with saveItem JSONValue createFakeResponse(const(string) path) { import std.digest.sha; // Generate a simulated JSON response which can be used // At a minimum we need: // 1. eTag // 2. cTag // 3. fileSystemInfo // 4. file or folder. if file, hash of file // 5. id // 6. name // 7. parent reference string fakeDriveId = defaultDriveId; string fakeRootId = defaultRootId; SysTime mtime = timeLastModified(path).toUTC(); // Need to update the 'fakeDriveId' & 'fakeRootId' with elements from the --dry-run database // Otherwise some calls to validate objects will fail as the actual driveId being used is invalid string parentPath = dirName(path); Item databaseItem; if (parentPath != ".") { // Not a 'root' parent // For each driveid in the existing driveIDsArray foreach (searchDriveId; driveIDsArray) { log.vdebug("FakeResponse: searching database for: ", searchDriveId, " ", parentPath); if (itemdb.selectByPath(parentPath, searchDriveId, databaseItem)) { log.vdebug("FakeResponse: Found Database Item: ", databaseItem); fakeDriveId = databaseItem.driveId; fakeRootId = databaseItem.id; } } } // real id / eTag / cTag are different format for personal / business account auto sha1 = new SHA1Digest(); ubyte[] fakedOneDriveItemValues = sha1.digest(path); JSONValue fakeResponse; if (isDir(path)) { // path is a directory fakeResponse = [ "id": JSONValue(toHexString(fakedOneDriveItemValues)), "cTag": JSONValue(toHexString(fakedOneDriveItemValues)), "eTag": JSONValue(toHexString(fakedOneDriveItemValues)), "fileSystemInfo": JSONValue([ "createdDateTime": mtime.toISOExtString(), "lastModifiedDateTime": mtime.toISOExtString() ]), "name": JSONValue(baseName(path)), "parentReference": JSONValue([ "driveId": JSONValue(fakeDriveId), "driveType": JSONValue(accountType), "id": JSONValue(fakeRootId) ]), "folder": JSONValue("") ]; } else { // path is a file // compute file hash - both business and personal responses use quickXorHash string quickXorHash = computeQuickXorHash(path); fakeResponse = [ "id": JSONValue(toHexString(fakedOneDriveItemValues)), "cTag": JSONValue(toHexString(fakedOneDriveItemValues)), "eTag": JSONValue(toHexString(fakedOneDriveItemValues)), "fileSystemInfo": JSONValue([ "createdDateTime": mtime.toISOExtString(), "lastModifiedDateTime": mtime.toISOExtString() ]), "name": JSONValue(baseName(path)), "parentReference": JSONValue([ "driveId": JSONValue(fakeDriveId), "driveType": JSONValue(accountType), "id": JSONValue(fakeRootId) ]), "file": JSONValue([ "hashes":JSONValue([ "quickXorHash": JSONValue(quickXorHash) ]) ]) ]; } log.vdebug("Generated Fake OneDrive Response: ", fakeResponse); return fakeResponse; } void handleOneDriveThrottleRequest() { // If OneDrive sends a status code 429 then this function will be used to process the Retry-After response header which contains the value by which we need to wait log.vdebug("Handling a OneDrive HTTP 429 Response Code (Too Many Requests)"); // Read in the Retry-After HTTP header as set and delay as per this value before retrying the request auto retryAfterValue = onedrive.getRetryAfterValue(); log.vdebug("Using Retry-After Value = ", retryAfterValue); // HTTP request returned status code 429 (Too Many Requests) // https://github.com/abraunegg/onedrive/issues/133 // https://github.com/abraunegg/onedrive/issues/815 ulong delayBeforeRetry = 0; if (retryAfterValue != 0) { // Use the HTTP Response Header Value delayBeforeRetry = retryAfterValue; } else { // Use a 120 second delay as a default given header value was zero // This value is based on log files and data when determining correct process for 429 response handling delayBeforeRetry = 120; // Update that we are over-riding the provided value with a default log.vdebug("HTTP Response Header retry-after value was 0 - Using a preconfigured default of: ", delayBeforeRetry); } // Sleep thread as per request log.log("Thread sleeping due to 'HTTP request returned status code 429' - The request has been throttled"); log.log("Sleeping for ", delayBeforeRetry, " seconds"); Thread.sleep(dur!"seconds"(delayBeforeRetry)); // Reset retry-after value to zero as we have used this value now and it may be changed in the future to a different value onedrive.resetRetryAfterValue(); } // Generage a /delta compatible response when using National Azure AD deployments that do not support /delta queries // see: https://docs.microsoft.com/en-us/graph/deployments#supported-features JSONValue generateDeltaResponse(const(char)[] driveId, const(char)[] idToQuery) { // JSON value which will be responded with JSONValue deltaResponse; // initial data JSONValue rootData; JSONValue driveData; JSONValue topLevelChildren; JSONValue[] childrenData; string nextLink; // Get drive details for the provided driveId try { driveData = onedrive.getPathDetailsById(driveId, idToQuery); } catch (OneDriveException e) { log.vdebug("driveData = onedrive.getPathDetailsById(driveId, idToQuery) generated a OneDriveException"); // HTTP request returned status code 504 (Gateway Timeout) or 429 retry if ((e.httpStatusCode == 429) || (e.httpStatusCode == 504)) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. if (e.httpStatusCode == 429) { log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - retrying applicable request"); handleOneDriveThrottleRequest(); } if (e.httpStatusCode == 504) { log.vdebug("Retrying original request that generated the HTTP 504 (Gateway Timeout) - retrying applicable request"); Thread.sleep(dur!"seconds"(30)); } // Retry original request by calling function again to avoid replicating any further error handling driveData = onedrive.getPathDetailsById(driveId, idToQuery); } else { // There was a HTTP 5xx Server Side Error displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Must exit here onedrive.shutdown(); exit(-1); } } if (!isItemRoot(driveData)) { // Get root details for the provided driveId try { rootData = onedrive.getDriveIdRoot(driveId); } catch (OneDriveException e) { log.vdebug("rootData = onedrive.getDriveIdRoot(driveId) generated a OneDriveException"); // HTTP request returned status code 504 (Gateway Timeout) or 429 retry if ((e.httpStatusCode == 429) || (e.httpStatusCode == 504)) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. if (e.httpStatusCode == 429) { log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - retrying applicable request"); handleOneDriveThrottleRequest(); } if (e.httpStatusCode == 504) { log.vdebug("Retrying original request that generated the HTTP 504 (Gateway Timeout) - retrying applicable request"); Thread.sleep(dur!"seconds"(30)); } // Retry original request by calling function again to avoid replicating any further error handling rootData = onedrive.getDriveIdRoot(driveId); } else { // There was a HTTP 5xx Server Side Error displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Must exit here onedrive.shutdown(); exit(-1); } } // Add driveData JSON data to array log.vlog("Adding OneDrive root details for processing"); childrenData ~= rootData; } // Add driveData JSON data to array log.vlog("Adding OneDrive folder details for processing"); childrenData ~= driveData; for (;;) { // query top level children try { topLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink); } catch (OneDriveException e) { // OneDrive threw an error log.vdebug("------------------------------------------------------------------"); log.vdebug("Query Error: topLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink)"); log.vdebug("driveId: ", driveId); log.vdebug("idToQuery: ", idToQuery); log.vdebug("nextLink: ", nextLink); // HTTP request returned status code 404 (Not Found) if (e.httpStatusCode == 404) { // Stop application log.log("\n\nOneDrive returned a 'HTTP 404 - Item not found'"); log.log("The item id to query was not found on OneDrive"); log.log("\nRemove your '", cfg.databaseFilePath, "' file and try to sync again\n"); } // HTTP request returned status code 429 (Too Many Requests) if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - attempting to query OneDrive drive children"); } // HTTP request returned status code 500 (Internal Server Error) if (e.httpStatusCode == 500) { // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); } // HTTP request returned status code 504 (Gateway Timeout) or 429 retry if ((e.httpStatusCode == 429) || (e.httpStatusCode == 504)) { // re-try the specific changes queries if (e.httpStatusCode == 504) { log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' when attempting to query OneDrive drive children - retrying applicable request"); log.vdebug("topLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink) previously threw an error - retrying"); // The server, while acting as a proxy, did not receive a timely response from the upstream server it needed to access in attempting to complete the request. log.vdebug("Thread sleeping for 30 seconds as the server did not receive a timely response from the upstream server it needed to access in attempting to complete the request"); Thread.sleep(dur!"seconds"(30)); } // re-try original request - retried for 429 and 504 try { log.vdebug("Retrying Query: topLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink)"); topLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink); log.vdebug("Query 'topLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink)' performed successfully on re-try"); } catch (OneDriveException e) { // display what the error is log.vdebug("Query Error: topLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink) on re-try after delay"); // error was not a 504 this time displayOneDriveErrorMessage(e.msg, getFunctionName!({})); } } else { // Default operation if not 404, 410, 429, 500 or 504 errors // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); } } // process top level children log.vlog("Adding ", count(topLevelChildren["value"].array), " OneDrive items for processing from OneDrive folder"); foreach (child; topLevelChildren["value"].array) { // add this child to the array of objects childrenData ~= child; // is this child a folder? if (isItemFolder(child)){ // We have to query this folders children if childCount > 0 if (child["folder"]["childCount"].integer > 0){ // This child folder has children string childIdToQuery = child["id"].str; string childDriveToQuery = child["parentReference"]["driveId"].str; auto childParentPath = child["parentReference"]["path"].str.split(":"); string folderPathToScan = childParentPath[1] ~ "/" ~ child["name"].str; string pathForLogging = "/" ~ driveData["name"].str ~ "/" ~ child["name"].str; JSONValue[] grandChildrenData = queryForChildren(childDriveToQuery, childIdToQuery, folderPathToScan, pathForLogging); foreach (grandChild; grandChildrenData.array) { // add the grandchild to the array childrenData ~= grandChild; } } } } // If a collection exceeds the default page size (200 items), the @odata.nextLink property is returned in the response // to indicate more items are available and provide the request URL for the next page of items. if ("@odata.nextLink" in topLevelChildren) { // Update nextLink to next changeSet bundle log.vdebug("Setting nextLink to (@odata.nextLink): ", nextLink); nextLink = topLevelChildren["@odata.nextLink"].str; } else break; } // craft response from all returned elements deltaResponse = [ "@odata.context": JSONValue("https://graph.microsoft.com/v1.0/$metadata#Collection(driveItem)"), "value": JSONValue(childrenData.array) ]; // return the generated JSON response return deltaResponse; } // query child for children JSONValue[] queryForChildren(const(char)[] driveId, const(char)[] idToQuery, const(char)[] childParentPath, string pathForLogging) { // function variables JSONValue thisLevelChildren; JSONValue[] thisLevelChildrenData; string nextLink; for (;;) { // query children thisLevelChildren = queryThisLevelChildren(driveId, idToQuery, nextLink); // process this level children if (!childParentPath.empty) { // We dont use childParentPath to log, as this poses an information leak risk. // The full parent path of the child, as per the JSON might be: // /Level 1/Level 2/Level 3/Child Shared Folder/some folder/another folder // But 'Child Shared Folder' is what is shared, thus '/Level 1/Level 2/Level 3/' is a potential information leak if logged. // Plus, the application output now shows accuratly what is being shared - so that is a good thing. log.vlog("Adding ", count(thisLevelChildren["value"].array), " OneDrive items for processing from ", pathForLogging); } foreach (child; thisLevelChildren["value"].array) { // add this child to the array of objects thisLevelChildrenData ~= child; // is this child a folder? if (isItemFolder(child)){ // We have to query this folders children if childCount > 0 if (child["folder"]["childCount"].integer > 0){ // This child folder has children string childIdToQuery = child["id"].str; string childDriveToQuery = child["parentReference"]["driveId"].str; auto grandchildParentPath = child["parentReference"]["path"].str.split(":"); string folderPathToScan = grandchildParentPath[1] ~ "/" ~ child["name"].str; string newLoggingPath = pathForLogging ~ "/" ~ child["name"].str; JSONValue[] grandChildrenData = queryForChildren(childDriveToQuery, childIdToQuery, folderPathToScan, newLoggingPath); foreach (grandChild; grandChildrenData.array) { // add the grandchild to the array thisLevelChildrenData ~= grandChild; } } } } // If a collection exceeds the default page size (200 items), the @odata.nextLink property is returned in the response // to indicate more items are available and provide the request URL for the next page of items. if ("@odata.nextLink" in thisLevelChildren) { // Update nextLink to next changeSet bundle nextLink = thisLevelChildren["@odata.nextLink"].str; log.vdebug("Setting nextLink to (@odata.nextLink): ", nextLink); } else break; } // return response return thisLevelChildrenData; } // Query from OneDrive the child objects for this element JSONValue queryThisLevelChildren(const(char)[] driveId, const(char)[] idToQuery, string nextLink) { JSONValue thisLevelChildren; // query children try { // attempt API call log.vdebug("Attempting Query: thisLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink)"); thisLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink); log.vdebug("Query 'thisLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink)' performed successfully"); } catch (OneDriveException e) { // OneDrive threw an error log.vdebug("------------------------------------------------------------------"); log.vdebug("Query Error: thisLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink)"); log.vdebug("driveId: ", driveId); log.vdebug("idToQuery: ", idToQuery); log.vdebug("nextLink: ", nextLink); // HTTP request returned status code 404 (Not Found) if (e.httpStatusCode == 404) { // Stop application log.log("\n\nOneDrive returned a 'HTTP 404 - Item not found'"); log.log("The item id to query was not found on OneDrive"); log.log("\nRemove your '", cfg.databaseFilePath, "' file and try to sync again\n"); } // HTTP request returned status code 429 (Too Many Requests) if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - attempting to query OneDrive drive children"); } // HTTP request returned status code 504 (Gateway Timeout) or 429 retry if ((e.httpStatusCode == 429) || (e.httpStatusCode == 504)) { // re-try the specific changes queries if (e.httpStatusCode == 504) { // transient error - try again in 30 seconds log.log("OneDrive returned a 'HTTP 504 - Gateway Timeout' when attempting to query OneDrive drive children - retrying applicable request"); log.vdebug("thisLevelChildren = onedrive.listChildren(driveId, idToQuery, nextLink) previously threw an error - retrying"); // The server, while acting as a proxy, did not receive a timely response from the upstream server it needed to access in attempting to complete the request. log.vdebug("Thread sleeping for 30 seconds as the server did not receive a timely response from the upstream server it needed to access in attempting to complete the request"); Thread.sleep(dur!"seconds"(30)); } // re-try original request - retried for 429 and 504 - but loop back calling this function log.vdebug("Retrying Query: thisLevelChildren = queryThisLevelChildren(driveId, idToQuery, nextLink)"); thisLevelChildren = queryThisLevelChildren(driveId, idToQuery, nextLink); } else { // Default operation if not 404, 429 or 504 errors // display what the error is displayOneDriveErrorMessage(e.msg, getFunctionName!({})); } } // return response return thisLevelChildren; } // OneDrive Business Shared Folder support void listOneDriveBusinessSharedFolders() { // List OneDrive Business Shared Folders log.log("\nListing available OneDrive Business Shared Folders:"); // Query the GET /me/drive/sharedWithMe API JSONValue graphQuery; try { graphQuery = onedrive.getSharedWithMe(); } catch (OneDriveException e) { if (e.httpStatusCode == 401) { // HTTP request returned status code 401 (Unauthorized) displayOneDriveErrorMessage(e.msg, getFunctionName!({})); handleClientUnauthorised(); } if (e.httpStatusCode == 429) { // HTTP request returned status code 429 (Too Many Requests). We need to leverage the response Retry-After HTTP header to ensure minimum delay until the throttle is removed. handleOneDriveThrottleRequest(); // Retry original request by calling function again to avoid replicating any further error handling log.vdebug("Retrying original request that generated the OneDrive HTTP 429 Response Code (Too Many Requests) - graphQuery = onedrive.getSharedWithMe();"); graphQuery = onedrive.getSharedWithMe(); } if (e.httpStatusCode >= 500) { // There was a HTTP 5xx Server Side Error displayOneDriveErrorMessage(e.msg, getFunctionName!({})); // Must exit here onedrive.shutdown(); exit(-1); } } if (graphQuery.type() == JSONType.object) { if (count(graphQuery["value"].array) == 0) { // no shared folders returned write("\nNo OneDrive Business Shared Folders were returned\n"); } else { // shared folders were returned log.vdebug("onedrive.getSharedWithMe API Response: ", graphQuery); foreach (searchResult; graphQuery["value"].array) { // loop variables string sharedFolderName; string sharedByName; string sharedByEmail; // is the shared item with us a 'folder' ? // we only handle folders, not files or other items if (isItemFolder(searchResult)) { // Debug response output log.vdebug("shared folder entry: ", searchResult); sharedFolderName = searchResult["name"].str; // configure who this was shared by if ("sharedBy" in searchResult["remoteItem"]["shared"]) { // we have shared by details we can use if ("displayName" in searchResult["remoteItem"]["shared"]["sharedBy"]["user"]) { sharedByName = searchResult["remoteItem"]["shared"]["sharedBy"]["user"]["displayName"].str; } if ("email" in searchResult["remoteItem"]["shared"]["sharedBy"]["user"]) { sharedByEmail = searchResult["remoteItem"]["shared"]["sharedBy"]["user"]["email"].str; } } // Output query result log.log("---------------------------------------"); log.log("Shared Folder: ", sharedFolderName); if ((sharedByName != "") && (sharedByEmail != "")) { log.log("Shared By: ", sharedByName, " (", sharedByEmail, ")"); } else { if (sharedByName != "") { log.log("Shared By: ", sharedByName); } } log.vlog("Item Id: ", searchResult["remoteItem"]["id"].str); log.vlog("Parent Drive Id: ", searchResult["remoteItem"]["parentReference"]["driveId"].str); if ("id" in searchResult["remoteItem"]["parentReference"]) { log.vlog("Parent Item Id: ", searchResult["remoteItem"]["parentReference"]["id"].str); } } } } write("\n"); } else { // Log that an invalid JSON object was returned log.error("ERROR: onedrive.getSharedWithMe call returned an invalid JSON Object"); } } // Query itemdb.computePath() and catch potential assert when DB consistency issue occurs string computeItemPath(string thisDriveId, string thisItemId) { static import core.exception; string calculatedPath; log.vdebug("Attempting to calculate local filesystem path for ", thisDriveId, " and ", thisItemId); try { calculatedPath = itemdb.computePath(thisDriveId, thisItemId); } catch (core.exception.AssertError) { // broken tree in the database, we cant compute the path for this item id, exit log.error("ERROR: A database consistency issue has been caught. A --resync is needed to rebuild the database."); // Must exit here to preserve data onedrive.shutdown(); exit(-1); } // return calculated path as string return calculatedPath; } void handleClientUnauthorised() { // common code for handling when a client is unauthorised writeln(); log.errorAndNotify("ERROR: Check your configuration as your refresh_token may be empty or invalid. You may need to issue a --reauth and re-authorise this client."); writeln(); // Must exit here onedrive.shutdown(); exit(-1); } // Wrapper function for makeDatabaseItem so we can check if the item, if a file, has any hashes private Item makeItem(JSONValue onedriveJSONItem) { Item newDatabaseItem = makeDatabaseItem(onedriveJSONItem); // Check for hashes in this DB item if (newDatabaseItem.type == ItemType.file) { // Does this file have a size greater than 0 - zero size files will potentially not have a hash if (hasFileSize(onedriveJSONItem)) { if (onedriveJSONItem["size"].integer > 0) { // Does the item have any hashes? if ((newDatabaseItem.quickXorHash.empty) && (newDatabaseItem.sha256Hash.empty)) { // Odd .. no hash ...... string apiMessage = "WARNING: OneDrive API inconsistency - this file does not have any hash: "; // This is computationally expensive .. but we are only doing this if there are no hashses provided: bool parentInDatabase = itemdb.idInLocalDatabase(newDatabaseItem.driveId, newDatabaseItem.parentId); if (parentInDatabase) { // Calculate this item path string newItemPath = computeItemPath(newDatabaseItem.driveId, newDatabaseItem.parentId) ~ "/" ~ newDatabaseItem.name; log.log(apiMessage, newItemPath); } else { // Use the item ID log.log(apiMessage, newDatabaseItem.id); } } } } } return newDatabaseItem; } }