65.9K
CodeProject 正在变化。 阅读更多。
Home

ftp2? msgfiles 演进的文件传输

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2022年9月12日

Apache

8分钟阅读

viewsIcon

6679

downloadIcon

210

一个用于通过网络发送文件的 .NET 客户端-服务器应用程序

引言

早在 2000 年初,当云朵只是天空中的那些蓬松的白色物体时,我在一家初创公司工作,这家公司的目标是改变文件的存储方式。我们称之为在线文件存储。您可以上传文件,然后可以在任何地方访问它们……并将它们发送给您的朋友。这不像 Napster 那样是文件共享;您不会与全世界共享您的 CD 收藏。但是,您可以轻松地将您的 CD 收藏发送给您的姐妹。

从那时起,技术已经发展和改进,您可以争辩说,文件共享或发送没有正当理由,因为网上有一个蓬勃发展的市场,用于购买和租赁对文件的访问权。每月支付费用即可访问文件城堡,然后将您想要的任何内容发送给支付相同城堡月费的其他人,问题就解决了。一切合法,一切合规。像 Napster 这样的东西永远不会被允许再次存在。

那么 msgfiles 到底是怎么回事呢?msgfiles 关注的是本地化和简单性。在您想与之传输文件的人都能访问的地方运行服务器软件。然后让他们运行客户端,您就可以轻松地相互发送文件。将其视为改进的消息式 FTP 系统,重点是该首字母缩略词的“传输”部分。它恰好介于电子邮件和 FTP 之间。电子邮件不适合处理大型文件或大量文件。FTP 不是消息式的。我很想听听您的看法。

ftp2 是一个煽动性的、宏伟的文章标题,但深入挖掘,您可能会看到为什么这并非不切实际的提议。好吧,也许有点疯狂……

顺便说一下……

我很久没写 C# 了。

有过几个 JavaScript 网页游戏:tapglasses.iotiletaps.com

我对 mscript 进行了一些迭代。

我将 C# 的一个 NoSQL 数据库移植到了 C++,4db

很少有 .NET。

我回来的第一感觉是 C# 已经通过 .NET 库和 LINQ 具有很高的生产力。我认为新的 AI 功能太过头了,无法使用……它只会碍事。我写代码是因为我想写代码,而不是一直点击代码建议。自动完成是理所当然的,就像切好的面包一样,但 AI 必须消失,我无法足够快地禁用它。

从积极的方面来看,空值可空性一开始很烦人,但它做得很好,并且有其目的。它给我的感觉有点像 Rust,编译器站在你这边帮助你编写正确的程序。我喜欢这一点。

所以有好有坏。

我仍然认为 C# 不适合大型项目。世界正在远离这些项目,所以也许这没关系。

msgfiles 客户端应用程序

您可以在 msgfiles.io 上安装客户端并查看截图指南。我在这里不再赘述, suffice to say...

您启动客户端……

  1. 输入一个显示名称和您的电子邮件地址。
  2. 输入服务器地址,就像 FTP 服务器一样。
  3. 服务器会将登录代码发送到您的电子邮件地址。
  4. 您输入该代码,然后就可以发送和接收消息了。

发送文件……

  1. 按“发送文件”按钮。
  2. 选择要发送文件的人。
  3. 输入您的小消息。
  4. 选择要发送的文件。
  5. 然后客户端会将文件 ZIP 压缩……
  6. ……并将 ZIP 文件和消息发送到服务器。
  7. 服务器存储 ZIP 文件和消息……
  8. ……并将带有访问令牌的电子邮件发送给您的收件人。

当您收到一封说明您有文件的电子邮件时……

  1. 启动客户端。
  2. 连接到服务器。
  3. 按“接收文件”按钮。
  4. 从电子邮件中复制访问令牌并将其粘贴到客户端中。
  5. 客户端会显示消息来自谁以及小消息。
  6. 您对此进行审阅,然后决定放弃或继续。
  7. 客户端会下载 ZIP 文件并向您显示内容的清单。
  8. 您对此进行审阅,然后决定放弃或继续。
  9. 然后您选择将文件放在哪里,ZIP 文件将被解压到该位置。
  10. 任务完成!

这就是整个应用程序。两个大按钮和一些简单的对话框。非常容易!

它可以更漂亮。有人要很快去毛伊岛吗?

msgfiles 服务器应用程序

