diff --git a/src/gui/invalidfilenamedialog.cpp b/src/gui/invalidfilenamedialog.cpp index 844f5b5e4bdf2..f26a5161abcda 100644 --- a/src/gui/invalidfilenamedialog.cpp +++ b/src/gui/invalidfilenamedialog.cpp @@ -64,7 +64,12 @@ QString illegalCharacterListToString(const QVector &illegalCharacters) namespace OCC { -InvalidFilenameDialog::InvalidFilenameDialog(AccountPtr account, Folder *folder, QString filePath, FileLocation fileLocation, QWidget *parent) +InvalidFilenameDialog::InvalidFilenameDialog(AccountPtr account, + Folder *folder, + QString filePath, + FileLocation fileLocation, + InvalidMode invalidMode, + QWidget *parent) : QDialog(parent) , _ui(new Ui::InvalidFilenameDialog) , _account(account) @@ -89,8 +94,37 @@ InvalidFilenameDialog::InvalidFilenameDialog(AccountPtr account, Folder *folder, _ui->descriptionLabel->setTextFormat(Qt::PlainText); _ui->errorLabel->setTextFormat(Qt::PlainText); - _ui->descriptionLabel->setText(tr("The file \"%1\" could not be synced because the name contains characters which are not allowed on this system.").arg(_originalFileName)); - _ui->explanationLabel->setText(tr("The following characters are not allowed on the system: * \" | & ? , ; : \\ / ~ < > leading/trailing spaces")); + switch (invalidMode) { + case InvalidMode::SystemInvalid: + _ui->descriptionLabel->setText(tr("The file \"%1\" could not be synced because the name contains characters which are not allowed on this system.").arg(_originalFileName)); + _ui->explanationLabel->setText(tr("The following characters are not allowed on the system: * \" | & ? , ; : \\ / ~ < > leading/trailing spaces")); + break; + case InvalidMode::ServerInvalid: + _ui->descriptionLabel->setText(tr("The file \"%1\" could not be synced because the name contains characters which are not allowed on the server.").arg(_originalFileName)); + + const auto caps = _account->capabilities(); + const auto forbiddenCharacters = caps.forbiddenFilenameCharacters(); + const auto forbiddenBasenames = caps.forbiddenFilenameBasenames(); + const auto forbiddenFilenames = caps.forbiddenFilenames(); + const auto forbiddenExtensions = caps.forbiddenFilenameExtensions(); + + auto explanations = QStringList(); + + if (!forbiddenCharacters.isEmpty()) { + explanations.append(tr("The following characters are not allowed: %1").arg(forbiddenCharacters.join(" "))); + } + if (!forbiddenBasenames.isEmpty()) { + explanations.append(tr("The following basenames are not allowed: %1").arg(forbiddenBasenames.join(" "))); + } + if (!forbiddenFilenames.isEmpty()) { + explanations.append(tr("The following filenames are not allowed: %1").arg(forbiddenFilenames.join(" "))); + } + if (!forbiddenExtensions.isEmpty()) { + explanations.append(tr("The following file extensions are not allowed: %1").arg(forbiddenExtensions.join(" "))); + } + _ui->explanationLabel->setText(explanations.join("\n")); + break; + } _ui->filenameLineEdit->setText(filePathFileInfo.fileName()); connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); diff --git a/src/gui/invalidfilenamedialog.h b/src/gui/invalidfilenamedialog.h index f2552e5fc1080..1477dd6451fa7 100644 --- a/src/gui/invalidfilenamedialog.h +++ b/src/gui/invalidfilenamedialog.h @@ -39,8 +39,17 @@ class InvalidFilenameDialog : public QDialog Default = 0, NewLocalFile, }; + enum class InvalidMode { + SystemInvalid, + ServerInvalid + }; - explicit InvalidFilenameDialog(AccountPtr account, Folder *folder, QString filePath, FileLocation fileLocation = FileLocation::Default, QWidget *parent = nullptr); + explicit InvalidFilenameDialog(AccountPtr account, + Folder *folder, + QString filePath, + FileLocation fileLocation = FileLocation::Default, + InvalidMode invalidMode = InvalidMode::SystemInvalid, + QWidget *parent = nullptr); ~InvalidFilenameDialog() override; diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index 3976e9950c3c5..61c05b8d8c796 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -729,9 +729,12 @@ void ActivityListModel::slotTriggerDefaultAction(const int activityIndex) const auto fileLocation = activity._syncFileItemStatus == SyncFileItem::FileNameInvalidOnServer ? InvalidFilenameDialog::FileLocation::NewLocalFile : InvalidFilenameDialog::FileLocation::Default; + const auto invalidMode = activity._syncFileItemStatus == SyncFileItem::FileNameInvalidOnServer + ? InvalidFilenameDialog::InvalidMode::ServerInvalid + : InvalidFilenameDialog::InvalidMode::SystemInvalid; _currentInvalidFilenameDialog = new InvalidFilenameDialog(_accountState->account(), folder, - folderDir.filePath(activity._file), fileLocation); + folderDir.filePath(activity._file), fileLocation, invalidMode); connect(_currentInvalidFilenameDialog, &InvalidFilenameDialog::accepted, folder, [folder]() { folder->scheduleThisFolderSoon(); }); @@ -840,9 +843,12 @@ void ActivityListModel::slotTriggerAction(const int activityIndex, const int act if (action._verb == "WEB") { Utility::openBrowser(QUrl(action._link)); return; - } else if (action._verb == "FIX_CONFLICT_LOCALLY" && - activity._type == Activity::SyncFileItemType && - (activity._syncFileItemStatus == SyncFileItem::Conflict || activity._syncFileItemStatus == SyncFileItem::FileNameClash)) { + } else if (((action._verb == "FIX_CONFLICT_LOCALLY" || action._verb == "RENAME_LOCAL_FILE") && + activity._type == Activity::SyncFileItemType && + (activity._syncFileItemStatus == SyncFileItem::Conflict || + activity._syncFileItemStatus == SyncFileItem::FileNameClash || + activity._syncFileItemStatus == SyncFileItem::FileNameInvalid || + activity._syncFileItemStatus == SyncFileItem::FileNameInvalidOnServer))) { slotTriggerDefaultAction(activityIndex); return; } else if (action._verb == ActivityLink::WhitelistFolderVerb && !activity._file.isEmpty()) { // _folder == folder alias/name, _file == folder/file path diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 7ecf98a61f26c..296da16eb181b 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -848,7 +848,15 @@ void User::processCompletedSyncItem(const Folder *folder, const SyncFileItemPtr _activityModel->addIgnoredFileToList(activity); } else { // add 'protocol error' to activity list - if (item->_status == SyncFileItem::Status::FileNameInvalid) { + if (item->_status == SyncFileItem::Status::FileNameInvalid || item->_status == SyncFileItem::Status::FileNameInvalidOnServer) { + ActivityLink buttonActivityLink; + buttonActivityLink._label = tr("Rename file"); + buttonActivityLink._link = activity._link.toString(); + buttonActivityLink._verb = "RENAME_LOCAL_FILE"; + buttonActivityLink._primary = true; + + activity._links = {buttonActivityLink}; + showDesktopNotification(item->_file, activity._subject, activity._id); } else if (item->_status == SyncFileItem::Conflict || item->_status == SyncFileItem::FileNameClash) { ActivityLink buttonActivityLink; diff --git a/src/libsync/capabilities.cpp b/src/libsync/capabilities.cpp index 143608b30082f..f7b84716f2daa 100644 --- a/src/libsync/capabilities.cpp +++ b/src/libsync/capabilities.cpp @@ -404,6 +404,26 @@ QStringList Capabilities::blacklistedFiles() const return _capabilities["files"].toMap()["blacklisted_files"].toStringList(); } +QStringList Capabilities::forbiddenFilenames() const +{ + return _capabilities["files"].toMap()["forbidden_filenames"].toStringList(); +} + +QStringList Capabilities::forbiddenFilenameCharacters() const +{ + return _capabilities["files"].toMap()["forbidden_filename_characters"].toStringList(); +} + +QStringList Capabilities::forbiddenFilenameBasenames() const +{ + return _capabilities["files"].toMap()["forbidden_filename_basenames"].toStringList(); +} + +QStringList Capabilities::forbiddenFilenameExtensions() const +{ + return _capabilities["files"].toMap()["forbidden_filename_extensions"].toStringList(); +} + /*-------------------------------------------------------------------------------------*/ // Direct Editing diff --git a/src/libsync/capabilities.h b/src/libsync/capabilities.h index ca2cb7190c4b4..da10854528b30 100644 --- a/src/libsync/capabilities.h +++ b/src/libsync/capabilities.h @@ -166,6 +166,11 @@ class OWNCLOUDSYNC_EXPORT Capabilities */ [[nodiscard]] QStringList blacklistedFiles() const; + [[nodiscard]] QStringList forbiddenFilenameCharacters() const; + [[nodiscard]] QStringList forbiddenFilenameBasenames() const; + [[nodiscard]] QStringList forbiddenFilenameExtensions() const; + [[nodiscard]] QStringList forbiddenFilenames() const; + /** * Whether conflict files should remain local (default) or should be uploaded. */ diff --git a/src/libsync/discovery.cpp b/src/libsync/discovery.cpp index 2b55eaf1c1d2f..089717f621684 100644 --- a/src/libsync/discovery.cpp +++ b/src/libsync/discovery.cpp @@ -292,8 +292,37 @@ bool ProcessDirectoryJob::handleExcluded(const QString &path, const Entries &ent } const auto &localName = entries.localEntry.name; + const auto splitName = localName.split('.'); + const auto &baseName = splitName.first(); + const auto extension = splitName.size() > 1 ? splitName.last() : QString(); + const auto accountCaps = _discoveryData->_account->capabilities(); + const auto forbiddenFilenames = accountCaps.forbiddenFilenames(); + const auto forbiddenBasenames = accountCaps.forbiddenFilenameBasenames(); + const auto forbiddenExtensions = accountCaps.forbiddenFilenameExtensions(); + const auto forbiddenChars = accountCaps.forbiddenFilenameCharacters(); + + const auto hasForbiddenFilename = forbiddenFilenames.contains(localName); + const auto hasForbiddenBasename = forbiddenBasenames.contains(baseName); + const auto hasForbiddenExtension = forbiddenExtensions.contains(extension); + + auto forbiddenCharMatch = QString{}; + const auto containsForbiddenCharacters = + std::any_of(forbiddenChars.cbegin(), + forbiddenChars.cend(), + [&localName, &forbiddenCharMatch](const QString &charPattern) { + if (localName.contains(charPattern)) { + forbiddenCharMatch = charPattern; + return true; + } + return false; + }); + if (excluded == CSYNC_NOT_EXCLUDED && !localName.isEmpty() - && _discoveryData->_serverBlacklistedFiles.contains(localName)) { + && (_discoveryData->_serverBlacklistedFiles.contains(localName) + || hasForbiddenFilename + || hasForbiddenBasename + || hasForbiddenExtension + || containsForbiddenCharacters)) { excluded = CSYNC_FILE_EXCLUDE_SERVER_BLACKLISTED; isInvalidPattern = true; } @@ -401,6 +430,19 @@ bool ProcessDirectoryJob::handleExcluded(const QString &path, const Entries &ent break; case CSYNC_FILE_EXCLUDE_SERVER_BLACKLISTED: item->_errorString = tr("The filename is blacklisted on the server."); + if (hasForbiddenFilename) { + item->_errorString += tr(" Reason: the entire filename is forbidden."); + } + if (hasForbiddenBasename) { + item->_errorString += tr(" Reason: the filename has a forbidden base name (filename start)."); + } + if (hasForbiddenExtension) { + item->_errorString += tr(" Reason: the file has a forbidden extension (.%1).").arg(extension); + } + if (containsForbiddenCharacters) { + item->_errorString += tr(" Reason: the filename contains a forbidden character (%1).").arg(forbiddenCharMatch); + } + item->_status = SyncFileItem::FileNameInvalidOnServer; break; } } diff --git a/test/testlocaldiscovery.cpp b/test/testlocaldiscovery.cpp index a3dc0d64d194a..11c7d2e0de0f6 100644 --- a/test/testlocaldiscovery.cpp +++ b/test/testlocaldiscovery.cpp @@ -294,6 +294,45 @@ private slots: QVERIFY(!fakeFolder.currentRemoteState().find("C/bar")); } + // Tests the behavior of forbidden filename detection + void testServerForbiddenFilenames() + { + FakeFolder fakeFolder { FileInfo::A12_B12_C12_S12() }; + QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState()); + + fakeFolder.syncEngine().account()->setCapabilities({ + { "files", QVariantMap { + { "forbidden_filenames", QVariantList { ".foo", "bar" } }, + { "forbidden_filename_characters", QVariantList { "_" } }, + { "forbidden_filename_basenames", QVariantList { "base" } }, + { "forbidden_filename_extensions", QVariantList { "ext" } } + } } + }); + + fakeFolder.localModifier().insert("C/.foo"); + fakeFolder.localModifier().insert("C/bar"); + fakeFolder.localModifier().insert("C/moo"); + fakeFolder.localModifier().insert("C/.moo"); + fakeFolder.localModifier().insert("C/potatopotato.txt"); + fakeFolder.localModifier().insert("C/potato_potato.txt"); + fakeFolder.localModifier().insert("C/basefilename.txt"); + fakeFolder.localModifier().insert("C/base.txt"); + fakeFolder.localModifier().insert("C/filename.txt"); + fakeFolder.localModifier().insert("C/filename.ext"); + + QVERIFY(fakeFolder.syncOnce()); + QVERIFY(fakeFolder.currentRemoteState().find("C/moo")); + QVERIFY(fakeFolder.currentRemoteState().find("C/.moo")); + QVERIFY(!fakeFolder.currentRemoteState().find("C/.foo")); + QVERIFY(!fakeFolder.currentRemoteState().find("C/bar")); + QVERIFY(fakeFolder.currentRemoteState().find("C/potatopotato.txt")); + QVERIFY(!fakeFolder.currentRemoteState().find("C/potato_potato.txt")); + QVERIFY(fakeFolder.currentRemoteState().find("C/basefilename.txt")); + QVERIFY(!fakeFolder.currentRemoteState().find("C/base.txt")); + QVERIFY(fakeFolder.currentRemoteState().find("C/filename.txt")); + QVERIFY(!fakeFolder.currentRemoteState().find("C/filename.ext")); + } + void testCreateFileWithTrailingSpaces_localAndRemoteTrimmedDoNotExist_renameAndUploadFile() { FakeFolder fakeFolder{FileInfo{}};