From 9cc72c2396014b2f6162426760ffd5cd1980dda2 Mon Sep 17 00:00:00 2001 From: abraunegg Date: Sat, 27 Jun 2020 19:10:37 +1000 Subject: [PATCH] Implement OneDrive Business Shared Folders Support (Issue #459) (#473) * Implement OneDrive Business Shared Folders Support --- Makefile.in | 2 +- README.md | 7 +- config | 2 + docs/BusinessSharedFolders.md | 187 +++++++++ docs/images/shared_with_me.JPG | Bin 0 -> 46012 bytes src/config.d | 22 +- src/itemdb.d | 43 +- src/main.d | 121 +++++- src/onedrive.d | 56 ++- src/selective.d | 425 +++++++++++--------- src/sync.d | 708 +++++++++++++++++++++++++++------ 11 files changed, 1233 insertions(+), 340 deletions(-) create mode 100644 docs/BusinessSharedFolders.md create mode 100644 docs/images/shared_with_me.JPG diff --git a/Makefile.in b/Makefile.in index 152869f7..151f02f9 100644 --- a/Makefile.in +++ b/Makefile.in @@ -54,7 +54,7 @@ endif system_unit_files = contrib/systemd/onedrive@.service user_unit_files = contrib/systemd/onedrive.service -DOCFILES = README.md config LICENSE CHANGELOG.md docs/Docker.md docs/INSTALL.md docs/Office365.md docs/USAGE.md +DOCFILES = README.md config LICENSE CHANGELOG.md docs/Docker.md docs/INSTALL.md docs/Office365.md docs/USAGE.md docs/BusinessSharedFolders.md ifneq ("$(wildcard /etc/redhat-release)","") RHEL = $(shell cat /etc/redhat-release | grep -E "(Red Hat Enterprise Linux Server|CentOS)" | wc -l) diff --git a/README.md b/README.md index 4b095b14..47a1a73f 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ This client is a 'fork' of the [skilion](https://github.com/skilion/onedrive) cl * File upload / download validation to ensure data integrity * Resumable uploads * Support OneDrive for Business (part of Office 365) -* Shared folders (OneDrive Personal) -* SharePoint / Office 365 Shared Libraries +* Shared Folder support for OneDrive Personal and OneDrive Business accounts +* SharePoint / Office365 Shared Libraries * Desktop notifications via libnotify * Dry-run capability to test configuration changes * Prevent major OneDrive accidental data deletion after configuration change @@ -37,6 +37,9 @@ See [docs/USAGE.md](https://github.com/abraunegg/onedrive/blob/master/docs/USAGE ## Docker support See [docs/Docker.md](https://github.com/abraunegg/onedrive/blob/master/docs/Docker.md) +## OneDrive Business Shared Folders +See [docs/BusinessSharedFolders.md](https://github.com/abraunegg/onedrive/blob/master/docs/docs/BusinessSharedFolders.md) + ## SharePoint / Office 365 Shared Libraries (Business or Education) See [docs/Office365.md](https://github.com/abraunegg/onedrive/blob/master/docs/Office365.md) diff --git a/config b/config index ccd8306e..bb3176ea 100644 --- a/config +++ b/config @@ -37,3 +37,5 @@ # application_id = "" # resync = "false" # bypass_data_preservation = "false" +# azure_ad_endpoint = "" +# sync_business_shared_folders = "false" diff --git a/docs/BusinessSharedFolders.md b/docs/BusinessSharedFolders.md new file mode 100644 index 00000000..7c4aab90 --- /dev/null +++ b/docs/BusinessSharedFolders.md @@ -0,0 +1,187 @@ +# How to configure OneDrive Business Shared Folder Sync +Syncing OneDrive Business Shared Folders requires additional configuration for your 'onedrive' client: +1. List available shared folders to determine which folder you wish to sync & to validate that you have access to that folder +2. Create a new file called 'business_shared_folders' in your config directory which contains a list of the shared folders you wish to sync +3. Perform a sync + +## Listing available OneDrive Business Shared Folders +List the available OneDrive Business Shared folders with the following command: +```text +onedrive --list-shared-folders +``` + This will return a listing of all OneDrive Business Shared folders which have been shared with you and by whom. This is important for conflict resolution: +```text +Initializing the Synchronization Engine ... + +Listing available OneDrive Business Shared Folders: +--------------------------------------- +Shared Folder: SharedFolder0 +Shared By: Firstname Lastname +--------------------------------------- +Shared Folder: SharedFolder1 +Shared By: Firstname Lastname +--------------------------------------- +Shared Folder: SharedFolder2 +Shared By: Firstname Lastname +--------------------------------------- +Shared Folder: SharedFolder0 +Shared By: Firstname Lastname (user@domain) +--------------------------------------- +Shared Folder: SharedFolder1 +Shared By: Firstname Lastname (user@domain) +--------------------------------------- +Shared Folder: SharedFolder2 +Shared By: Firstname Lastname (user@domain) +... +``` + +## Configuring OneDrive Business Shared Folders +1. Create a new file called 'business_shared_folders' in your config directory +2. On each new line, list the OneDrive Business Shared Folder you wish to sync +```text +[alex@centos7full onedrive]$ cat ~/.config/onedrive/business_shared_folders +# comment +Child Shared Folder +# Another comment +Top Level to Share +[alex@centos7full onedrive]$ +``` +3. Validate your configuration with `onedrive --display-config`: +```text +Configuration file successfully loaded +onedrive version = v2.4.3 +Config path = /home/alex/.config/onedrive-business/ +Config file found in config path = true +Config option 'check_nosync' = false +Config option 'sync_dir' = /home/alex/OneDriveBusiness +Config option 'skip_dir' = +Config option 'skip_file' = ~*|.~*|*.tmp +Config option 'skip_dotfiles' = false +Config option 'skip_symlinks' = false +Config option 'monitor_interval' = 300 +Config option 'min_notify_changes' = 5 +Config option 'log_dir' = /var/log/onedrive/ +Config option 'classify_as_big_delete' = 1000 +Config option 'sync_root_files' = false +Selective sync 'sync_list' configured = false +Business Shared Folders configured = true +business_shared_folders contents: +# comment +Child Shared Folder +# Another comment +Top Level to Share +``` + +## Performing a sync of OneDrive Business Shared Folders +Perform a standalone sync using the following command: `onedrive --synchronize --sync-shared-folders --verbose`: +```text +onedrive --synchronize --sync-shared-folders --verbose +Using 'user' Config Dir: /home/alex/.config/onedrive-business/ +Using 'system' Config Dir: +Configuration file successfully loaded +Initializing the OneDrive API ... +Configuring Global Azure AD Endpoints +Opening the item database ... +All operations will be performed in: /home/alex/OneDriveBusiness +Application version: v2.4.3 +Account Type: business +Default Drive ID: b!bO8V7s9SSk6r7mWHpIjURotN33W1W2tEv3OXV_oFIdQimEdOHR-1So7CqeT1MfHA +Default Root ID: 01WIXGO5V6Y2GOVW7725BZO354PWSELRRZ +Remaining Free Space: 1098316220277 +Fetching details for OneDrive Root +OneDrive Root exists in the database +Initializing the Synchronization Engine ... +Syncing changes from OneDrive ... +Applying changes of Path ID: 01WIXGO5V6Y2GOVW7725BZO354PWSELRRZ +Number of items from OneDrive to process: 0 +Attempting to sync OneDrive Business Shared Folders +Syncing this OneDrive Business Shared Folder: Child Shared Folder +OneDrive Business Shared Folder - Shared By: test user +Applying changes of Path ID: 01JRXHEZMREEB3EJVHNVHKNN454Q7DFXPR +Adding OneDrive root details for processing +Adding OneDrive folder details for processing +Adding 4 OneDrive items for processing from OneDrive folder +Adding 2 OneDrive items for processing from /Child Shared Folder/Cisco VDI Whitepaper +Adding 2 OneDrive items for processing from /Child Shared Folder/SMPP_Shared +Processing 11 OneDrive items to ensure consistent local state +Syncing this OneDrive Business Shared Folder: Top Level to Share +OneDrive Business Shared Folder - Shared By: test user (testuser@mynasau3.onmicrosoft.com) +Applying changes of Path ID: 01JRXHEZLRMXHKBYZNOBF3TQOPBXS3VZMA +Adding OneDrive root details for processing +Adding OneDrive folder details for processing +Adding 4 OneDrive items for processing from OneDrive folder +Adding 3 OneDrive items for processing from /Top Level to Share/10-Files +Adding 2 OneDrive items for processing from /Top Level to Share/10-Files/Cisco VDI Whitepaper +Adding 2 OneDrive items for processing from /Top Level to Share/10-Files/Images +Adding 8 OneDrive items for processing from /Top Level to Share/10-Files/Images/JPG +Adding 8 OneDrive items for processing from /Top Level to Share/10-Files/Images/PNG +Adding 2 OneDrive items for processing from /Top Level to Share/10-Files/SMPP +Processing 31 OneDrive items to ensure consistent local state +Uploading differences of ~/OneDriveBusiness +Processing root +The directory has not changed +Processing SMPP_Local +The directory has not changed +Processing SMPP-IF-SPEC_v3_3-24858.pdf +The file has not changed +Processing SMPP_v3_4_Issue1_2-24857.pdf +The file has not changed +Processing new_local_file.txt +The file has not changed +Processing root +The directory has not changed +... +The directory has not changed +Processing week02-03-Combinational_Logic-v1.pptx +The file has not changed +Uploading new items of ~/OneDriveBusiness +Applying changes of Path ID: 01WIXGO5V6Y2GOVW7725BZO354PWSELRRZ +Number of items from OneDrive to process: 0 +Attempting to sync OneDrive Business Shared Folders +Syncing this OneDrive Business Shared Folder: Child Shared Folder +OneDrive Business Shared Folder - Shared By: test user +Applying changes of Path ID: 01JRXHEZMREEB3EJVHNVHKNN454Q7DFXPR +Adding OneDrive root details for processing +Adding OneDrive folder details for processing +Adding 4 OneDrive items for processing from OneDrive folder +Adding 2 OneDrive items for processing from /Child Shared Folder/Cisco VDI Whitepaper +Adding 2 OneDrive items for processing from /Child Shared Folder/SMPP_Shared +Processing 11 OneDrive items to ensure consistent local state +Syncing this OneDrive Business Shared Folder: Top Level to Share +OneDrive Business Shared Folder - Shared By: test user (testuser@mynasau3.onmicrosoft.com) +Applying changes of Path ID: 01JRXHEZLRMXHKBYZNOBF3TQOPBXS3VZMA +Adding OneDrive root details for processing +Adding OneDrive folder details for processing +Adding 4 OneDrive items for processing from OneDrive folder +Adding 3 OneDrive items for processing from /Top Level to Share/10-Files +Adding 2 OneDrive items for processing from /Top Level to Share/10-Files/Cisco VDI Whitepaper +Adding 2 OneDrive items for processing from /Top Level to Share/10-Files/Images +Adding 8 OneDrive items for processing from /Top Level to Share/10-Files/Images/JPG +Adding 8 OneDrive items for processing from /Top Level to Share/10-Files/Images/PNG +Adding 2 OneDrive items for processing from /Top Level to Share/10-Files/SMPP +Processing 31 OneDrive items to ensure consistent local state +``` + +**Note:** Whenever you modify the `business_shared_folders` file you must perform a `--resync` of your database to clean up stale entries due to changes in your configuration. + +## Enable / Disable syncing of OneDrive Business Shared Folders +Performing a sync of the configured OneDrive Business Shared Folders can be enabled / disabled via adding the following to your configuration file. + +### Enable syncing of OneDrive Business Shared Folders via config file +```text +sync_business_shared_folders = "true" +``` + +### Disable syncing of OneDrive Business Shared Folders via config file +```text +sync_business_shared_folders = "false" +``` + +## Known Issues +Shared folders, shared with you from people outside of your 'organisation' are unable to be synced. This is due to the Microsoft Graph API not presenting these folders. + +Shared folders that match this scenario, when you view 'Shared' via OneDrive online, will have a 'world' symbol as per below: + +![shared_with_me](./images/shared_with_me.jpg) + +This issue is being tracked by: [#966](https://github.com/abraunegg/onedrive/issues/966) \ No newline at end of file diff --git a/docs/images/shared_with_me.JPG b/docs/images/shared_with_me.JPG new file mode 100644 index 0000000000000000000000000000000000000000..437ebb4fba0226caba8c857bed315e1c14274ad4 GIT binary patch literal 46012 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 iN`Kl#{ja?6-1PtKMESqdQXu~?wHNHh#z4}zTl literal 0 HcmV?d00001 diff --git a/src/config.d b/src/config.d index 8201bbc9..c665d852 100644 --- a/src/config.d +++ b/src/config.d @@ -23,6 +23,7 @@ final class Config public string configFileSyncDir = ""; public string configFileSkipFile = ""; public string configFileSkipDir = ""; + public string businessSharedFolderFilePath = ""; private string userConfigFilePath = ""; private string systemConfigFilePath = ""; // was the application just authorised - paste of response uri @@ -33,8 +34,9 @@ final class Config private string[string] stringValues; private bool[string] boolValues; private long[string] longValues; + // Compile time regex - this does not change public auto configRegex = ctRegex!(`^(\w+)\s*=\s*"(.*)"\s*$`); - + this(string confdirOption) { // default configuration - entries in config file ~/.config/onedrive/config @@ -100,6 +102,8 @@ final class Config // AD Endpoint: https://login.chinacloudapi.cn // Graph Endpoint: https://microsoftgraph.chinacloudapi.cn stringValues["azure_ad_endpoint"] = ""; + // Allow enable / disable of the syncing of OneDrive Business Shared Folders via configuration file + boolValues["sync_business_shared_folders"] = false; // DEVELOPER OPTIONS // display_memory = true | false @@ -189,6 +193,7 @@ final class Config userConfigFilePath = buildNormalizedPath(configDirName ~ "/config"); syncListFilePath = buildNormalizedPath(configDirName ~ "/sync_list"); systemConfigFilePath = buildNormalizedPath(systemConfigDirName ~ "/config"); + businessSharedFolderFilePath = buildNormalizedPath(configDirName ~ "/business_shared_folders"); // Debug Output for application set variables based on configDirName log.vdebug("refreshTokenFilePath = ", refreshTokenFilePath); @@ -199,6 +204,7 @@ final class Config log.vdebug("userConfigFilePath = ", userConfigFilePath); log.vdebug("syncListFilePath = ", syncListFilePath); log.vdebug("systemConfigFilePath = ", systemConfigFilePath); + log.vdebug("businessSharedFolderFilePath = ", businessSharedFolderFilePath); } bool initialize() @@ -259,13 +265,16 @@ final class Config boolValues["force"] = false; boolValues["remove_source_files"] = false; boolValues["skip_dir_strict_match"] = false; - + boolValues["list_business_shared_folders"] = false; + // Application Startup option validation try { string tmpStr; bool tmpBol; long tmpVerb; + // duplicated from main.d to get full help output! auto opt = getopt( + args, std.getopt.config.bundling, std.getopt.config.caseSensitive, @@ -404,7 +413,6 @@ final class Config "user-agent", "Specify a User Agent string to the http client", &stringValues["user_agent"], - // duplicated from main.d to get full help output! "confdir", "Set the directory used to store the configuration files", &tmpStr, @@ -413,7 +421,13 @@ final class Config &tmpVerb, "version", "Print the version and exit", - &tmpBol + &tmpBol, + "list-shared-folders", + "List OneDrive Business Shared Folders", + &boolValues["list_business_shared_folders"], + "sync-shared-folders", + "Sync OneDrive Business Shared Folders", + &boolValues["sync_business_shared_folders"] ); if (opt.helpWanted) { outputLongHelp(opt.options); diff --git a/src/itemdb.d b/src/itemdb.d index 90568790..22ca6424 100644 --- a/src/itemdb.d +++ b/src/itemdb.d @@ -2,6 +2,8 @@ import std.datetime; import std.exception; import std.path; import std.string; +import std.stdio; +import std.algorithm.searching; import core.stdc.stdlib; import sqlite; static import log; @@ -368,9 +370,14 @@ final class ItemDatabase if (r2.empty) { // root reached assert(path.length >= 4); - // remove "root" - if (path.length >= 5) path = path[5 .. $]; - else path = path[4 .. $]; + // remove "root/" from path string if it exists + if (path.length >= 5) { + if (canFind(path, "root/")){ + path = path[5 .. $]; + } + } else { + path = path[4 .. $]; + } // special case of computing the path of the root itself if (path.length == 0) path = "."; break; @@ -427,17 +434,39 @@ final class ItemDatabase // As we query /children to get all children from OneDrive, update anything in the database // to be flagged as not-in-sync, thus, we can use that flag to determing what was previously // in-sync, but now deleted on OneDrive - void downgradeSyncStatusFlag() + void downgradeSyncStatusFlag(const(char)[] driveId, const(char)[] id) { - db.exec("UPDATE item SET syncStatus = 'N'"); + assert(driveId); + auto stmt = db.prepare("UPDATE item SET syncStatus = 'N' WHERE driveId = ?1 AND id = ?2"); + stmt.bind(1, driveId); + stmt.bind(2, id); + stmt.exec(); } // National Cloud Deployments (US and DE) do not support /delta as a query // Select items that have a out-of-sync flag set - Item[] selectOutOfSyncItems() + Item[] selectOutOfSyncItems(const(char)[] driveId) { + assert(driveId); Item[] items; - auto stmt = db.prepare("SELECT * FROM item WHERE syncStatus = 'N'"); + auto stmt = db.prepare("SELECT * FROM item WHERE syncStatus = 'N' AND driveId = ?1"); + stmt.bind(1, driveId); + auto res = stmt.exec(); + while (!res.empty) { + items ~= buildItem(res); + res.step(); + } + return items; + } + + // OneDrive Business Folders are stored in the database potentially without a root | parentRoot link + // Select items associated with the provided driveId + Item[] selectByDriveId(const(char)[] driveId) + { + assert(driveId); + Item[] items; + auto stmt = db.prepare("SELECT * FROM item WHERE driveId = ?1 AND parentId IS NULL"); + stmt.bind(1, driveId); auto res = stmt.exec(); while (!res.empty) { items ~= buildItem(res); diff --git a/src/main.d b/src/main.d index 5db7e7de..11e751c0 100644 --- a/src/main.d +++ b/src/main.d @@ -26,6 +26,7 @@ int main(string[] args) string configFilePath; string syncListFilePath; string databaseFilePath; + string businessSharedFolderFilePath; string currentConfigHash; string currentSyncListHash; string previousConfigHash; @@ -35,7 +36,11 @@ int main(string[] args) string configBackupFile; string syncDir; string logOutputMessage; + string currentBusinessSharedFoldersHash; + string previousBusinessSharedFoldersHash; + string businessSharedFoldersHashFile; bool configOptionsDifferent = false; + bool businessSharedFoldersDifferent = false; bool syncListConfigured = false; bool syncListDifferent = false; bool syncDirDifferent = false; @@ -144,6 +149,7 @@ int main(string[] args) configFilePath = buildNormalizedPath(cfg.configDirName ~ "/config"); syncListFilePath = buildNormalizedPath(cfg.configDirName ~ "/sync_list"); databaseFilePath = buildNormalizedPath(cfg.configDirName ~ "/items.db"); + businessSharedFolderFilePath = buildNormalizedPath(cfg.configDirName ~ "/business_shared_folders"); // Has any of our configuration that would require a --resync been changed? // 1. sync_list file modification @@ -152,6 +158,7 @@ int main(string[] args) configHashFile = buildNormalizedPath(cfg.configDirName ~ "/.config.hash"); syncListHashFile = buildNormalizedPath(cfg.configDirName ~ "/.sync_list.hash"); configBackupFile = buildNormalizedPath(cfg.configDirName ~ "/.config.backup"); + businessSharedFoldersHashFile = buildNormalizedPath(cfg.configDirName ~ "/.business_shared_folders.hash"); // Does a config file exist with a valid hash file if ((exists(configFilePath)) && (!exists(configHashFile))) { @@ -165,6 +172,12 @@ int main(string[] args) std.file.write(syncListHashFile, computeQuickXorHash(syncListFilePath)); } + // check if business_shared_folders & business_shared_folders hash exists + if ((exists(businessSharedFolderFilePath)) && (!exists(businessSharedFoldersHashFile))) { + // Hash of business_shared_folders file needs to be created + std.file.write(businessSharedFoldersHashFile, computeQuickXorHash(businessSharedFolderFilePath)); + } + // If hash files exist, but config files do not ... remove the hash, but only if --resync was issued as now the application will use 'defaults' which 'may' be different if ((!exists(configFilePath)) && (exists(configHashFile))) { // if --resync safe remove config.hash and config.backup @@ -180,11 +193,18 @@ int main(string[] args) if (cfg.getValueBool("resync")) safeRemove(syncListHashFile); } + if ((!exists(businessSharedFolderFilePath)) && (exists(businessSharedFoldersHashFile))) { + // if --resync safe remove business_shared_folders.hash + if (cfg.getValueBool("resync")) safeRemove(businessSharedFoldersHashFile); + } + // Read config hashes if they exist if (exists(configFilePath)) currentConfigHash = computeQuickXorHash(configFilePath); if (exists(syncListFilePath)) currentSyncListHash = computeQuickXorHash(syncListFilePath); + if (exists(businessSharedFolderFilePath)) currentBusinessSharedFoldersHash = computeQuickXorHash(businessSharedFolderFilePath); if (exists(configHashFile)) previousConfigHash = readText(configHashFile); if (exists(syncListHashFile)) previousSyncListHash = readText(syncListHashFile); + if (exists(businessSharedFoldersHashFile)) previousBusinessSharedFoldersHash = readText(businessSharedFoldersHashFile); // Was sync_list file updated? if (currentSyncListHash != previousSyncListHash) { @@ -193,6 +213,13 @@ int main(string[] args) syncListDifferent = true; } + // Was business_shared_folders updated? + if (currentBusinessSharedFoldersHash != previousBusinessSharedFoldersHash) { + // Debugging output to assist what changed + log.vdebug("business_shared_folders file has been updated, --resync needed"); + businessSharedFoldersDifferent = true; + } + // Was config file updated between last execution ang this execution? if (currentConfigHash != previousConfigHash) { // config file was updated, however we only want to trigger a --resync requirement if sync_dir, skip_dir, skip_file or drive_id was modified @@ -315,7 +342,7 @@ int main(string[] args) } // Has anything triggered a --resync requirement? - if (configOptionsDifferent || syncListDifferent || syncDirDifferent || skipFileDifferent || skipDirDifferent) { + if (configOptionsDifferent || syncListDifferent || syncDirDifferent || skipFileDifferent || skipDirDifferent || businessSharedFoldersDifferent) { // --resync needed, is the user just testing configuration changes? if (!cfg.getValueBool("display_config")){ // not testing configuration changes @@ -340,6 +367,11 @@ int main(string[] args) log.vdebug("updating sync_list hash as --resync issued"); std.file.write(syncListHashFile, computeQuickXorHash(syncListFilePath)); } + if (exists(businessSharedFolderFilePath)) { + // update business_shared_folders hash + log.vdebug("updating business_shared_folders hash as --resync issued"); + std.file.write(businessSharedFoldersHashFile, computeQuickXorHash(businessSharedFolderFilePath)); + } } } } @@ -438,10 +470,8 @@ int main(string[] args) if (cfg.getValueBool("display_config")){ // Display application version writeln("onedrive version = ", strip(import("version"))); - // Display all of the pertinent configuration options writeln("Config path = ", cfg.configDirName); - // Does a config file exist or are we using application defaults writeln("Config file found in config path = ", exists(configFilePath)); @@ -465,7 +495,7 @@ int main(string[] args) // Is sync_list configured? if (exists(syncListFilePath)){ writeln("Config option 'sync_root_files' = ", cfg.getValueBool("sync_root_files")); - writeln("Selective sync configured = true"); + writeln("Selective sync 'sync_list' configured = true"); writeln("sync_list contents:"); // Output the sync_list contents auto syncListFile = File(syncListFilePath); @@ -476,10 +506,25 @@ int main(string[] args) } } else { writeln("Config option 'sync_root_files' = ", cfg.getValueBool("sync_root_files")); - writeln("Selective sync configured = false"); + writeln("Selective sync 'sync_list' configured = false"); } - // exit + // Is business_shared_folders configured + if (exists(businessSharedFolderFilePath)){ + writeln("Business Shared Folders configured = true"); + writeln("business_shared_folders contents:"); + // Output the business_shared_folders contents + auto businessSharedFolderFileList = File(businessSharedFolderFilePath); + auto range = businessSharedFolderFileList.byLine(); + foreach (line; range) + { + writeln(line); + } + } else { + writeln("Business Shared Folders configured = false"); + } + + // Exit return EXIT_SUCCESS; } @@ -518,9 +563,9 @@ int main(string[] args) performSyncOK = true; } - // create-directory, remove-directory, source-directory, destination-directory + // create-directory, remove-directory, source-directory, destination-directory // these are activities that dont perform a sync, so to not generate an error message for these items either - if (((cfg.getValueString("create_directory") != "") || (cfg.getValueString("remove_directory") != "")) || ((cfg.getValueString("source_directory") != "") && (cfg.getValueString("destination_directory") != "")) || (cfg.getValueString("get_file_link") != "") || (cfg.getValueString("get_o365_drive_id") != "") || cfg.getValueBool("display_sync_status")) { + if (((cfg.getValueString("create_directory") != "") || (cfg.getValueString("remove_directory") != "")) || ((cfg.getValueString("source_directory") != "") && (cfg.getValueString("destination_directory") != "")) || (cfg.getValueString("get_file_link") != "") || (cfg.getValueString("get_o365_drive_id") != "") || cfg.getValueBool("display_sync_status") || cfg.getValueBool("list_business_shared_folders")) { performSyncOK = true; } @@ -580,11 +625,13 @@ int main(string[] args) // Configure selective sync by parsing and getting a regex for skip_file config component auto selectiveSync = new SelectiveSync(); - if (exists(cfg.syncListFilePath)){ + + // load sync_list if it exists + if (exists(syncListFilePath)){ log.vdebug("Loading user configured sync_list file ..."); syncListConfigured = true; // list what will be synced - auto syncListFile = File(cfg.syncListFilePath); + auto syncListFile = File(syncListFilePath); auto range = syncListFile.byLine(); foreach (line; range) { @@ -596,7 +643,20 @@ int main(string[] args) syncListFile.close(); } } - selectiveSync.load(cfg.syncListFilePath); + selectiveSync.load(syncListFilePath); + + // load business_shared_folders if it exists + if (exists(businessSharedFolderFilePath)){ + log.vdebug("Loading user configured business_shared_folders file ..."); + // list what will be synced + auto businessSharedFolderFileList = File(businessSharedFolderFilePath); + auto range = businessSharedFolderFileList.byLine(); + foreach (line; range) + { + log.vdebug("business_shared_folders: ", line); + } + } + selectiveSync.loadSharedFolders(businessSharedFolderFilePath); // Configure skip_dir, skip_file, skip-dir-strict-match & skip_dotfiles from config entries // Handle skip_dir configuration in config file @@ -725,11 +785,42 @@ int main(string[] args) // Are we obtaining the Office 365 Drive ID for a given Office 365 SharePoint Shared Library? if (cfg.getValueString("get_o365_drive_id") != "") { sync.querySiteCollectionForDriveID(cfg.getValueString("get_o365_drive_id")); + // Exit application + // Use exit scopes to shutdown API + return EXIT_SUCCESS; } // Are we obtaining the URL path for a synced file? if (cfg.getValueString("get_file_link") != "") { sync.queryOneDriveForFileURL(cfg.getValueString("get_file_link"), syncDir); + // Exit application + // Use exit scopes to shutdown API + return EXIT_SUCCESS; + } + + // Are we listing OneDrive Business Shared Folders + if (cfg.getValueBool("list_business_shared_folders")) { + // Is this a business account type? + if (sync.getAccountType() == "business"){ + // List OneDrive Business Shared Folders + sync.listOneDriveBusinessSharedFolders(); + } else { + log.error("ERROR: Unsupported account type for listing OneDrive Business Shared Folders"); + } + // Exit application + // Use exit scopes to shutdown API + return EXIT_SUCCESS; + } + + // Are we going to sync OneDrive Business Shared Folders + if (cfg.getValueBool("sync_business_shared_folders")) { + // Is this a business account type? + if (sync.getAccountType() == "business"){ + // Configure flag to sync business folders + sync.setSyncBusinessFolders(); + } else { + log.error("ERROR: Unsupported account type for syncing OneDrive Business Shared Folders"); + } } // Are we displaying the sync status of the client? @@ -752,9 +843,9 @@ int main(string[] args) if (cfg.getValueBool("synchronize")) { if (online) { // Check user entry for local path - the above chdir means we are already in ~/OneDrive/ thus singleDirectory is local to this path - if (cfg.getValueString("single_directory") != ""){ + if (cfg.getValueString("single_directory") != "") { // Does the directory we want to sync actually exist? - if (!exists(cfg.getValueString("single_directory"))){ + if (!exists(cfg.getValueString("single_directory"))) { // the requested directory does not exist .. log.logAndNotify("ERROR: The requested local directory does not exist. Please check ~/OneDrive/ for requested path"); // Use exit scopes to shutdown API @@ -938,7 +1029,7 @@ int main(string[] args) } try { // perform a --monitor sync - log.vlog("Starting a sync with OneDrive"); + if (logMonitorCounter == logInterval) log.log("Starting a sync with OneDrive"); performSync(sync, cfg.getValueString("single_directory"), cfg.getValueBool("download_only"), cfg.getValueBool("local_first"), cfg.getValueBool("upload_only"), (logMonitorCounter == logInterval ? MONITOR_LOG_QUIET : MONITOR_LOG_SILENT), fullScanRequired, syncListConfiguredFullScanOverride, displaySyncOptions, cfg.getValueBool("monitor"), m); if (!cfg.getValueBool("download_only")) { // discard all events that may have been generated by the sync that have not already been handled @@ -949,7 +1040,7 @@ int main(string[] args) log.error("ERROR: The following inotify error was generated: ", e.msg); } } - log.vlog("Sync with OneDrive is complete"); + if (logMonitorCounter == logInterval) log.log("Sync with OneDrive is complete"); } catch (CurlException e) { // we already tried three times in the performSync routine // if we still have problems, then the sync handle might have diff --git a/src/onedrive.d b/src/onedrive.d index 1d68e983..9bb59ba5 100644 --- a/src/onedrive.d +++ b/src/onedrive.d @@ -60,6 +60,9 @@ private { string driveUrl = globalGraphEndpoint ~ "/v1.0/me/drive"; string driveByIdUrl = globalGraphEndpoint ~ "/v1.0/drives/"; + // What is 'shared with me' Query + string sharedWithMe = globalGraphEndpoint ~ "/v1.0/me/drive/sharedWithMe"; + // Item Queries string itemByIdUrl = globalGraphEndpoint ~ "/v1.0/me/drive/items/"; string itemByPathUrl = globalGraphEndpoint ~ "/v1.0/me/drive/root:/"; @@ -156,6 +159,8 @@ final class OneDriveApi // Office 365 / SharePoint Queries siteSearchUrl = usl4GraphEndpoint ~ "/v1.0/sites?search"; siteDriveUrl = usl4GraphEndpoint ~ "/v1.0/sites/"; + // Shared With Me + sharedWithMe = usl4GraphEndpoint ~ "/v1.0/me/drive/sharedWithMe"; break; case "USL5": log.log("Configuring Azure AD for US Government Endpoints (DOD)"); @@ -172,6 +177,8 @@ final class OneDriveApi // Office 365 / SharePoint Queries siteSearchUrl = usl5GraphEndpoint ~ "/v1.0/sites?search"; siteDriveUrl = usl5GraphEndpoint ~ "/v1.0/sites/"; + // Shared With Me + sharedWithMe = usl5GraphEndpoint ~ "/v1.0/me/drive/sharedWithMe"; break; case "DE": log.log("Configuring Azure AD Germany"); @@ -188,6 +195,8 @@ final class OneDriveApi // Office 365 / SharePoint Queries siteSearchUrl = deGraphEndpoint ~ "/v1.0/sites?search"; siteDriveUrl = deGraphEndpoint ~ "/v1.0/sites/"; + // Shared With Me + sharedWithMe = deGraphEndpoint ~ "/v1.0/me/drive/sharedWithMe"; break; case "CN": log.log("Configuring AD China operated by 21Vianet"); @@ -204,6 +213,8 @@ final class OneDriveApi // Office 365 / SharePoint Queries siteSearchUrl = cnGraphEndpoint ~ "/v1.0/sites?search"; siteDriveUrl = cnGraphEndpoint ~ "/v1.0/sites/"; + // Shared With Me + sharedWithMe = cnGraphEndpoint ~ "/v1.0/me/drive/sharedWithMe"; break; // Default - all other entries default: @@ -392,9 +403,25 @@ final class OneDriveApi url = driveUrl ~ "/root"; return get(url); } + + // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_get + JSONValue getDriveIdRoot(const(char)[] driveId) + { + checkAccessTokenExpired(); + const(char)[] url; + url = driveByIdUrl ~ driveId ~ "/root"; + return get(url); + } + // https://docs.microsoft.com/en-us/graph/api/drive-sharedwithme + JSONValue getSharedWithMe() + { + checkAccessTokenExpired(); + return get(sharedWithMe); + } + // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delta - JSONValue viewChangesById(const(char)[] driveId, const(char)[] id, const(char)[] deltaLink) + JSONValue viewChangesByItemId(const(char)[] driveId, const(char)[] id, const(char)[] deltaLink) { checkAccessTokenExpired(); const(char)[] url; @@ -408,6 +435,18 @@ final class OneDriveApi return get(url); } + // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delta + JSONValue viewChangesByDriveId(const(char)[] driveId, const(char)[] deltaLink) + { + checkAccessTokenExpired(); + const(char)[] url = deltaLink; + if (url == null) { + url = driveByIdUrl ~ driveId ~ "/root/delta"; + url ~= "?select=id,name,eTag,cTag,deleted,file,folder,root,fileSystemInfo,remoteItem,parentReference,size"; + } + return get(url); + } + // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_list_children JSONValue listChildren(const(char)[] driveId, const(char)[] id, const(char)[] nextLink) { @@ -422,7 +461,7 @@ final class OneDriveApi } return get(url); } - + // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_get_content void downloadById(const(char)[] driveId, const(char)[] id, string saveToPath, long fileSize) { @@ -506,6 +545,19 @@ final class OneDriveApi return get(url); } + // Return the requested details of the specified path on the specified drive id + JSONValue getPathDetailsByDriveId(const(char)[] driveId, const(string) path) + { + checkAccessTokenExpired(); + const(char)[] url; + // string driveByIdUrl = "https://graph.microsoft.com/v1.0/drives/"; + // Required format: /drives/{drive-id}/root:/{item-path} + url = driveByIdUrl ~ driveId ~ "/root:/" ~ encodeComponent(path); + url ~= "?select=id,name,eTag,cTag,deleted,file,folder,root,fileSystemInfo,remoteItem,parentReference,size"; + return get(url); + } + + // Return the requested details of the specified id // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_get JSONValue getFileDetails(const(char)[] driveId, const(char)[] id) diff --git a/src/selective.d b/src/selective.d index fd6286f4..e86c4d7f 100644 --- a/src/selective.d +++ b/src/selective.d @@ -1,185 +1,240 @@ -import std.algorithm; -import std.array; -import std.file; -import std.path; -import std.regex; -import std.stdio; -import util; - -final class SelectiveSync -{ - private string[] paths; - private Regex!char mask; - private Regex!char dirmask; - private bool skipDirStrictMatch = false; - private bool skipDotfiles = false; - - void load(string filepath) - { - if (exists(filepath)) { - // open file as read only - auto file = File(filepath, "r"); - auto range = file.byLine(); - foreach (line; range) { - // Skip comments in file - if (line.length == 0 || line[0] == ';' || line[0] == '#') continue; - paths ~= buildNormalizedPath(line); - } - file.close(); - } - } - - // Configure skipDirStrictMatch if function is called - // By default, skipDirStrictMatch = false; - void setSkipDirStrictMatch() - { - skipDirStrictMatch = true; - } - - void setFileMask(const(char)[] mask) - { - this.mask = wild2regex(mask); - } - - void setDirMask(const(char)[] dirmask) - { - this.dirmask = wild2regex(dirmask); - } - - // Configure skipDotfiles if function is called - // By default, skipDotfiles = false; - void setSkipDotfiles() - { - skipDotfiles = true; - } - - // return value of skipDotfiles - bool getSkipDotfiles() - { - return skipDotfiles; - } - - // config file skip_dir parameter - bool isDirNameExcluded(string name) - { - // Does the directory name match skip_dir config entry? - // Returns true if the name matches a skip_dir config entry - // Returns false if no match - - // Try full path match first - if (!name.matchFirst(dirmask).empty) { - return true; - } else { - // Do we check the base name as well? - if (!skipDirStrictMatch) { - // check just the basename in the path - string filename = baseName(name); - if(!filename.matchFirst(dirmask).empty) { - return true; - } - } - } - // no match - return false; - } - - // config file skip_file parameter - bool isFileNameExcluded(string name) - { - // Does the file name match skip_file config entry? - // Returns true if the name matches a skip_file config entry - // Returns false if no match - - // Try full path match first - if (!name.matchFirst(mask).empty) { - return true; - } else { - // check just the file name - string filename = baseName(name); - if(!filename.matchFirst(mask).empty) { - return true; - } - } - // no match - return false; - } - - // Match against sync_list only - bool isPathExcludedViaSyncList(string path) - { - return .isPathExcluded(path, paths); - } - - // Match against skip_dir, skip_file & sync_list entries - bool isPathExcludedMatchAll(string path) - { - return .isPathExcluded(path, paths) || .isPathMatched(path, mask) || .isPathMatched(path, dirmask); - } - - // is the path a dotfile? - bool isDotFile(string path) - { - // always allow the root - if (path == ".") return false; - - path = buildNormalizedPath(path); - auto paths = pathSplitter(path); - foreach(base; paths) { - if (startsWith(base, ".")){ - return true; - } - } - return false; - } -} - -// test if the given path is not included in the allowed paths -// if there are no allowed paths always return false -private bool isPathExcluded(string path, string[] allowedPaths) -{ - // always allow the root - if (path == ".") return false; - // if there are no allowed paths always return false - if (allowedPaths.empty) return false; - - path = buildNormalizedPath(path); - foreach (allowed; allowedPaths) { - auto comm = commonPrefix(path, allowed); - if (comm.length == path.length) { - // the given path is contained in an allowed path - return false; - } - if (comm.length == allowed.length && path[comm.length] == '/') { - // the given path is a subitem of an allowed path - return false; - } - } - return true; -} - -// test if the given path is matched by the regex expression. -// recursively test up the tree. -private bool isPathMatched(string path, Regex!char mask) { - path = buildNormalizedPath(path); - auto paths = pathSplitter(path); - - string prefix = ""; - foreach(base; paths) { - prefix ~= base; - if (!path.matchFirst(mask).empty) { - // the given path matches something which we should skip - return true; - } - prefix ~= dirSeparator; - } - return false; -} - -unittest -{ - assert(isPathExcluded("Documents2", ["Documents"])); - assert(!isPathExcluded("Documents", ["Documents"])); - assert(!isPathExcluded("Documents/a.txt", ["Documents"])); - assert(isPathExcluded("Hello/World", ["Hello/John"])); - assert(!isPathExcluded(".", ["Documents"])); -} +import std.algorithm; +import std.array; +import std.file; +import std.path; +import std.regex; +import std.stdio; +import util; + +final class SelectiveSync +{ + private string[] paths; + private string[] businessSharedFoldersList; + private Regex!char mask; + private Regex!char dirmask; + private bool skipDirStrictMatch = false; + private bool skipDotfiles = false; + + // load sync_list file + void load(string filepath) + { + if (exists(filepath)) { + // open file as read only + auto file = File(filepath, "r"); + auto range = file.byLine(); + foreach (line; range) { + // Skip comments in file + if (line.length == 0 || line[0] == ';' || line[0] == '#') continue; + paths ~= buildNormalizedPath(line); + } + file.close(); + } + } + + // Configure skipDirStrictMatch if function is called + // By default, skipDirStrictMatch = false; + void setSkipDirStrictMatch() + { + skipDirStrictMatch = true; + } + + // load business_shared_folders file + void loadSharedFolders(string filepath) + { + if (exists(filepath)) { + // open file as read only + auto file = File(filepath, "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(); + } + } + + void setFileMask(const(char)[] mask) + { + this.mask = wild2regex(mask); + } + + void setDirMask(const(char)[] dirmask) + { + this.dirmask = wild2regex(dirmask); + } + + // Configure skipDotfiles if function is called + // By default, skipDotfiles = false; + void setSkipDotfiles() + { + skipDotfiles = true; + } + + // return value of skipDotfiles + bool getSkipDotfiles() + { + return skipDotfiles; + } + + // config file skip_dir parameter + bool isDirNameExcluded(string name) + { + // Does the directory name match skip_dir config entry? + // Returns true if the name matches a skip_dir config entry + // Returns false if no match + + // Try full path match first + if (!name.matchFirst(dirmask).empty) { + return true; + } else { + // Do we check the base name as well? + if (!skipDirStrictMatch) { + // check just the basename in the path + string filename = baseName(name); + if(!filename.matchFirst(dirmask).empty) { + return true; + } + } + } + // no match + return false; + } + + // config file skip_file parameter + bool isFileNameExcluded(string name) + { + // Does the file name match skip_file config entry? + // Returns true if the name matches a skip_file config entry + // Returns false if no match + + // Try full path match first + if (!name.matchFirst(mask).empty) { + return true; + } else { + // check just the file name + string filename = baseName(name); + if(!filename.matchFirst(mask).empty) { + return true; + } + } + // no match + return false; + } + + // Match against sync_list only + bool isPathExcludedViaSyncList(string path) + { + return .isPathExcluded(path, paths); + } + + // Match against skip_dir, skip_file & sync_list entries + bool isPathExcludedMatchAll(string path) + { + return .isPathExcluded(path, paths) || .isPathMatched(path, mask) || .isPathMatched(path, dirmask); + } + + // is the path a dotfile? + bool isDotFile(string path) + { + // always allow the root + if (path == ".") return false; + + path = buildNormalizedPath(path); + auto paths = pathSplitter(path); + foreach(base; paths) { + if (startsWith(base, ".")){ + return true; + } + } + return false; + } + + // is business shared folder matched + bool isSharedFolderMatched(string name) + { + // if there are no shared folder always return false + if (businessSharedFoldersList.empty) return false; + + if (!name.matchFirst(businessSharedFoldersList).empty) { + return true; + } else { + return false; + } + } + + // is business shared folder included + bool isPathIncluded(string path, string[] allowedPaths) + { + // always allow the root + if (path == ".") return true; + // if there are no allowed paths always return true + if (allowedPaths.empty) return true; + + path = buildNormalizedPath(path); + foreach (allowed; allowedPaths) { + auto comm = commonPrefix(path, allowed); + if (comm.length == path.length) { + // the given path is contained in an allowed path + return true; + } + if (comm.length == allowed.length && path[comm.length] == '/') { + // the given path is a subitem of an allowed path + return true; + } + } + return false; + } +} + +// test if the given path is not included in the allowed paths +// if there are no allowed paths always return false +private bool isPathExcluded(string path, string[] allowedPaths) +{ + // always allow the root + if (path == ".") return false; + // if there are no allowed paths always return false + if (allowedPaths.empty) return false; + + path = buildNormalizedPath(path); + foreach (allowed; allowedPaths) { + auto comm = commonPrefix(path, allowed); + if (comm.length == path.length) { + // the given path is contained in an allowed path + return false; + } + if (comm.length == allowed.length && path[comm.length] == '/') { + // the given path is a subitem of an allowed path + return false; + } + } + return true; +} + +// test if the given path is matched by the regex expression. +// recursively test up the tree. +private bool isPathMatched(string path, Regex!char mask) { + path = buildNormalizedPath(path); + auto paths = pathSplitter(path); + + string prefix = ""; + foreach(base; paths) { + prefix ~= base; + if (!path.matchFirst(mask).empty) { + // the given path matches something which we should skip + return true; + } + prefix ~= dirSeparator; + } + return false; +} + +// unit tests +unittest +{ + assert(isPathExcluded("Documents2", ["Documents"])); + assert(!isPathExcluded("Documents", ["Documents"])); + assert(!isPathExcluded("Documents/a.txt", ["Documents"])); + assert(isPathExcluded("Hello/World", ["Hello/John"])); + assert(!isPathExcluded(".", ["Documents"])); +} diff --git a/src/sync.d b/src/sync.d index 41b025a1..a2ee920a 100644 --- a/src/sync.d +++ b/src/sync.d @@ -250,6 +250,8 @@ final class SyncEngine private bool bypassDataPreservation = false; // is National Cloud Deployments configured private bool nationalCloudDeployment = 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) { @@ -372,6 +374,12 @@ final class SyncEngine defaultDriveId = oneDriveDetails["id"].str; defaultRootId = oneDriveRootDetails["id"].str; remainingFreeSpace = oneDriveDetails["quota"]["remaining"].integer; + // 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) { @@ -452,6 +460,12 @@ final class SyncEngine 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() @@ -508,6 +522,13 @@ final class SyncEngine log.vdebug("Setting nationalCloudDeployment = 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) { @@ -522,20 +543,189 @@ final class SyncEngine Item[] items = itemdb.selectRemoteItems(); foreach (item; items) { log.vdebug("------------------------------------------------------------------"); - log.vlog("Syncing OneDrive Shared Folder: ", item.name); - applyDifferences(item.remoteDriveId, item.remoteId, performFullItemScan); + 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 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 = onedrive.getSharedWithMe(); + if (graphQuery.type() == JSONType.object) { + string sharedFolderName; + foreach (searchResult; graphQuery["value"].array) { + sharedFolderName = searchResult["name"].str; + // 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: ", sharedFolderName); + 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.selectByPath(sharedFolderName, searchDriveId, databaseItem)) { + log.vdebug("Found shared folder name in database"); + itemInDatabase = true; + 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))); + + // Additional logging + 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; + } + } + + 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 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) { - log.vlog("Getting path details from OneDrive ..."); + // 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; JSONValue onedrivePathDetails; - // test if the path we are going to sync from actually exists on OneDrive + // 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 = onedrive.getSharedWithMe(); + + if (graphQuery.type() == JSONType.object) { + // valid response from OneDrive + foreach (searchResult; graphQuery["value"].array) { + string sharedFolderName = searchResult["name"].str; + // Compare this to values in business_shared_folders + if(selectiveSync.isSharedFolderMatched(sharedFolderName)){ + // Folder matches a user configured sync entry + string[] allowedPath; + allowedPath ~= sharedFolderName; + // But is this shared folder what we are looking for? + if (selectiveSync.isPathIncluded(path,allowedPath)) { + // Path we want to sync is on a OneDrive Business Shared Folder + // Set the correct driveId + driveId = searchResult["remoteItem"]["parentReference"]["driveId"].str; + // Keep the driveIDsArray with unique entries only + if (!canFind(driveIDsArray, driveId)) { + // Add this drive id to the array to search with + driveIDsArray ~= driveId; + } + } + } + } + } 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 { - onedrivePathDetails = onedrive.getPathDetails(path); // Returns a JSON String for the OneDrive Path + onedrivePathDetails = onedrive.getPathDetailsByDriveId(driveId, path); } catch (OneDriveException e) { log.vdebug("onedrivePathDetails = onedrive.getPathDetails(path) generated a OneDriveException"); if (e.httpStatusCode == 404) { @@ -558,13 +748,13 @@ final class SyncEngine // 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) { - string driveId; - string folderId; - + // 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 @@ -873,7 +1063,7 @@ final class SyncEngine // Debug Output log.vdebug("Sync Folder Name: ", syncFolderName); log.vdebug("Sync Folder Parent Path: ", syncFolderPath); - log.vdebug("Sync Folder Actual Path: ", syncFolderChildPath); + log.vdebug("Sync Folder Child Path: ", syncFolderChildPath); } } else { // Log that an invalid JSON object was returned @@ -948,11 +1138,24 @@ final class SyncEngine // National Cloud Deployments (US and DE) 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) { - // have to query /children rather than /delta + if ((nationalCloudDeployment) || ((driveId!= defaultDriveId) && (syncBusinessFolders))) { + // Have to query /children rather than /delta nationalCloudChildrenScan = true; - // Before we get any data, flag any object in the database as out of sync - itemdb.downgradeSyncStatusFlag(); + log.vdebug("Using /children call to query drive for items"); + // In OneDrive Business Shared Folder scenario, 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 + itemdb.downgradeSyncStatusFlag(drivePathChild.driveId, drivePathChild.id); + } + } + + // Build own 'changes' response try { // we have to 'build' our own JSON response that looks like /delta changes = generateDeltaResponse(driveId, idToQuery); @@ -1021,18 +1224,20 @@ final class SyncEngine } } } else { - // query for changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink); + log.vdebug("Using /delta call to query drive for items"); + + // query for changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink); try { // Fetch the changes relative to the path id we want to query // changes with or without deltaLink - changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink); + changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink); if (changes.type() == JSONType.object) { - log.vdebug("Query 'changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink)' performed successfully"); + log.vdebug("Query 'changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)' performed successfully"); } } catch (OneDriveException e) { // OneDrive threw an error log.vdebug("------------------------------------------------------------------"); - log.vdebug("Query Error: changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink)"); + log.vdebug("Query Error: changes = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)"); log.vdebug("driveId: ", driveId); log.vdebug("idToQuery: ", idToQuery); log.vdebug("deltaLink: ", deltaLink); @@ -1048,7 +1253,7 @@ final class SyncEngine // 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.viewChangesById(driveId, idToQuery, deltaLink)', setting 'deltaLink = null'"); + log.vdebug("Delta link expired for 'onedrive.viewChangesByItemId(driveId, idToQuery, deltaLink)', setting 'deltaLink = null'"); deltaLink = null; continue; } @@ -1073,7 +1278,7 @@ final class SyncEngine // 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.viewChangesById(driveId, idToQuery, deltaLink) previously threw an error - retrying"); + 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)); @@ -1081,20 +1286,20 @@ final class SyncEngine } // re-try original request - retried for 429 and 504 try { - log.vdebug("Retrying Query: changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink)"); - changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink); - log.vdebug("Query 'changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink)' performed successfully on re-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.viewChangesById(driveId, idToQuery, deltaLink) on re-try after delay"); + 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.viewChangesById(driveId, idToQuery, deltaLink) previously threw an error - retrying with empty deltaLink"); + 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.viewChangesById(driveId, idToQuery, deltaLink); - log.vdebug("Query 'changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink)' performed successfully on re-try"); + 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); @@ -1114,13 +1319,13 @@ final class SyncEngine } } - // query for changesAvailable = onedrive.viewChangesById(driveId, idToQuery, deltaLinkAvailable); + // query for changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable); try { // Fetch the changes relative to the path id we want to query // changes based on deltaLink - changesAvailable = onedrive.viewChangesById(driveId, idToQuery, deltaLinkAvailable); + changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable); if (changesAvailable.type() == JSONType.object) { - log.vdebug("Query 'changesAvailable = onedrive.viewChangesById(driveId, idToQuery, deltaLinkAvailable)' performed successfully"); + 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); @@ -1130,10 +1335,10 @@ final class SyncEngine } catch (OneDriveException e) { // OneDrive threw an error log.vdebug("------------------------------------------------------------------"); - log.vdebug("Query Error: changesAvailable = onedrive.viewChangesById(driveId, idToQuery, deltaLinkAvailable)"); + log.vdebug("Query Error: changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)"); log.vdebug("driveId: ", driveId); log.vdebug("idToQuery: ", idToQuery); - log.vdebug("deltaLink: ", deltaLink); + log.vdebug("deltaLinkAvailable: ", deltaLinkAvailable); // HTTP request returned status code 404 (Not Found) if (e.httpStatusCode == 404) { @@ -1146,7 +1351,7 @@ final class SyncEngine // 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.viewChangesById(driveId, idToQuery, deltaLinkAvailable)', setting 'deltaLinkAvailable = null'"); + log.vdebug("Delta link expired for 'onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)', setting 'deltaLinkAvailable = null'"); deltaLinkAvailable = null; continue; } @@ -1155,7 +1360,7 @@ final class SyncEngine 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"); + 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) @@ -1171,28 +1376,28 @@ final class SyncEngine // 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.viewChangesById(driveId, idToQuery, deltaLinkAvailable) previously threw an error - retrying"); + 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 deltaLink after delay"); + 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.viewChangesById(driveId, idToQuery, deltaLinkAvailable)"); - changesAvailable = onedrive.viewChangesById(driveId, idToQuery, deltaLinkAvailable); - log.vdebug("Query 'changesAvailable = onedrive.viewChangesById(driveId, idToQuery, deltaLinkAvailable)' performed successfully on re-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"); } catch (OneDriveException e) { // display what the error is - log.vdebug("Query Error: changesAvailable = onedrive.viewChangesById(driveId, idToQuery, deltaLinkAvailable) on re-try after delay"); + 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.viewChangesById(driveId, idToQuery, deltaLinkAvailable) previously threw an error - retrying with empty deltaLinkAvailable"); + log.vdebug("changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable) previously threw an error - retrying with empty deltaLinkAvailable"); try { // try query with empty deltaLinkAvailable value deltaLinkAvailable = null; - changesAvailable = onedrive.viewChangesById(driveId, idToQuery, deltaLinkAvailable); - log.vdebug("Query 'changesAvailable = onedrive.viewChangesById(driveId, idToQuery, deltaLinkAvailable)' performed successfully on re-try"); + changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable); + log.vdebug("Query 'changesAvailable = onedrive.viewChangesByItemId(driveId, idToQuery, deltaLinkAvailable)' performed successfully on re-try"); } catch (OneDriveException e) { // Tried 3 times, give up displayOneDriveErrorMessage(e.msg); @@ -1216,7 +1421,7 @@ final class SyncEngine // 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))) { + if ((("value" in changes) != null) && ((deltaChanges > 0) || (oneDriveFullScanTrigger) || (nationalCloudChildrenScan) || (syncBusinessFolders) )) { auto nrChanges = count(changes["value"].array); auto changeCount = 0; @@ -1693,8 +1898,22 @@ final class SyncEngine unwanted = true; } else { // Edge case as the parent (from another users OneDrive account) will never be in the database - log.vdebug("Parent not in database but appears to be a shared folder: item.driveId (", item.driveId,"), item.parentId (", item.parentId,") not in local database"); - item.parentId = null; // ensures that it has no parent + 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); @@ -2327,39 +2546,65 @@ final class SyncEngine } } - // scan the given directory for differences and new items + // 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; + } + // Are we configured to use a National Cloud Deployment // 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, but National Cloud Deployments (US and DE) do not support /delta as a query - if (nationalCloudDeployment) { + if ((nationalCloudDeployment) || (syncBusinessFolders)) { // Select items that have a out-of-sync flag set - Item[] outOfSyncItems = itemdb.selectOutOfSyncItems(); - 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(); + 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(); + } } } } // scan for changes in the path provided - log.vlog("Uploading differences of ", path); + log.log("Uploading differences of ", logPath); Item item; - if (itemdb.selectByPath(path, defaultDriveId, item)) { - // Database scan of every item in DB, does it still exist on disk in the location the DB thinks it is - uploadDifferences(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 + foreach(dbItem; itemdb.selectByDriveId(driveId)) { + // Does it still exist on disk in the location the DB thinks it is + uploadDifferences(dbItem); + } + } else { + if (itemdb.selectByPath(path, driveId, item)) { + // Does it still exist on disk in the location the DB thinks it is + uploadDifferences(item); + } + } } - - log.vlog("Uploading new items of ", path); + + 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 @@ -2372,40 +2617,76 @@ final class SyncEngine // 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; + } + // Are we configured to use a National Cloud Deployment // 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, but National Cloud Deployments (US and DE) do not support /delta as a query - if (nationalCloudDeployment) { + if ((nationalCloudDeployment) || (syncBusinessFolders)) { // Select items that have a out-of-sync flag set - Item[] outOfSyncItems = itemdb.selectOutOfSyncItems(); - 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(); + 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(); + } } } } // scan for changes in the path provided - log.vlog("Uploading differences of ", path); + log.vlog("Uploading differences of ", logPath); Item item; - if (itemdb.selectByPath(path, defaultDriveId, item)) { - // Database scan of every item in DB, does it still exist on disk in the location the DB thinks it is - uploadDifferences(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 + foreach(dbItem; itemdb.selectByDriveId(driveId)) { + // Does it still exist on disk in the location the DB thinks it is + uploadDifferences(dbItem); + } + } else { + if (itemdb.selectByPath(path, driveId, item)) { + // Does it still exist on disk in the location the DB thinks it is + uploadDifferences(item); + } + } } } // scan the given directory for new items - for use with --monitor void scanForDifferencesFilesystemScan(const(string) path) { - log.vlog("Uploading new items of ", 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; + } + + log.vlog("Uploading new items of ", logPath); // Filesystem walk to find new files not uploaded uploadNewItems(path); } @@ -3072,7 +3353,7 @@ final class SyncEngine // filter out user configured items to skip if (path != ".") { if (isDir(path)) { - log.vdebug("Checking path: ", path); + log.vdebug("Checking local path: ", path); // Only check path if config is != "" if (cfg.getValueString("skip_dir") != "") { if (selectiveSync.isDirNameExcluded(path.strip('.').strip('/'))) { @@ -3080,6 +3361,20 @@ final class SyncEngine 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 + 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); @@ -3109,11 +3404,20 @@ final class SyncEngine // This item passed all the unwanted checks // We want to upload this new item if (isDir(path)) { - Item item; - if (!itemdb.selectByPath(path, defaultDriveId, 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 uploadCreateDir(path); } + // recursively traverse children // the above operation takes time and the directory might have // disappeared in the meantime @@ -3135,6 +3439,7 @@ final class SyncEngine return; } } else { + bool fileFoundInDB = false; // This item is a file long fileSize = getSize(path); // Can we upload this file - is there enough free space? - https://github.com/skilion/onedrive/issues/73 @@ -3144,8 +3449,15 @@ final class SyncEngine log.vlog("Ignoring OneDrive account quota details to upload file - this may fail if not enough space on OneDrive .."); } Item item; - if (!itemdb.selectByPath(path, defaultDriveId, item)) { - // item is not in the database, upload new 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, upload as new file uploadNewFile(path); // did the upload fail? @@ -3189,14 +3501,30 @@ final class SyncEngine log.vlog("OneDrive Client requested to create remote path: ", path); JSONValue onedrivePathDetails; Item parent; - // Was the path entered the root path? if (path != "."){ - // If this is null or empty - we cant query the database properly + // 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.selectByPathWithRemote(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 == "")){ - // What path to use? - string parentPath = dirName(path); // will be either . or something else - try { log.vdebug("Attempting to query OneDrive for this parent path: ", parentPath); onedrivePathDetails = onedrive.getPathDetails(parentPath); @@ -3244,11 +3572,11 @@ final class SyncEngine // 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.getPathDetails(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 + // 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) { @@ -3275,6 +3603,7 @@ final class SyncEngine // 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) { @@ -3332,7 +3661,7 @@ final class SyncEngine 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"); - string parentPath = dirName(path); + parentPath = dirName(path); uploadCreateDir(parentPath); } else { // parent is in database @@ -3375,9 +3704,31 @@ final class SyncEngine uploadFailed = false; Item parent; - // Check the database for the parent - //enforce(itemdb.selectByPath(dirName(path), defaultDriveId, parent), "The parent item is not in the local database"); - if ((dryRun) || (itemdb.selectByPath(dirName(path), defaultDriveId, 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.selectByPathWithRemote(parentPath, driveId, dbResponse)){ + // parent path was found in the database + parent = dbResponse; + parentPathFoundInDB = true; + } + } + } + + // If performing a dry-run or parent path is found in the database + if ((dryRun) || (parentPathFoundInDB)) { // Maximum file size upload // https://support.microsoft.com/en-au/help/3125202/restrictions-and-limitations-when-you-sync-files-and-folders // 1. OneDrive Business say's 15GB @@ -4157,7 +4508,9 @@ final class SyncEngine } } - private Item[] getChildren(string driveId, string id){ + // 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) { @@ -4223,7 +4576,8 @@ final class SyncEngine } // Parse and display error message received from OneDrive - private void displayOneDriveErrorMessage(string message) { + private void displayOneDriveErrorMessage(string message) + { log.error("\nERROR: OneDrive returned an error with the following message:"); auto errorArray = splitLines(message); log.error(" Error Message: ", errorArray[0]); @@ -4242,7 +4596,8 @@ final class SyncEngine } // Parse and display error message received from the local file system - private void displayFileSystemErrorMessage(string message) { + private void displayFileSystemErrorMessage(string message) + { log.error("ERROR: The local file system returned an error with the following message:"); auto errorArray = splitLines(message); log.error(" Error Message: ", errorArray[0]); @@ -4426,7 +4781,8 @@ final class SyncEngine } // Query Office 365 SharePoint Shared Library site to obtain it's Drive ID - void querySiteCollectionForDriveID(string o365SharedLibraryName){ + 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 @@ -4509,7 +4865,8 @@ final class SyncEngine } // Query OneDrive for a URL path of a file - void queryOneDriveForFileURL(string localFilePath, string syncDir) { + void queryOneDriveForFileURL(string localFilePath, string syncDir) + { // Query if file is valid locally if (exists(localFilePath)) { // File exists locally, does it exist in the database @@ -4543,7 +4900,8 @@ final class SyncEngine } // Query the OneDrive 'drive' to determine if we are 'in sync' or if there are pending changes - void queryDriveForChanges(const(string) path) { + void queryDriveForChanges(const(string) path) + { // Function variables int validChanges = 0; @@ -4632,7 +4990,7 @@ final class SyncEngine // Query OneDrive changes try { - changes = onedrive.viewChangesById(driveId, idToQuery, deltaLink); + 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. @@ -4716,7 +5074,8 @@ final class SyncEngine } // Create a fake OneDrive response suitable for use with saveItem - JSONValue createFakeResponse(const(string) path) { + JSONValue createFakeResponse(const(string) path) + { import std.digest.sha; // Generate a simulated JSON response which can be used // At a minimum we need: @@ -4786,7 +5145,8 @@ final class SyncEngine return fakeResponse; } - void handleOneDriveThrottleRequest() { + 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 @@ -4820,20 +5180,22 @@ final class SyncEngine // 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) { + 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 Default Root + // Get drive details for the provided driveId try { - rootData = onedrive.getDefaultRoot(); + driveData = onedrive.getPathDetailsById(driveId, idToQuery); } catch (OneDriveException e) { - log.vdebug("oneDriveRootDetails = onedrive.getDefaultRoot() generated a OneDriveException"); + 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. @@ -4846,7 +5208,7 @@ final class SyncEngine Thread.sleep(dur!"seconds"(30)); } // Retry original request by calling function again to avoid replicating any further error handling - rootData = onedrive.getDefaultRoot(); + driveData = onedrive.getPathDetailsById(driveId, idToQuery); } else { // There was a HTTP 5xx Server Side Error displayOneDriveErrorMessage(e.msg); @@ -4854,9 +5216,42 @@ final class SyncEngine exit(-1); } } - // add root JSON data to array - log.vlog("Adding OneDrive root details for processing"); - childrenData ~= rootData; + + 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); + // Must exit here + 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 @@ -4920,7 +5315,7 @@ final class SyncEngine } // process top level children - log.vlog("Adding ", count(topLevelChildren["value"].array), " OneDrive items for processing from OneDrive root"); + 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; @@ -4933,7 +5328,8 @@ final class SyncEngine string childDriveToQuery = child["parentReference"]["driveId"].str; auto childParentPath = child["parentReference"]["path"].str.split(":"); string folderPathToScan = childParentPath[1] ~ "/" ~ child["name"].str; - JSONValue[] grandChildrenData = queryForChildren(childDriveToQuery, childIdToQuery, folderPathToScan); + 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; @@ -4956,12 +5352,13 @@ final class SyncEngine "value": JSONValue(childrenData.array) ]; - // return response + // return the generated JSON response return deltaResponse; } // query child for children - JSONValue[] queryForChildren(const(char)[] driveId, const(char)[] idToQuery, const(char)[] childParentPath) { + JSONValue[] queryForChildren(const(char)[] driveId, const(char)[] idToQuery, const(char)[] childParentPath, string pathForLogging) + { // function variables JSONValue thisLevelChildren; JSONValue[] thisLevelChildrenData; @@ -5030,7 +5427,12 @@ final class SyncEngine // process this level children if (!childParentPath.empty) { - log.vlog("Adding ", count(thisLevelChildren["value"].array), " OneDrive items for processing from ", childParentPath); + // 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 @@ -5044,7 +5446,8 @@ final class SyncEngine string childDriveToQuery = child["parentReference"]["driveId"].str; auto grandchildParentPath = child["parentReference"]["path"].str.split(":"); string folderPathToScan = grandchildParentPath[1] ~ "/" ~ child["name"].str; - JSONValue[] grandChildrenData = queryForChildren(childDriveToQuery, childIdToQuery, folderPathToScan); + 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; @@ -5064,4 +5467,61 @@ final class SyncEngine // return response return thisLevelChildrenData; } -} + + // 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 = onedrive.getSharedWithMe(); + 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; + + // Debug response output + log.vdebug("shared folder entry: ", searchResult); + sharedFolderName = searchResult["name"].str; + + 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"); + } + } +} \ No newline at end of file