您可以在 msgfiles.io 上安装服务器并获取安装步骤和维护技巧。

代码

msgfiles 是开源的,在 GitHub 上,采用 Apache 2.0 许可证。它全部是 .NET 6 C#,在一个解决方案中包含单元测试。

这是解决方案中项目的概述。

有两个应用程序项目,clientserver。这些项目中的代码很少,只有顶层的编排。

securenet

这个低级库封装了第三方依赖项,包括 ZIP、AES 和 JSON。它还包括核心 TLS 代码,包括自签名证书生成和 SslStream 包装函数。许多核心构建块,如 SMTP 包装类 EmailClient 和会话管理类 SessionStore 也在这里。

msglib

这个库实现了客户端 MsgClient 和服务器端 MsgRequestHandler.cs 类中的消息处理。核心 MessageStore 类也在这里。

client(客户端)

一个基本的概念验证 WinForms 应用程序,它响应 MsgClient 事件来显示进度并提示用户输入令牌和确认。我设想更漂亮的应用程序将取代这个程序;希望它们会像这个卑微的开始一样简单易用。

服务器

用于在服务器端运行程序的命令行应用程序。服务器依赖于一个设置 INI 文件以及允许和阻止列表文件。命令行提示提供了对这些文件的方便访问,服务器会拾取一些更改并立即生效。

理论上,您可以只使用 securenet 项目来开发自己的客户端-服务器应用程序。这有点像 http2,它可以带您走很远。

这就是 CodeProject……

说了这么多民间传说和项目,让我们看看代码吧!

安全网络

msgfiles 的核心是通过 SslStream 进行基本的自签名安全网络。

/// Create a self-signed cert...in six lines of code
public static X509Certificate GenCert()
{
    using (RSA rsa = RSA.Create(4096))
    {
        var distinguishedName = new X500DistinguishedName($"CN=msgfiles.io");
        var request = new CertificateRequest(distinguishedName, rsa, 
                      HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
        var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), 
                          DateTimeOffset.UtcNow.AddDays(3650));
        return new X509Certificate2
               (certificate.Export(X509ContentType.Pfx, "password"), 
               "password", X509KeyStorageFlags.MachineKeySet);
    }
}

/// Given a client TCP connection, secure communications with the server
public static Stream SecureConnectionToServer(TcpClient client)
{
    var client_stream = client.GetStream();
    var ssl_stream = 
        new SslStream
        (
            client_stream,
            false,
            (object obj, X509Certificate? cert, X509Chain? chain, 
                         SslPolicyErrors errors) => true
        );
    ssl_stream.AuthenticateAsClient("msgfiles.io");
    if (!ssl_stream.IsAuthenticated)
        throw new NetworkException("Connection to server not authenticated");
    return ssl_stream;
}

/// Given a server TCP connection, secure communications with the client
public async static Task<Stream> SecureConnectionFromClient
             (TcpClient client, X509Certificate cert)
{
    var client_stream = client.GetStream();
    var ssl_stream = new SslStream(client_stream, false, 
    (object obj, X509Certificate? cert2, X509Chain? chain, 
                 SslPolicyErrors errors) => true);
    await ssl_stream.AuthenticateAsServerAsync(cert).ConfigureAwait(false);
    if (!ssl_stream.IsAuthenticated)
        throw new NetworkException("Connection from client not authenticated");
    return ssl_stream;
}

网络载荷序列化

一旦有了安全网络,您就需要一个机制来在网络上传输载荷。我选择让载荷类似于 HTTP,并压缩头部,以便将诸如消息文本和收件人等重要内容放在其中。如果您的短消息和收件人列表加起来(压缩后)超过 64 KB……您可能更喜欢电子邮件!

public static int MaxObjectByteCount = 64 * 1024;

/// Given pretty much anything, 
/// turn it into JSON,
/// get the UTF-8 bytes,
/// compress the bytes,
/// make sure it isn't too big,
/// then send it over the stream, length-prefixed
public static void SendObject<T>(Stream stream, T headers)
{
    string json = JsonConvert.SerializeObject(headers);

    byte[] json_bytes = Utils.Compress(Encoding.UTF8.GetBytes(json));
    if (json_bytes.Length > MaxObjectByteCount)
        throw new InputException("Too much to send");

    byte[] num_bytes = BitConverter.GetBytes
           (IPAddress.HostToNetworkOrder(json_bytes.Length));

    using (var buffer = Utils.CombineArrays(num_bytes, json_bytes))
        stream.Write(buffer.GetBuffer(), 0, (int)buffer.Length);
}
public static async Task SendObjectAsync<T>(Stream stream, T headers)
...

