diff options
author | warwickmm <warwickmm@users.noreply.github.com> | 2020-11-09 04:59:37 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-11-09 04:59:37 +0300 |
commit | c7f9f8f5566b99d3189d9d32f24a888a8cdde231 (patch) | |
tree | 7a6128c2a2e3dcd8fb34e4c664020ec6905a99fa /Duplicati/Library | |
parent | 85139f86f243db02671b2fd527545e43a285c8e9 (diff) | |
parent | 812d698abab1cb8d1a663c23fd5aa7b50d89dde9 (diff) |
Merge pull request #4324 from martikyan/feature/telegram_backend
Add support for Telegram channels as a backend destination.
Diffstat (limited to 'Duplicati/Library')
-rw-r--r-- | Duplicati/Library/Backend/Telegram/ChannelFileInfo.cs | 86 | ||||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/Duplicati.Library.Backend.Telegram.csproj | 104 | ||||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/Duplicati.snk | bin | 0 -> 596 bytes | |||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/EncryptedFileSessionStore.cs | 159 | ||||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/Extensions/TelegramClientExtensions.cs | 48 | ||||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/Properties/AssemblyInfo.cs | 53 | ||||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/StreamReadHelper.cs | 48 | ||||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/Strings.cs | 63 | ||||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/TelegramBackend.cs | 521 | ||||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/app.config | 6 | ||||
-rw-r--r-- | Duplicati/Library/Backend/Telegram/packages.config | 5 |
11 files changed, 1093 insertions, 0 deletions
diff --git a/Duplicati/Library/Backend/Telegram/ChannelFileInfo.cs b/Duplicati/Library/Backend/Telegram/ChannelFileInfo.cs new file mode 100644 index 000000000..2233a9a93 --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/ChannelFileInfo.cs @@ -0,0 +1,86 @@ +using System; +using Duplicati.Library.Common.IO; +using TeleSharp.TL; + +namespace Duplicati.Library.Backend +{ + public class ChannelFileInfo : IEquatable<ChannelFileInfo> + { + public int MessageId { get; } + public long MediaDocAccessHash { get; } + public long DocumentId { get; } + public int Version { get; } + public long Size { get; } + public string Name { get; } + public DateTime Date { get; } + + public ChannelFileInfo() + { } + + public ChannelFileInfo(int messageId, long mediaDocAccessHash, long documentId, int version, long size, string name, DateTime date) + { + MessageId = messageId; + MediaDocAccessHash = mediaDocAccessHash; + DocumentId = documentId; + Version = version; + Size = size; + Name = name; + Date = date; + } + + public FileEntry ToFileEntry() + { + return new FileEntry(Name, Size) + { + LastModification = Date, + LastAccess = Date, + IsFolder = false + }; + } + + public TLInputDocumentFileLocation ToFileLocation() + { + return new TLInputDocumentFileLocation + { + Id = DocumentId, + Version = Version, + AccessHash = MediaDocAccessHash + }; + } + + public bool Equals(ChannelFileInfo other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return MessageId == other.MessageId; + } + + public override bool Equals(object obj) + { + return ReferenceEquals(this, obj) || obj is ChannelFileInfo other && Equals(other); + } + + public override int GetHashCode() + { + return MessageId; + } + + public static bool operator ==(ChannelFileInfo left, ChannelFileInfo right) + { + return Equals(left, right); + } + + public static bool operator !=(ChannelFileInfo left, ChannelFileInfo right) + { + return !Equals(left, right); + } + } +}
\ No newline at end of file diff --git a/Duplicati/Library/Backend/Telegram/Duplicati.Library.Backend.Telegram.csproj b/Duplicati/Library/Backend/Telegram/Duplicati.Library.Backend.Telegram.csproj new file mode 100644 index 000000000..7e8c7f6c9 --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/Duplicati.Library.Backend.Telegram.csproj @@ -0,0 +1,104 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="15.0"> + <PropertyGroup> + <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> + <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> + <ProjectGuid>{B6BB37DE-1DCA-401B-B92F-202F341916EA}</ProjectGuid> + <OutputType>Library</OutputType> + <AppDesignerFolder>Properties</AppDesignerFolder> + <RootNamespace>Duplicati.Library.Backend</RootNamespace> + <AssemblyName>Duplicati.Library.Backend.Telegram</AssemblyName> + <AssemblyOriginatorKeyFile>Duplicati.snk</AssemblyOriginatorKeyFile> + <FileUpgradeFlags> + </FileUpgradeFlags> + <OldToolsVersion>3.5</OldToolsVersion> + <TargetFrameworkVersion>v4.7.1</TargetFrameworkVersion> + <TargetFrameworkProfile /> + <UpgradeBackupLocation> + </UpgradeBackupLocation> + <UseMSBuildEngine>false</UseMSBuildEngine> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> + <DebugSymbols>true</DebugSymbols> + <DebugType>full</DebugType> + <Optimize>false</Optimize> + <OutputPath>bin\Debug\</OutputPath> + <DefineConstants>DEBUG;TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> + <DebugType>pdbonly</DebugType> + <Optimize>true</Optimize> + <OutputPath>bin\Release\</OutputPath> + <DefineConstants>TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <ItemGroup> + <Reference Include="SharpAESCrypt, Version=1.3.3.0, Culture=neutral, PublicKeyToken=null"> + <HintPath>..\..\..\..\packages\SharpAESCrypt.exe.1.3.3\lib\netstandard2.0\SharpAESCrypt.exe</HintPath> + </Reference> + <Reference Include="System" /> + <Reference Include="System.Core" /> + <Reference Include="System.Net.Http" /> + <Reference Include="TeleSharp.TL, Version=1.0.0.0, Culture=neutral, processorArchitecture=MSIL"> + <HintPath>..\..\..\..\packages\TLSharp.0.1.0.574\lib\net46\TeleSharp.TL.dll</HintPath> + </Reference> + <Reference Include="TLSharp.Core, Version=1.0.0.0, Culture=neutral, processorArchitecture=MSIL"> + <HintPath>..\..\..\..\packages\TLSharp.0.1.0.574\lib\net46\TLSharp.Core.dll</HintPath> + </Reference> + </ItemGroup> + <ItemGroup> + <Compile Include="ChannelFileInfo.cs" /> + <Compile Include="EncryptedFileSessionStore.cs" /> + <Compile Include="Extensions\TelegramClientExtensions.cs" /> + <Compile Include="StreamReadHelper.cs" /> + <Compile Include="TelegramBackend.cs" /> + <Compile Include="Properties\AssemblyInfo.cs" /> + <Compile Include="Strings.cs" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\AutoUpdater\Duplicati.Library.AutoUpdater.csproj"> + <Project>{7e119745-1f62-43f0-936c-f312a1912c0b}</Project> + <Name>Duplicati.Library.AutoUpdater</Name> + </ProjectReference> + <ProjectReference Include="..\..\Logging\Duplicati.Library.Logging.csproj"> + <Project>{d10a5fc0-11b4-4e70-86aa-8aea52bd9798}</Project> + <Name>Duplicati.Library.Logging</Name> + </ProjectReference> + <ProjectReference Include="..\..\Modules\Builtin\Duplicati.Library.Modules.Builtin.csproj"> + <Project>{52826615-7964-47FE-B4B3-1B2DBDF605B9}</Project> + <Name>Duplicati.Library.Modules.Builtin</Name> + </ProjectReference> + <ProjectReference Include="..\..\Utility\Duplicati.Library.Utility.csproj"> + <Project>{DE3E5D4C-51AB-4E5E-BEE8-E636CEBFBA65}</Project> + <Name>Duplicati.Library.Utility</Name> + </ProjectReference> + <ProjectReference Include="..\..\Interface\Duplicati.Library.Interface.csproj"> + <Project>{C5899F45-B0FF-483C-9D38-24A9FCAAB237}</Project> + <Name>Duplicati.Library.Interface</Name> + </ProjectReference> + <ProjectReference Include="..\..\Localization\Duplicati.Library.Localization.csproj"> + <Project>{B68F2214-951F-4F78-8488-66E1ED3F50BF}</Project> + <Name>Duplicati.Library.Localization</Name> + </ProjectReference> + <ProjectReference Include="..\..\Common\Duplicati.Library.Common.csproj"> + <Project>{D63E53E4-A458-4C2F-914D-92F715F58ACF}</Project> + <Name>Duplicati.Library.Common</Name> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <None Include="app.config" /> + <None Include="Duplicati.snk" /> + <None Include="packages.config" /> + </ItemGroup> + <Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" /> + <!-- To modify your build process, add your task inside one of the targets below and uncomment it. + Other similar extension points exist, see Microsoft.Common.targets. + <Target Name="BeforeBuild"> + </Target> + <Target Name="AfterBuild"> + </Target> + --> +</Project>
\ No newline at end of file diff --git a/Duplicati/Library/Backend/Telegram/Duplicati.snk b/Duplicati/Library/Backend/Telegram/Duplicati.snk Binary files differnew file mode 100644 index 000000000..e0c1e2dd8 --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/Duplicati.snk diff --git a/Duplicati/Library/Backend/Telegram/EncryptedFileSessionStore.cs b/Duplicati/Library/Backend/Telegram/EncryptedFileSessionStore.cs new file mode 100644 index 000000000..5f0016874 --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/EncryptedFileSessionStore.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Duplicati.Library.AutoUpdater; +using TLSharp.Core; + +namespace Duplicati.Library.Backend +{ + public class EncryptedFileSessionStore : ISessionStore + { + private static readonly object m_lockObj = new object(); + private static readonly uint[] m_lookup32 = CreateLookup32(); + + private readonly string m_teleDataPath; + private readonly string m_password; + private readonly SHA256 m_sha = SHA256.Create(); + private static readonly ConcurrentDictionary<string, byte[]> m_userIdLastSessionMap = new ConcurrentDictionary<string, byte[]>(); + + public EncryptedFileSessionStore(string password) + { + if (string.IsNullOrWhiteSpace(password)) + { + throw new ArgumentNullException(nameof(password)); + } + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var appName = AutoUpdateSettings.AppName; + m_teleDataPath = Path.Combine(appData, appName, nameof(Telegram)); + m_password = password; + + Directory.CreateDirectory(m_teleDataPath); + } + + + public void Save(Session session) + { + if (session.AuthKey == null) + { + return; + } + + var sessionId = session.SessionUserId; + var filePath = GetSessionFilePath(sessionId); + var sessionBytes = session.ToBytes(); + + if (m_userIdLastSessionMap.TryGetValue(sessionId, out var sessionCache)) + { + if (sessionCache.SequenceEqual(sessionBytes)) + { + return; + } + } + + WriteToEncryptedStorage(sessionBytes, m_password, filePath, sessionId); + } + + public Session Load(string userId) + { + if (m_userIdLastSessionMap.TryGetValue(userId, out var cachedBytes)) + { + var cachedSession = Session.FromBytes(cachedBytes, this, userId); + return cachedSession; + } + + var filePath = GetSessionFilePath(userId); + var sessionBytes = ReadFromEncryptedStorage(m_password, filePath); + + if (sessionBytes == null) + { + return null; + } + + var session = Session.FromBytes(sessionBytes, this, userId); + return session; + } + + private string GetSessionFilePath(string userId) + { + userId = userId.TrimStart('+'); + var sha = GetShortSha(userId); + var sessionFilePath = Path.Combine(m_teleDataPath, $"t_{sha}.dat"); + + return sessionFilePath; + } + + private static void WriteToEncryptedStorage(byte[] bytesToWrite, string pass, string path, string sessionId) + { + lock (m_lockObj) + { + using (var sessionMs = new MemoryStream(bytesToWrite)) + using (var file = File.Open(path, FileMode.Create, FileAccess.Write)) + { + SharpAESCrypt.SharpAESCrypt.Encrypt(pass, sessionMs, file); + } + + m_userIdLastSessionMap[sessionId] = bytesToWrite; + } + } + + private byte[] ReadFromEncryptedStorage(string pass, string path) + { + var fileInfo = new FileInfo(path); + if (fileInfo.Exists == false || fileInfo.Length == 0) + { + return null; + } + + lock (m_lockObj) + { + using (var sessionMs = new MemoryStream()) + using (var file = File.Open(path, FileMode.Open, FileAccess.Read)) + { + SharpAESCrypt.SharpAESCrypt.Decrypt(pass, file, sessionMs); + return sessionMs.ToArray(); + } + } + } + + private string GetShortSha(string input) + { + var inputBytes = Encoding.UTF8.GetBytes(input); + + var longShaBytes = m_sha.ComputeHash(inputBytes); + var longSha = ByteArrayToHexViaLookup32(longShaBytes); + var result = longSha.Substring(0, 16); + + return result; + } + + private static uint[] CreateLookup32() + { + var result = new uint[256]; + for (var i = 0; i < 256; i++) + { + var s = i.ToString("X2"); + result[i] = s[0] + ((uint)s[1] << 16); + } + + return result; + } + + private static string ByteArrayToHexViaLookup32(byte[] bytes) + { + var lookup32 = m_lookup32; + var result = new char[bytes.Length * 2]; + for (var i = 0; i < bytes.Length; i++) + { + var val = lookup32[bytes[i]]; + result[2 * i] = (char)val; + result[2 * i + 1] = (char)(val >> 16); + } + + return new string(result); + } + } +}
\ No newline at end of file diff --git a/Duplicati/Library/Backend/Telegram/Extensions/TelegramClientExtensions.cs b/Duplicati/Library/Backend/Telegram/Extensions/TelegramClientExtensions.cs new file mode 100644 index 000000000..7b1fd82ec --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/Extensions/TelegramClientExtensions.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Reflection; +using TLSharp.Core; +using TLSharp.Core.Network; + +namespace Duplicati.Library.Backend.Extensions +{ + public static class TelegramClientExtensions + { + private static BindingFlags _bindingFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Default; + + public static bool IsReallyConnected(this TelegramClient client) + { + if (client.IsConnected == false) + { + return false; + } + + var senderFieldInfo = typeof(TelegramClient).GetField("sender", _bindingFlags); + var sender = (MtProtoSender)senderFieldInfo.GetValue(client); + + if (sender == null) + { + return false; + } + + var transportFieldInfo = typeof(TelegramClient).GetField("transport", _bindingFlags); + var transportField = (TcpTransport)transportFieldInfo.GetValue(client); + + if (transportField == null || transportField.IsConnected == false) + { + return false; + } + + var tcpClientFieldInfo = typeof(TcpTransport).GetField("tcpClient", _bindingFlags); + var tcpClient = (TcpClient)tcpClientFieldInfo.GetValue(transportField); + + if (tcpClient == null || tcpClient.Connected == false) + { + return false; + } + + return true; + } + } +}
\ No newline at end of file diff --git a/Duplicati/Library/Backend/Telegram/Properties/AssemblyInfo.cs b/Duplicati/Library/Backend/Telegram/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..d33f05968 --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/Properties/AssemblyInfo.cs @@ -0,0 +1,53 @@ +#region Disclaimer / License +// Copyright (C) 2015, The Duplicati Team +// http://www.duplicati.com, info@duplicati.com +// +// This library is free software; you can redistribute it and/or +// modify it under the terms of the GNU Lesser General Public +// License as published by the Free Software Foundation; either +// version 2.1 of the License, or (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +// +#endregion +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Telegram")] +[assembly: AssemblyDescription("A Telegram backend for Duplicati")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Duplicati Team")] +[assembly: AssemblyProduct("Duplicati.Backend.Telegram")] +[assembly: AssemblyCopyright("LGPL, Copyright © Duplicati Team 2015")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("afa68988-4d82-490e-8044-acbc7d52ef4f")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Revision and Build Numbers +// by using the '*' as shown below: +[assembly: AssemblyVersion("2.0.0.7")] +[assembly: AssemblyFileVersion("2.0.0.7")] diff --git a/Duplicati/Library/Backend/Telegram/StreamReadHelper.cs b/Duplicati/Library/Backend/Telegram/StreamReadHelper.cs new file mode 100644 index 000000000..d436bdb27 --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/StreamReadHelper.cs @@ -0,0 +1,48 @@ +using System.IO; +using Duplicati.Library.Utility; + +namespace Duplicati.Library.Backend +{ + /// <summary> + /// Private helper class to fix a bug with the StreamReader + /// </summary> + internal class StreamReadHelper : OverrideableStream + { + /// <summary> + /// Once the stream has returned 0 as the read count it is disposed, + /// and subsequent read requests will throw an ObjectDisposedException + /// </summary> + private bool m_empty; + + /// <summary> + /// Basic initialization, just pass the stream to the super class + /// </summary> + /// <param name="stream"></param> + public StreamReadHelper(Stream stream) : base(stream) + { } + + /// <summary> + /// Override the read function to make sure that we only return less than the requested amount of data if the stream is exhausted + /// </summary> + /// <param name="buffer">The buffer to place data in</param> + /// <param name="offset">The offset into the buffer to start at</param> + /// <param name="count">The number of bytes to read</param> + /// <returns>The number of bytes read</returns> + public override int Read(byte[] buffer, int offset, int count) + { + var readCount = 0; + int a; + + while (!m_empty && count > 0) + { + a = base.Read(buffer, offset, count); + readCount += a; + count -= a; + offset += a; + m_empty = a == 0; + } + + return readCount; + } + } +}
\ No newline at end of file diff --git a/Duplicati/Library/Backend/Telegram/Strings.cs b/Duplicati/Library/Backend/Telegram/Strings.cs new file mode 100644 index 000000000..16a7c79a3 --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/Strings.cs @@ -0,0 +1,63 @@ +using Duplicati.Library.Localization.Short; + +namespace Duplicati.Library.Backend +{ + internal static class Strings + { + #region Constants + + public const string API_ID_KEY = "api-id"; + public const string API_HASH_KEY = "api-hash"; + public const string PHONE_NUMBER_KEY = "phone-number"; + public const string AUTH_CODE_KEY = "auth-code"; + public const string AUTH_PASSWORD = "auth-password"; + public const string CHANNEL_NAME = "channel-name"; + + #endregion + + #region General + + public static string DisplayName { get; } = "Telegram"; + public static string Description => LC.L("This backend can read and write data to a Telegram backend"); + + #endregion + + #region Errors + + public static string WrongAuthCodeError => LC.L("The auth code is incorrect"); + public static string NoAuthCodeError => LC.L("The auth code is missing"); + public static string NoPasswordError => LC.L("The password is missing"); + public static string NoChannelNameError => LC.L("The channel name is missing"); + public static string NoApiIdError => LC.L("The API ID is missing"); + public static string NoApiHashError => LC.L("The API hash is missing"); + public static string NoPhoneNumberError => LC.L("The phone number is missing"); + + #endregion + + + #region Descriptions + + public static string ApiIdShort => LC.L("The API ID"); + public static string ApiHashShort => LC.L("The API hash"); + public static string ApiIdLong => LC.L("The API ID retrieved from https://my.telegram.org/"); + public static string ApiHashLong => LC.L("The API hash retrieved from https://my.telegram.org/"); + public static string PhoneNumberShort => LC.L("Your phone number"); + public static string PhoneNumberLong => LC.L("The phone number you registered with"); + public static string AuthCodeShort => LC.L("The code you should have received (if you did)"); + public static string AuthCodeLong => LC.L("The auth code that you received. Input only if you did receive it"); + public static string PasswordShort => LC.L("2FA password (if enabled)"); + public static string PasswordLong => LC.L("The 2 step verification password. Input only if you have set it up"); + public static string ChannelName => LC.L("The channel name of the backup"); + + #endregion + + #region Formats + + public const string TELEGRAM_FLOOD = "It's required to wait {0} seconds before continuing"; + public const string STARTING_EXECUTING = "Starting executing action {0}"; + public const string DONE_EXECUTING = "Done executing action {0}"; + public const string USER_INFO_EXC = "Exception thrown that should be shown on UI"; + + #endregion + } +}
\ No newline at end of file diff --git a/Duplicati/Library/Backend/Telegram/TelegramBackend.cs b/Duplicati/Library/Backend/Telegram/TelegramBackend.cs new file mode 100644 index 000000000..373809659 --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/TelegramBackend.cs @@ -0,0 +1,521 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Duplicati.Library.Backend.Extensions; +using Duplicati.Library.Interface; +using Duplicati.Library.Logging; +using TeleSharp.TL; +using TeleSharp.TL.Channels; +using TeleSharp.TL.Messages; +using TLSharp.Core; +using TLSharp.Core.Exceptions; +using TLSharp.Core.Network.Exceptions; +using TLSharp.Core.Utils; +using TLRequestDeleteMessages = TeleSharp.TL.Channels.TLRequestDeleteMessages; + +namespace Duplicati.Library.Backend +{ + public class Telegram : IStreamingBackend + { + private static TelegramClient m_telegramClient; + private readonly EncryptedFileSessionStore m_encSessionStore; + + private static readonly object m_lockObj = new object(); + + private readonly int m_apiId; + private readonly string m_apiHash; + private readonly string m_authCode; + private readonly string m_password; + private readonly string m_channelName; + private readonly string m_phoneNumber; + private TLChannel m_channelCache; + + private static string m_phoneCodeHash; + private static readonly string m_logTag = Log.LogTagFromType(typeof(Telegram)); + private const int BYTES_IN_MEBIBYTE = 1048576; + + public Telegram() + { } + + public Telegram(string url, Dictionary<string, string> options) + { + if (options.TryGetValue(Strings.API_ID_KEY, out var apiId)) + { + m_apiId = int.Parse(apiId); + } + + if (options.TryGetValue(Strings.API_HASH_KEY, out var apiHash)) + { + m_apiHash = apiHash.Trim(); + } + + if (options.TryGetValue(Strings.PHONE_NUMBER_KEY, out var phoneNumber)) + { + m_phoneNumber = phoneNumber.Trim(); + } + + if (options.TryGetValue(Strings.AUTH_CODE_KEY, out var authCode)) + { + m_authCode = authCode.Trim(); + } + + if (options.TryGetValue(Strings.AUTH_PASSWORD, out var password)) + { + m_password = password.Trim(); + } + + if (options.TryGetValue(Strings.CHANNEL_NAME, out var channelName)) + { + m_channelName = channelName.Trim(); + } + + if (m_apiId == 0) + { + throw new UserInformationException(Strings.NoApiIdError, nameof(Strings.NoApiIdError)); + } + + if (string.IsNullOrEmpty(m_apiHash)) + { + throw new UserInformationException(Strings.NoApiHashError, nameof(Strings.NoApiHashError)); + } + + if (string.IsNullOrEmpty(m_phoneNumber)) + { + throw new UserInformationException(Strings.NoPhoneNumberError, nameof(Strings.NoPhoneNumberError)); + } + + if (string.IsNullOrEmpty(m_channelName)) + { + throw new UserInformationException(Strings.NoChannelNameError, nameof(Strings.NoChannelNameError)); + } + + m_encSessionStore = new EncryptedFileSessionStore($"{m_apiHash}_{m_apiId}"); + InitializeTelegramClient(); + } + + private void InitializeTelegramClient() + { + m_telegramClient = m_telegramClient ?? new TelegramClient(m_apiId, m_apiHash, m_encSessionStore, m_phoneNumber); + } + + public void Dispose() + { + // Do not dispose m_telegramClient. + // There are bugs connected with reusing + // the old sockets + } + + public string DisplayName { get; } = Strings.DisplayName; + public string ProtocolKey { get; } = "telegram"; + + public IEnumerable<IFileEntry> List() + { + return SafeExecute<IEnumerable<IFileEntry>>(() => + { + Authenticate(); + EnsureChannelCreated(); + var fileInfos = ListChannelFileInfos(); + var result = fileInfos.Select(fi => fi.ToFileEntry()); + return result; + }, + nameof(List)); + } + + public Task PutAsync(string remotename, Stream stream, CancellationToken cancelToken) + { + SafeExecute(() => + { + cancelToken.ThrowIfCancellationRequested(); + + Authenticate(); + + cancelToken.ThrowIfCancellationRequested(); + + var channel = GetChannel(); + + cancelToken.ThrowIfCancellationRequested(); + + using (var sr = new StreamReader(new StreamReadHelper(stream))) + { + cancelToken.ThrowIfCancellationRequested(); + EnsureConnected(cancelToken); + + cancelToken.ThrowIfCancellationRequested(); + var file = m_telegramClient.UploadFile(remotename, sr, cancelToken).GetAwaiter().GetResult(); + + cancelToken.ThrowIfCancellationRequested(); + var inputPeerChannel = new TLInputPeerChannel {ChannelId = channel.Id, AccessHash = (long)channel.AccessHash}; + var fileNameAttribute = new TLDocumentAttributeFilename + { + FileName = remotename + }; + + EnsureConnected(cancelToken); + m_telegramClient.SendUploadedDocument(inputPeerChannel, file, remotename, "application/zip", new TLVector<TLAbsDocumentAttribute> {fileNameAttribute}, cancelToken).GetAwaiter().GetResult(); + } + }, + nameof(PutAsync)); + + return Task.CompletedTask; + } + + public void Get(string remotename, Stream stream) + { + SafeExecute(() => + { + var fileInfo = ListChannelFileInfos().First(fi => fi.Name == remotename); + var fileLocation = fileInfo.ToFileLocation(); + + var limit = BYTES_IN_MEBIBYTE; + var currentOffset = 0; + + + while (currentOffset < fileInfo.Size) + { + try + { + EnsureConnected(); + var file = m_telegramClient.GetFile(fileLocation, limit, currentOffset).GetAwaiter().GetResult(); + stream.Write(file.Bytes, 0, file.Bytes.Length); + currentOffset += file.Bytes.Length; + } + catch (InvalidOperationException e) + { + if (e.Message.Contains("Couldn't read the packet length") == false) + { + throw; + } + } + } + }, + $"{nameof(Get)}"); + } + + public Task PutAsync(string remotename, string filename, CancellationToken cancelToken) + { + using (var fs = File.OpenRead(filename)) + { + PutAsync(remotename, fs, cancelToken).GetAwaiter().GetResult(); + } + + return Task.CompletedTask; + } + + public void Get(string remotename, string filename) + { + using (var fs = new FileStream(filename, FileMode.Create, FileAccess.Write)) + { + Get(remotename, fs); + } + } + + public void Delete(string remotename) + { + SafeExecute(() => + { + var channel = GetChannel(); + var fileInfo = ListChannelFileInfos().FirstOrDefault(fi => fi.Name == remotename); + if (fileInfo == null) + { + return; + } + + var request = new TLRequestDeleteMessages + { + Channel = new TLInputChannel + { + ChannelId = channel.Id, + AccessHash = channel.AccessHash.Value + }, + Id = new TLVector<int> {fileInfo.MessageId} + }; + + EnsureConnected(); + m_telegramClient.SendRequestAsync<TLAffectedMessages>(request).GetAwaiter().GetResult(); + }, + $"{nameof(Delete)}({remotename})"); + } + + public List<ChannelFileInfo> ListChannelFileInfos() + { + var channel = GetChannel(); + + var inputPeerChannel = new TLInputPeerChannel {ChannelId = channel.Id, AccessHash = channel.AccessHash.Value}; + var result = new List<ChannelFileInfo>(); + var oldMinDate = 0L; + var newMinDate = (long?)null; + + while (oldMinDate != newMinDate) + { + oldMinDate = newMinDate ?? 0L; + RetrieveMessages(inputPeerChannel, result, oldMinDate); + if (result.Any() == false) + { + break; + } + + newMinDate = result.Min(cfi => Utility.Utility.NormalizeDateTimeToEpochSeconds(cfi.Date)); + } + + result = result.Distinct().ToList(); + return result; + } + + private void RetrieveMessages(TLInputPeerChannel inputPeerChannel, List<ChannelFileInfo> result, long offsetDate) + { + EnsureConnected(); + var absHistory = m_telegramClient.GetHistoryAsync(inputPeerChannel, offsetDate: (int)offsetDate).GetAwaiter().GetResult(); + var history = ((TLChannelMessages)absHistory).Messages.OfType<TLMessage>(); + + foreach (var msg in history) + { + if (msg.Media is TLMessageMediaDocument media && + media.Document is TLDocument mediaDoc) + { + var fileInfo = new ChannelFileInfo( + msg.Id, + mediaDoc.AccessHash, + mediaDoc.Id, + mediaDoc.Version, + mediaDoc.Size, + media.Caption, + DateTimeOffset.FromUnixTimeSeconds(msg.Date).UtcDateTime); + + result.Add(fileInfo); + } + } + } + + public IList<ICommandLineArgument> SupportedCommands { get; } = new List<ICommandLineArgument> + { + new CommandLineArgument(Strings.API_ID_KEY, CommandLineArgument.ArgumentType.Integer, Strings.ApiIdShort, Strings.ApiIdLong), + new CommandLineArgument(Strings.API_HASH_KEY, CommandLineArgument.ArgumentType.String, Strings.ApiHashShort, Strings.ApiHashLong), + new CommandLineArgument(Strings.PHONE_NUMBER_KEY, CommandLineArgument.ArgumentType.String, Strings.PhoneNumberShort, Strings.PhoneNumberLong), + new CommandLineArgument(Strings.AUTH_CODE_KEY, CommandLineArgument.ArgumentType.String, Strings.AuthCodeShort, Strings.AuthCodeLong), + new CommandLineArgument(Strings.AUTH_PASSWORD, CommandLineArgument.ArgumentType.String, Strings.PasswordShort, Strings.PasswordLong), + new CommandLineArgument(Strings.CHANNEL_NAME, CommandLineArgument.ArgumentType.String, Strings.ChannelName, Strings.ChannelName) + }; + + public string Description { get; } = Strings.Description; + + public string[] DNSName { get; } + + public void Test() + { + SafeExecute(Authenticate, nameof(Authenticate)); + } + + public void CreateFolder() + { + SafeExecute(() => + { + Authenticate(); + EnsureChannelCreated(); + }, + nameof(CreateFolder)); + } + + private TLChannel GetChannel() + { + if (m_channelCache != null) + { + return m_channelCache; + } + + var absChats = GetChats(); + + var channel = (TLChannel)absChats.FirstOrDefault(chat => chat is TLChannel tlChannel && tlChannel.Title == m_channelName); + m_channelCache = channel; + return channel; + } + + private IEnumerable<TLAbsChat> GetChats() + { + var lastDate = 0; + while (true) + { + EnsureConnected(); + var dialogs = m_telegramClient.GetUserDialogsAsync(lastDate).GetAwaiter().GetResult(); + var tlDialogs = dialogs as TLDialogs; + var tlDialogsSlice = dialogs as TLDialogsSlice; + + foreach (var chat in tlDialogs?.Chats ?? tlDialogsSlice?.Chats) + { + switch (chat) + { + case TLChannelForbidden _: + case TLChatForbidden _: + break; + case TLChat c: + lastDate = c.Date; + break; + case TLChannel c: + lastDate = c.Date; + break; + + default: + throw new NotSupportedException($"Unsupported chat type {chat.GetType()}"); + } + + yield return chat; + } + + if (tlDialogs?.Dialogs?.Count < 100 || tlDialogsSlice?.Dialogs?.Count < 100) + { + yield break; + } + } + } + + private void EnsureChannelCreated() + { + var channel = GetChannel(); + if (channel == null) + { + var newGroup = new TLRequestCreateChannel + { + Broadcast = false, + Megagroup = false, + Title = m_channelName, + About = string.Empty + }; + + EnsureConnected(); + m_telegramClient.SendRequestAsync<object>(newGroup).GetAwaiter().GetResult(); + } + } + + private void Authenticate() + { + EnsureConnected(); + + if (IsAuthenticated()) + { + return; + } + + try + { + if (m_phoneCodeHash == null) + { + EnsureConnected(); + var phoneCodeHash = m_telegramClient.SendCodeRequestAsync(m_phoneNumber).GetAwaiter().GetResult(); + SetPhoneCodeHash(phoneCodeHash); + m_telegramClient.Session.Save(); + + if (string.IsNullOrEmpty(m_authCode)) + { + throw new UserInformationException(Strings.NoAuthCodeError, nameof(Strings.NoAuthCodeError)); + } + + throw new UserInformationException(Strings.WrongAuthCodeError, nameof(Strings.WrongAuthCodeError)); + } + + m_telegramClient.MakeAuthAsync(m_phoneNumber, m_phoneCodeHash, m_authCode).GetAwaiter().GetResult(); + } + catch (CloudPasswordNeededException) + { + if (string.IsNullOrEmpty(m_password)) + { + m_telegramClient.Session.Save(); + throw new UserInformationException(Strings.NoPasswordError, nameof(Strings.NoPasswordError)); + } + + EnsureConnected(); + var passwordSetting = m_telegramClient.GetPasswordSetting().GetAwaiter().GetResult(); + m_telegramClient.MakeAuthWithPasswordAsync(passwordSetting, m_password).GetAwaiter().GetResult(); + } + + m_telegramClient.Session.Save(); + } + + + private void EnsureConnected(CancellationToken cancelToken = default(CancellationToken)) + { + if (m_telegramClient.IsReallyConnected()) + { + return; + } + + cancelToken.ThrowIfCancellationRequested(); + + m_telegramClient.ConnectAsync(false, cancelToken).GetAwaiter().GetResult(); + + cancelToken.ThrowIfCancellationRequested(); + + if (m_telegramClient.IsReallyConnected() == false) + { + throw new WebException("Unable to connect to telegram"); + } + } + + private bool IsAuthenticated() + { + var isAuthorized = m_telegramClient.IsUserAuthorized(); + return isAuthorized; + } + + private static void SetPhoneCodeHash(string phoneCodeHash) + { + m_phoneCodeHash = phoneCodeHash; + } + + private void SafeExecute(Action action, string actionName) + { + lock (m_lockObj) + { + Log.WriteInformationMessage(m_logTag, nameof(Strings.STARTING_EXECUTING), Strings.STARTING_EXECUTING, actionName); + try + { + action(); + } + catch (UserInformationException uiExc) + { + Log.WriteWarningMessage(m_logTag, nameof(Strings.USER_INFO_EXC), uiExc, Strings.USER_INFO_EXC); + throw; + } + catch (FloodException floodExc) + { + var randSeconds = new Random().Next(2, 15); + Log.WriteInformationMessage(m_logTag, nameof(Strings.TELEGRAM_FLOOD), Strings.TELEGRAM_FLOOD, floodExc.TimeToWait.TotalSeconds + randSeconds); + Thread.Sleep(floodExc.TimeToWait + TimeSpan.FromSeconds(randSeconds)); + SafeExecute(action, actionName); + } + } + + Log.WriteInformationMessage(m_logTag, nameof(Strings.DONE_EXECUTING), Strings.DONE_EXECUTING, actionName); + } + + private T SafeExecute<T>(Func<T> func, string actionName) + { + lock (m_lockObj) + { + Log.WriteInformationMessage(m_logTag, nameof(Strings.STARTING_EXECUTING), Strings.STARTING_EXECUTING, actionName); + try + { + var res = func(); + Log.WriteInformationMessage(m_logTag, nameof(Strings.DONE_EXECUTING), Strings.DONE_EXECUTING, actionName); + return res; + } + catch (UserInformationException uiExc) + { + Log.WriteWarningMessage(m_logTag, nameof(Strings.USER_INFO_EXC), uiExc, Strings.USER_INFO_EXC); + throw; + } + catch (FloodException floodExc) + { + var randSeconds = new Random().Next(2, 15); + Log.WriteInformationMessage(m_logTag, nameof(Strings.TELEGRAM_FLOOD), Strings.TELEGRAM_FLOOD, floodExc.TimeToWait.TotalSeconds + randSeconds); + Thread.Sleep(floodExc.TimeToWait); + var res = SafeExecute(func, actionName); + Log.WriteInformationMessage(m_logTag, nameof(Strings.DONE_EXECUTING), Strings.DONE_EXECUTING, actionName); + return res; + } + } + } + } +}
\ No newline at end of file diff --git a/Duplicati/Library/Backend/Telegram/app.config b/Duplicati/Library/Backend/Telegram/app.config new file mode 100644 index 000000000..ba98fc967 --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/app.config @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<configuration> + <startup> + <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.1"/> + </startup> +</configuration> diff --git a/Duplicati/Library/Backend/Telegram/packages.config b/Duplicati/Library/Backend/Telegram/packages.config new file mode 100644 index 000000000..ecd8f06db --- /dev/null +++ b/Duplicati/Library/Backend/Telegram/packages.config @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="SharpAESCrypt.exe" version="1.3.3" targetFramework="net471" /> + <package id="TLSharp" version="0.1.0.574" targetFramework="net471" /> +</packages>
\ No newline at end of file |