/// Receive pretty much anything from a stream
/// Read the length, ensure it's not too much
/// Read the bytes, decompress, turn into a string, JSON parse,
/// and out comes an object
public static T ReadObject<T>(Stream stream)
{
    byte[] num_bytes = new byte[4];
    if (stream.Read(num_bytes, 0, num_bytes.Length) != num_bytes.Length)
        throw new SocketException();

    int bytes_length = 
        IPAddress.NetworkToHostOrder(BitConverter.ToInt32(num_bytes, 0));
    if (bytes_length > MaxObjectByteCount)
        throw new InputException("Too much to read");

    byte[] header_bytes = new byte[bytes_length];
    int read_yet = 0;
    while (read_yet < bytes_length)
    {
        int to_read = bytes_length - read_yet;
        int new_read = stream.Read(header_bytes, read_yet, to_read);
        if (new_read <= 0)
            throw new NetworkException("Connection closed");
        else
            read_yet += new_read;
    }

    string json = Encoding.UTF8.GetString
                  (Utils.Decompress(header_bytes, bytes_length));
    var obj = JsonConvert.DeserializeObject<T>(json);
    if (obj == null)
        throw new InputException("Input did not parse");
    else
        return obj;
}
public static async Task<T> ReadObjectAsync<T>(Stream stream)
...

访问控制

运行任何类型的服务器的一个重要主题是访问控制。我不认为将此服务器放在互联网上;这是一个内部网的用例。也许您在一个部门设置了一个服务器,而不希望其他部门对其进行干预。

因此,服务器有两个用于访问控制的文件,allow.txtblock.txt。您可以放入完整的电子邮件地址或带有 @ 前缀的域名。如果您的电子邮件地址不在允许列表中,或者您被阻止,您就无法连接,也无法发送任何内容。服务器已被锁定。

这是强制执行访问控制的代码

/// Manage allow and block lists of email addresses
/// to validate that a given email address is allowed access
public class AllowBlock
{
    /// Swap in new lists
    public void SetLists(HashSet<string> allow, HashSet<string> block)
    {
        try
        {
            m_rwLock.EnterWriteLock();

            m_allowList = allow;
            m_blockList = block;
        }
        finally
        {
            m_rwLock.ExitWriteLock();
        }
    }

    /// Ensure that an email address or its domain is allowed,
    /// or at least not blocked
    public void EnsureEmailAllowed(string email)
    {
        try
        {
            m_rwLock.EnterReadLock();

            // Normalize the email address
            email = Utils.GetValidEmail(email).ToLower();
            if (email.Length == 0)
                throw new InputException($"Invalid email: {email}");

            // Include the leading @, list files use this to allow/block entire domains
            string domain = email.Substring(email.IndexOf('@')).ToLower();

            // Look for specific email address blocks first, that trumps all
            if (m_blockList.Contains(email))
                throw new InputException($"Blocked email: {email}");

            // Check for specific email address being allowed, this trumps domains
            if (m_allowList.Contains(email))
                return;

            // Check for a whole blocked domain
            if (m_blockList.Contains(domain))
                throw new InputException($"Blocked domain: {email}");

            // Allow a whole domain
            if (m_allowList.Contains(domain))
                return;

            // Failing all of that, if there is an allow list,
            // the email is not on any of them, so they're blocked by default
            if (m_allowList.Count > 0)
                throw new InputException($"Not allowed: {email}");

            // no allow list, not blocked -> allowed
        }
        finally
        {
            m_rwLock.ExitReadLock();
        }
    }

    private HashSet<string> m_allowList = new HashSet<string>();
    private HashSet<string> m_blockList = new HashSet<string>();

    private ReaderWriterLockSlim m_rwLock = new ReaderWriterLockSlim();
}

发送电子邮件

msgfiles 的另一个主题是发送电子邮件。System.Net.SmtpClient 很有意思。它不是线程安全的。它有一个非 async/await 的 SendAsync 函数。文档建议底层有网络连接池,所以我们为每条消息创建一个 SmtpClient,并使用“发送并祈祷”的 SendAsync 函数。

/// SmtpClient wrapper class
public class EmailClient
{
    public EmailClient(string server, int port, string username, string password)
    {
        m_server = server;
        m_port = port;
        m_credential = new NetworkCredential(username, password);
    }

    public void SendEmail
    (
        string from, // display <email> or just email
        Dictionary<string, string> toAddrs, // email -> display
        string subject,
        string body
    )
    {
        var fromKvp = Utils.ParseEmail(from);

        var mail_message = new MailMessage();
        mail_message.From = new MailAddress(fromKvp.Key, fromKvp.Value);

        foreach (var toKvp in toAddrs)
            mail_message.To.Add(new MailAddress(toKvp.Key, toKvp.Value));

        mail_message.Subject = subject;
        mail_message.Body = body;

        SmtpClient client = new SmtpClient(m_server, m_port);
        client.Credentials = m_credential;
        client.DeliveryMethod = SmtpDeliveryMethod.Network;
        client.EnableSsl = true;
        client.SendAsync(mail_message, null);
    }

    private string m_server;
    private int m_port;
    private NetworkCredential m_credential;
}

ZIP 文件处理

ZIP 文件是此应用程序的核心。我创建了一些围绕 DotNetZip NuGet 包的包装函数。

/// Create a ZIP file from files and folders to include
public static void CreateZip(IClientApp app, string zipFilePath, IEnumerable<string> paths)
{
    using (var zip = new Ionic.Zip.ZipFile(zipFilePath))
    {
        zip.CompressionLevel = Ionic.Zlib.CompressionLevel.BestSpeed;
        string lastZipCurrentFilename = "";
        zip.SaveProgress +=
            (object? sender, Ionic.Zip.SaveProgressEventArgs e) =>
            {
                if (e.CurrentEntry != null && 
                    e.CurrentEntry.FileName != lastZipCurrentFilename)
                {
                    lastZipCurrentFilename = e.CurrentEntry.FileName;
                    app.Log(lastZipCurrentFilename);
                }

                if (e.TotalBytesToTransfer > 0)
                    app.Progress((double)e.BytesTransferred / e.TotalBytesToTransfer);
            };
        foreach (var path in paths)
        {
            if (File.Exists(path))
                zip.AddFile(path, "");
            else if (Directory.Exists(path))
                zip.AddDirectory(path, Path.GetFileName(path));
            else
                throw new InputException($"Item to send not found: {path}");
        }

        zip.Save();
    }
}

/// Summarize the contents of a ZIP file for the benefit of having an idea
/// whether they are what is expected, and safe
public static string ManifestZip(string zipFilePath)
{
    int file_count = 0;
    long total_byte_count = 0;

    StringBuilder entry_lines = new StringBuilder();
            
    Dictionary<string, int> ext_counts = new Dictionary<string, int>();
            
    using (var zip_file = new Ionic.Zip.ZipFile(zipFilePath))
    {
        foreach (var zip_entry in zip_file.Entries)
        {
            if (zip_entry.IsDirectory)
                continue;

            string size_str = 
                Utils.ByteCountToStr(zip_entry.UncompressedSize);
            entry_lines.AppendLine($"{zip_entry.FileName} ({size_str})");

            string ext = Path.GetExtension(zip_entry.FileName).ToUpper();
            if (ext_counts.ContainsKey(ext))
                ++ext_counts[ext];
            else
                ext_counts[ext] = 1;

            ++file_count;
            total_byte_count += zip_entry.UncompressedSize;
        }
    }

    string ext_summary =
        "File Types:\r\n" +
        string.Join
        (
            "\r\n",
            ext_counts
                .Select(kvp => $"{kvp.Key.Trim('.')}: {kvp.Value}")
                .OrderBy(str => str)
        );

    return
        $"Files: {file_count}" +
        $" - " +
        $"Total: {Utils.ByteCountToStr(total_byte_count)}" +
        $"\r\n\r\n" +
        $"{ext_summary}" +
        $"\r\n\r\n" +
        $"{entry_lines}";
}

/// Extract a ZIP file's contents into an output directory
public static void ExtractZip
       (IClientApp app, string zipFilePath, string extractionDirPath)
{
    using (var zip = new Ionic.Zip.ZipFile(zipFilePath))
    {
        string lastZipCurrentFilename = "";
        zip.ExtractProgress +=
            (object? sender, Ionic.Zip.ExtractProgressEventArgs e) =>
            {
                if (e.CurrentEntry != null && 
                    e.CurrentEntry.FileName != lastZipCurrentFilename)
                {
                    lastZipCurrentFilename = e.CurrentEntry.FileName;
                    app.Log(lastZipCurrentFilename);
                }

                if (e.TotalBytesToTransfer > 0)
                    app.Progress((double)e.BytesTransferred / e.TotalBytesToTransfer);
            };
        zip.ExtractAll(extractionDirPath);
    }
}

客户端消息发送

再往上层是发送消息的客户端代码。

/// Send a message with files to recipients
public bool SendMsg
(
    IEnumerable<string> to, 
    string message, 
    IEnumerable<string> paths
)
{
    using (var temp_file_use = new TempFileUse(".zip"))
    {
        string zip_file_path = temp_file_use.FilePath;

        App.Log("Adding files to package...");
        Utils.CreateZip(App, zip_file_path, paths);

        App.Log("Scanning package...");
        string zip_hash;
        using (var fs = File.OpenRead(zip_file_path))
            zip_hash = Utils.HashStream(fs);

        App.Log("Sending message...");
        long zip_file_size_bytes = new FileInfo(zip_file_path).Length;
        var send_request =
            new ClientRequest()
            {
                version = 1,
                verb = "POST",
                contentLength = zip_file_size_bytes,
                headers = new Dictionary<string, string>()
                {
                    { "to", string.Join("; ", to) },
                    { "message", message },
                    { "hash", zip_hash }
                }
            };
        if (ServerStream == null)
            return false;
        SecureNet.SendObject(ServerStream, send_request);

        App.Log("Sending package...");
        using (var zip_file_stream = File.OpenRead(zip_file_path))
        {
            long sent_yet = 0;
            byte[] buffer = new byte[64 * 1024];
            while (sent_yet < zip_file_size_bytes)
            {
                int to_read = 
                (int)Math.Min(zip_file_size_bytes - sent_yet, buffer.Length);
                int read = zip_file_stream.Read(buffer, 0, to_read);
                if (App.Cancelled)
                    return false;

                if (ServerStream == null)
                    return false;
                ServerStream.Write(buffer, 0, read);

                sent_yet += read;

                App.Progress((double)sent_yet / zip_file_size_bytes);
                if (App.Cancelled)
                    return false;
            }
        }
        if (App.Cancelled)
            return false;

        App.Log("Receiving response...");
        using (var send_response = SecureNet.ReadObject<ServerResponse>(ServerStream))
        {
            App.Log($"Server Response: {send_response.ResponseSummary}");
            if (send_response.statusCode / 100 != 2)
                throw send_response.CreateException();
        }

        return true;
    }
}

客户端消息接收

这是客户端接收消息的代码。

/// Given a message token, get a message for the current user
/// Returns true if getting the message succeeded
/// Sets shouldDelete to true if the user canceled the operation
public bool GetMessage(string msgToken, out bool shouldDelete)
{
    shouldDelete = false;

    {
        App.Log("Sending GET msg request...");
        var request =
            new ClientRequest()
            {
                version = 1,
                verb = "GET",
                headers =
                    new Dictionary<string, string>()
                    { { "token", msgToken }, { "part", "msg"} }
            };
        if (ServerStream == null)
            return false;
        SecureNet.SendObject(ServerStream, request);
        if (App.Cancelled)
            return false;

        App.Log("Receiving GET msg response...");
        if (ServerStream == null)
            return false;
        using (var response = SecureNet.ReadObject<ServerResponse>(ServerStream))
        {
            App.Log($"Server Response: {response.ResponseSummary}");
            if (response.statusCode / 100 != 2)
                throw response.CreateException();

            msg? m = JsonConvert.DeserializeObject<msg>(response.headers["msg"]);
            string status = m == null ? "(null)" : m.from;
            App.Log($"Message: {status}");
            if (m == null)
                return false;
            else
                msgToken = m.token;

            if (!App.ConfirmDownload(m.from, m.message, out shouldDelete))
                return false;
        }
    }

    {
        App.Log("Sending GET file request...");
        var request =
            new ClientRequest()
            {
                version = 1,
                verb = "GET",
                headers =
                    new Dictionary<string, string>()
                    { { "token", msgToken }, { "part", "file"} }
            };
        if (ServerStream == null)
            return false;
        SecureNet.SendObject(ServerStream, request);
        if (App.Cancelled)
            return false;

        App.Log("Receiving GET file response...");
        if (ServerStream == null)
            return false;
        using (var response = SecureNet.ReadObject<ServerResponse>(ServerStream))
        {
            App.Log($"Server Response: {response.ResponseSummary}");
            if (response.statusCode / 100 != 2)
                throw response.CreateException();

            using (var temp_file_use = new TempFileUse(".zip"))
            {
                string temp_file_path = temp_file_use.FilePath;

                App.Log($"Downloading files...");
                if (App.Cancelled)
                    return false;
                using (var fs = File.OpenWrite(temp_file_path))
                {
                    long total_to_read = response.contentLength;
                    long read_yet = 0;
                    byte[] buffer = new byte[64 * 1024];
                    while (read_yet < total_to_read)
                    {
                        int to_read = (int)Math.Min(total_to_read - read_yet, 
                                       buffer.Length);
                        if (ServerStream == null)
                            return false;
                        int read = ServerStream.Read(buffer, 0, to_read);
                        if (App.Cancelled)
                            return false;

                        if (read == 0)
                            throw new NetworkException("Connection lost");
                        fs.Write(buffer, 0, read);
                        if (App.Cancelled)
                            return false;

                        read_yet += read;

                        App.Progress((double)read_yet / total_to_read);
                    }
                }

                App.Log($"Scanning downloaded files...");
                if (App.Cancelled)
                    return false;
                string local_hash;
                using (var fs = File.OpenRead(temp_file_path))
                    local_hash = Utils.HashStream(fs);
                if (App.Cancelled)
                    return false;
                if (local_hash != response.headers["hash"])
                    throw new NetworkException("File transmission error");

                App.Log($"Examining downloaded files...");
                string manifest = Utils.ManifestZip(temp_file_path);
                if (App.Cancelled)
                    return false;

                string extraction_dir_path = "";
                if (!App.ConfirmExtraction(manifest, out shouldDelete, 
                                           out extraction_dir_path))
                    return false;

                App.Log($"Saving downloaded files...");
                Utils.ExtractZip(App, temp_file_path, extraction_dir_path);

                App.Log($"All done.");
                return true;
            }
        }
    }
}

服务器消息发送

在服务器端,这是处理用户发送消息的代码。

private async Task<ServerResponse> HandleSendRequestAsync
        (ClientRequest request, HandlerContext ctxt)
{
    // Unpack the message
    Utils.NormalizeDict
    (
        request.headers,
        new[]
        { "to", "message", "packageHash" }
    );

    string to = request.headers["to"];
    if (to == "")
        throw new InputException("Header missing: to");

    string message = request.headers["message"];
    if (message == "")
        throw new InputException("Header missing: message");

    long package_size_bytes = request.contentLength;
    if
    (
        MaxSendPayloadMB > 0
        &&
        package_size_bytes / 1024 / 1024 > MaxSendPayloadMB
    )
    {
        throw new InputException("Header invalid: package too big");
    }

    string sent_zip_hash = request.headers["hash"];
    if (sent_zip_hash == "")
        throw new InputException("Header missing: hash");

    Log(ctxt, $"Sending: To: {to}");

    using (var temp_file_use = new TempFileUse(".zip"))
    {
        string stored_file_path = "";
        string temp_zip_file_path = temp_file_use.FilePath;
        try
        {
            Log(ctxt, $"Saving ZIP: {temp_zip_file_path}");
            using (var zip_file_stream = File.OpenWrite(temp_zip_file_path))
            {
                long written_yet = 0;
                byte[] buffer = new byte[64 * 1024];
                while (written_yet < package_size_bytes)
                {
                    int to_read = (int)Math.Min
                                  (package_size_bytes - written_yet, buffer.Length);
                    int read = await ctxt.ConnectionStream.ReadAsync
                               (buffer, 0, to_read).ConfigureAwait(false);
                    if (read == 0)
                        throw new NetworkException("Connection lost");
                    await zip_file_stream.WriteAsync
                          (buffer, 0, read).ConfigureAwait(false);
                    written_yet += read;
                }
            }

            Log(ctxt, $"Hashing ZIP");
            string local_zip_hash;
            using (var zip_file_stream = File.OpenRead(temp_zip_file_path))
                local_zip_hash = await Utils.HashStreamAsync
                                 (zip_file_stream).ConfigureAwait(false);
            if (local_zip_hash != sent_zip_hash)
                throw new InputException("Received file contents do not match 
                                          what was sent");

            Log(ctxt, $"Storing ZIP");
            stored_file_path = m_fileStore.StoreFile(temp_zip_file_path);
            File.Delete(temp_zip_file_path);
            temp_zip_file_path = "";
            temp_file_use.Clear();

            Log(ctxt, $"Storing messages");
            string email_from = $"{ctxt.Auth["display"]} <{ctxt.Auth["email"]}>";
            var toos = to.Split(';').Select(t => t.Trim()).Where(t => t.Length > 0);
            foreach (var too in toos)
            {
                string token = 
                    m_msgStore.StoreMessage
                    (
                        new msg()
                        {
                            from = email_from,
                            to = too,
                            message = message
                        },
                        stored_file_path,
                        local_zip_hash
                    );

                    Log(ctxt, $"Sending email");
                    ctxt.App.SendDeliveryMessage
                    (
                        email_from,
                        too,
                        message,
                        token
                    );
            }
            stored_file_path = "";

            return
                new ServerResponse()
                {
                    version = 1,
                    statusCode = 200,
                    statusMessage = "OK"
                };
        }
        finally
        {
            if (stored_file_path != "" && File.Exists(stored_file_path))
                File.Delete(stored_file_path);
        }
    }
}

服务器消息接收

这是服务器处理用户接收消息的代码。

private async Task<ServerResponse> HandleGetRequestAsync
              (ClientRequest request, HandlerContext ctxt)
{
    string to = ctxt.Auth["email"];
    m_allowBlock.EnsureEmailAllowed(to);

    Utils.NormalizeDict(request.headers, new[] { "token", "part" });

    string token = request.headers["token"];
    if (token.Length == 0)
        throw new InputException("Header missing: token");

    string part_to_get = request.headers["part"];
    if (part_to_get.Length == 0)
        throw new InputException("Header missing: part");

    bool get_msg = false, get_file = false;
    if (part_to_get == "msg")
        get_msg = true;
    else if (part_to_get == "file")
        get_file = true;
    else
        throw new InputException("Invalid header: part");

    Log(ctxt, $"Get Message: {to} - {token} - {part_to_get}");

    string package_file_path, package_file_hash;
    var msg = 
        m_msgStore.GetMessage
        (to, token, out package_file_path, out package_file_hash);

    if (get_msg)
    {
        if (msg == null)
        {
            var response_404 =
                new ServerResponse()
                {
                    version = 1,
                    statusCode = 404,
                    statusMessage = "Message Not Found"
                };
            return response_404;
        }

        var response =
            new ServerResponse()
            {
                version = 1,
                statusCode = 200,
                statusMessage = "OK",
                headers =
                    new Dictionary<string, string>()
                    { { "msg", JsonConvert.SerializeObject(msg) } },
            };
        await Task.FromResult(0);
        return response;
    }
    else if (get_file)
    {
        if (!File.Exists(package_file_path))
        {
            var response_404 =
                new ServerResponse()
                {
                    version = 1,
                    statusCode = 404,
                    statusMessage = "File Not Found"
                };
            return response_404;
        }

        var response =
            new ServerResponse()
            {
                version = 1,
                statusCode = 200,
                statusMessage = "OK",
                contentLength = new FileInfo(package_file_path).Length,
                headers =
                    new Dictionary<string, string>()
                    { { "hash", package_file_hash } },
                streamToSend = File.OpenRead(package_file_path)
            };
        await Task.FromResult(0);
        return response;
    }
    else
        throw new InputException("Invalid header: part");
}

服务器应用程序启动

仍然在服务器端,这是服务器的启动代码,您可以看到所有内容是如何协同工作的。

public ServerApp()
{
    string settings_file_path = Path.Combine(AppDocsDirPath, "settings.ini");
    if (!File.Exists(settings_file_path))
        throw new InputException
              ($"settings.ini file does not exist in {AppDocsDirPath}");
            
    m_settings = new Settings(settings_file_path);

    if (!int.TryParse
    (
        m_settings.Get("application", "MaxSendPayloadMB"),
        out MsgRequestHandler.MaxSendPayloadMB
    ))
    {
        throw new InputException("Invalid setting: MaxSendPayloadMB");
    }

    if (!int.TryParse
    (
        m_settings.Get("application", "ReceiveTimeoutSeconds"),
        out Server.ReceiveTimeoutSeconds
    ))
    {
        throw new InputException("Invalid setting: ReceiveTimeoutSeconds");
    }

    if (!int.TryParse
    (
        m_settings.Get("application", "ServerPort"),
        out ServerPort
    ))
    {
        throw new InputException("Invalid setting: ServerPort");
    }

    m_settingsWatcher = new FileSystemWatcher(AppDocsDirPath, "*.ini");
    m_settingsWatcher.Changed += SettingsWatcher_Changed;
    m_settingsWatcher.Created += SettingsWatcher_Changed;
    m_settingsWatcher.Deleted += SettingsWatcher_Changed;
    SettingsWatcher_Changed(new object(), 
            new FileSystemEventArgs(WatcherChangeTypes.All, "", null));

    m_txtFilesWatcher = new FileSystemWatcher(AppDocsDirPath, "*.txt");
    m_txtFilesWatcher.Changed += TextWatcher_Changed;
    m_txtFilesWatcher.Created += TextWatcher_Changed;
    m_txtFilesWatcher.Deleted += TextWatcher_Changed;
    TextWatcher_Changed(new object(), 
                new FileSystemEventArgs(WatcherChangeTypes.All, "", null));

    m_sessions = new SessionStore(Path.Combine(AppDocsDirPath, "sessions.db"));

    m_messageStore = new MessageStore(Path.Combine(AppDocsDirPath, "messages.db"));

    m_fileStore = new FileStore(m_settings.Get("application", "FileStoreDir"));

    m_logStore = new LogStore(Path.Combine(AppDocsDirPath, "logs"), "raw");
    m_accessStore = new LogStore(Path.Combine(AppDocsDirPath, "logs"), "access");

    string mail_server = m_settings.Get("application", "MailServer");
    if (string.IsNullOrWhiteSpace(mail_server))
        throw new InputException("Invalid setting: MailServer");

    int mail_port;
    if (!int.TryParse
    (
        m_settings.Get("application", "MailPort"),
        out mail_port
    ))
    {
        throw new InputException("Invalid setting: MailPort");
    }

    m_emailClient =
        new EmailClient
        (
            mail_server,
            mail_port,
            m_settings.Get("application", "MailUsername"),
            m_settings.Get("application", "MailPassword")
        );

    m_maintenanceTimer = new Timer(MaintenanceTimer, null, 0, 60 * 1000);

    var to_kvp = Utils.ParseEmail(m_settings.Get("application", "MailAdminAddress"));
    m_emailClient.SendEmail
    (
        m_settings.Get("application", "MailFromAddress"),
        new Dictionary<string, string>() { { to_kvp.Key, to_kvp.Value } },
        "Server Started Up",
        "So far so good..."
    );
}

允许阻止列表文件加载器

最后,这是用于加载允许和阻止列表文本文件的函数。LINQ 可能很慢,但它确实很漂亮,所以在速度不是首要考虑的情况下,尽管使用它吧!

private HashSet<string> LoadFileList(string fileName)
{
    string file_path = Path.Combine(AppDocsDirPath, fileName);
    if (File.Exists(file_path))
    {
        return
            new HashSet<string>
            (
                File.ReadAllLines(file_path)
                .Select(e => e.Trim().ToLower())
                .Where(e => e.Length > 0 && e[0] != '#')
            );
    }
    else
        return new HashSet<string>();
}

结论

好了,这是一次 .NET 客户端-服务器内部网应用程序的快速浏览,希望您喜欢。

我最近没怎么写 C# / .NET,所以请用新颖的方法来启发我以及我这些恐龙般的同僚。

如果您在阅读完这一切后,认为这是您想尝试的东西,请发送一两个您希望访问演示服务器的电子邮件地址,并附上一句简短的“我不是机器人”消息到 contact@msgfiles.io,我将为您设置。只发送一个地址也可以,您可以像电子邮件一样将文件发送给自己。您发送的地址应来自与您要访问的地址相同域的地址。

历史

  • 2022 年 9 月 11 日:初始版本
© . All rights reserved.