Передать файл через веб-сервер

Для тестирования одной фигни, понадобилось написать минимальный веб-сервер на нативных сокетах. Смысл в том, чтобы в сервере не было ничего лишнего.
Взял код с ютуба на C++ и переписал на C#:

        private void btnStartServer_Click(object sender, EventArgs e)
        {
            int port = 7000;
            server = new Server();
            server.MessageReceived += (s, c, msg) =>
            {
                string page = "<!DOCTYPE html>" +
                    "<html lang = \"en\">" +
                    "<head>" +
                    "<meta charset = \"UTF-8\">" +
                    "<title>Document</title>" +
                    "</head>" +
                    "<body>" +
                    "test text" +
                    "<body>" +
                    "</html>";
                System.Diagnostics.Debug.WriteLine("connected");
                server.SendMessage(c, $"HTTP/1.1 200 OK\r\n\r\n{page}");
                c.Shutdown(System.Net.Sockets.SocketShutdown.Both);
            };
            server.Start(port);
            btnStartServer.Enabled = false;
        }

Работает. При переходе на localhost:7000 видно страницу. А как вместо текстового тела браузеру в ответ отправить бинарные данные, например файл? Ну или не браузеру. Например, чтобы обратиться к localhost:7000/file.mpg и появилось бы предложение скачать файл.
Ну то есть, понятно, что серверу придёт запрос типа GET /file.mpg HTTP/1.1. Сервер должен его разобрать и открыть файл (желательно в многопотоке). Но как, собственно, отправить клиенту бинарные данные, а не текстовые?

Server это что именно?

Server это то что я попытался написать, основываясь на примере кода на C++.

using System.Collections.Generic;
using System.Net.Sockets;
using System.Text;

namespace HTTP_server
{
    internal class Server : TcpListener
    {
        private List<Socket> clients = new List<Socket>();

        public delegate void ClientConnectedDelegate(object sender, Socket client);
        public delegate void ClientDisconnectedDelegate(object sender, Socket client);
        public delegate void MessageReceivedDelegate(object sender, Socket client, string message);
        public ClientConnectedDelegate ClientConnected;
        public ClientDisconnectedDelegate ClientDisconnected;
        public MessageReceivedDelegate MessageReceived;
        
        public override void OnClientConnected(Socket client)
        {
            System.Diagnostics.Debug.WriteLine($"{client.RemoteEndPoint} is connected");
            clients.Add(client);
            ClientConnected?.Invoke(this, client);
        }

        public override void OnClientDisconnected(Socket client)
        {
            System.Diagnostics.Debug.WriteLine($"{client.RemoteEndPoint} is disconnected");
            clients.Remove(client);
            ClientDisconnected?.Invoke(this, client);
            client.Shutdown(SocketShutdown.Both);
            client.Close();
        }

        public override void OnMessageReceived(Socket client, string msg)
        {
            System.Diagnostics.Debug.WriteLine($"{client.RemoteEndPoint}> {msg}");
            MessageReceived?.Invoke(this, client, msg);
        }

        public void SendMessage(Socket socket, string msg)
        {
            socket.Send(Encoding.UTF8.GetBytes(msg));
        }
    }
}
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;

namespace HTTP_server
{
    internal class TcpListener : IDisposable
    {
        private Socket _listeningSocket;
        public bool Active { get; private set; }

        public void Start(int port, int maxClients = 0)
        {
            if (Active)
            {
                throw new Exception("The server is already started or the port is busy");
            }
            _listeningSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, port);
            _listeningSocket.Bind(endPoint);
            _listeningSocket.Listen(maxClients);
            Active = true;
            Task.Run(() =>
            {
                while (Active)
                {
                    try
                    {
                        Socket clientSocket = _listeningSocket.Accept();
                        OnClientConnected(clientSocket);

                        Task.Run(() =>
                        {
                            while (Active)
                            {
                                try
                                {
                                    byte[] buffer = new byte[1024];
                                    int bytesReceived = clientSocket.Receive(buffer, 0, buffer.Length, SocketFlags.None);
                                    if (!Active)
                                    {
                                        throw new Exception("The server is stopped");
                                    }
                                    if (bytesReceived == 0)
                                    {
                                        throw new Exception("Zero bytes received");
                                    }

                                    string msg = Encoding.UTF8.GetString(buffer, 0, bytesReceived);
                                    OnMessageReceived(clientSocket, msg);
                                }
                                catch (Exception ex)
                                {
                                    System.Diagnostics.Debug.WriteLine(ex.Message);
                                    OnClientDisconnected(clientSocket);
                                    break;
                                }
                            }
                        });
                    }
                    catch (Exception ex)
                    {
                        System.Diagnostics.Debug.WriteLine(ex.Message);
                    }
                }
                OnTerminate();
            });
        }

        public void Stop()
        {
            if (Active)
            {
                Active = false;
                if (_listeningSocket != null)
                {
                    try
                    {
                        _listeningSocket.Shutdown(SocketShutdown.Both);
                    }
                    catch (Exception ex)
                    {
                        System.Diagnostics.Debug.WriteLine(ex.Message);
                    }
                    _listeningSocket.Close();
                }
            }
        }

        public virtual void OnClientConnected(Socket client)
        {
            System.Diagnostics.Debug.WriteLine($"{client.RemoteEndPoint} is connected");
        }

        public virtual void OnClientDisconnected(Socket client)
        {
            System.Diagnostics.Debug.WriteLine($"{client.RemoteEndPoint} is disconnected");
            client.Shutdown(SocketShutdown.Both);
            client.Close();
        }

        public virtual void OnMessageReceived(Socket client, string msg)
        {
            System.Diagnostics.Debug.WriteLine($"{client.RemoteEndPoint}> {msg}");
        }

        private void OnTerminate()
        {
            Stop();
            _listeningSocket = null;
        }

        public void Dispose()
        {
            Stop();
        }
    }
}

Многопоточность писал сам. По-этому код корявый.

Тут же бинарные и есть.

Можно просто сделать массив из GetBytes + остальных байтов.

Это-то да. Но как браузер поймет, что нужно открыть диалог сохранения файла, а не открывать его содержимое во вкладке?
И если файл большой, не одним же ответом его передавать. Надо же будет как-то писать циклы передачи и принятия.

По заголовкам. MIME types (IANA media types) - HTTP | MDN
Там еще и размер есть.

Ну так пишите циклы вместо массива. И чтение до размера.

Не совсем понял.
То есть, если сервер отправит заголовок Content-Type: video/mp4, то браузер должен открыть диалог сохранения?

Да-да, это я знаю/помню.

Да, ну или что он хочет делать с таким типом в зависимости от разработчиков браузера и настроек.

Попробовал в ответе отправить заголовок Content-Type: video/mp4. Вместо открытия диалога, браузер пытается воспроизводить несуществующий файл, но пишет, что это невозможно. Но это фигня. Я не планирую подключаться к этому серверу из браузера.
Я сейчас не могу понять, как отправить файл клиенту.
В своём менеджере закачки, я писал вот такой код:

        public int GetResponseStream(string url, long rangeFrom, long rangeTo, out Stream stream)
        {
            stream = null;
            if (!FileDownloader.IsRangeValid(rangeFrom, rangeTo))
            {
                return FileDownloader.DOWNLOAD_ERROR_RANGE;
            }
            try
            {
                HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);

                if (Headers != null)
                {
                    SetRequestHeaders(request, Headers);
                }

                AddRange(request, rangeFrom, rangeTo);

                webResponse = (HttpWebResponse)request.GetResponse();
                int statusCode = (int)webResponse.StatusCode;
                if (statusCode == 200 || statusCode == 206)
                {
                    stream = webResponse.GetResponseStream();
                }
                return statusCode;
            }
            catch (WebException ex)
            {
                System.Diagnostics.Debug.WriteLine(ex.Message);
                LastErrorMessage = ex.Message;
                if (webResponse != null)
                {
                    webResponse.Dispose();
                    webResponse = null;
                }
                if (ex.Status == WebExceptionStatus.ProtocolError)
                {
                    HttpWebResponse httpWebResponse = (HttpWebResponse)ex.Response;
                    int statusCode = (int)httpWebResponse.StatusCode;
                    return statusCode;
                }
                else
                {
                    return ex.HResult;
                }
            }
            catch (NotSupportedException ex)
            {
                System.Diagnostics.Debug.WriteLine(ex.Message);
                LastErrorMessage = ex.Message;
                if (webResponse != null)
                {
                    webResponse.Dispose();
                    webResponse = null;
                }
                return FileDownloader.DOWNLOAD_ERROR_INVALID_URL;
            }
            catch (UriFormatException ex)
            {
                System.Diagnostics.Debug.WriteLine(ex.Message);
                LastErrorMessage = ex.Message;
                if (webResponse != null)
                {
                    webResponse.Dispose();
                    webResponse = null;
                }
                return FileDownloader.DOWNLOAD_ERROR_INVALID_URL;
            }
            catch (Exception ex)
            {
                System.Diagnostics.Debug.WriteLine(ex.Message);
                LastErrorMessage = ex.Message;
                if (webResponse != null)
                {
                    webResponse.Dispose();
                    webResponse = null;
                }
                return ex.HResult;
            }
        }
        private int ContentToStream(WebContent content, Stream stream)
        {
            if (content == null || content.ContentData == null)
            {
                return DOWNLOAD_ERROR_NULL_CONTENT;
            }

            try
            {
                byte[] buf = new byte[4096];
                int iter = 0;
                do
                {
                    int bytesRead = content.ContentData.Read(buf, 0, buf.Length);
                    if (bytesRead <= 0)
                    {
                        break;
                    }
                    stream.Write(buf, 0, bytesRead);
                    _bytesTransfered += bytesRead;
                    StreamSize = stream.Length;
                    if (WorkProgress != null && (ProgressUpdateInterval == 0 || iter++ >= ProgressUpdateInterval))
                    {
                        WorkProgress.Invoke(this, _bytesTransfered, content.Length);
                        iter = 0;
                    }
                    if (CancelTest != null)
                    {
                        bool stop = false;
                        CancelTest.Invoke(this, ref stop);
                        Stopped = stop;
                        if (Stopped)
                        {
                            break;
                        }
                    }
                }
                while (true);
            }
            catch (Exception ex)
            {
                System.Diagnostics.Debug.WriteLine(ex.Message);
                LastErrorMessage = ex.Message;
                return ex.HResult;
            }
            
            if (Stopped)
            {
                return DOWNLOAD_ERROR_CANCELED_BY_USER;
            }
            else if (content.Length >= 0L && _bytesTransfered != content.Length)
            {
                return DOWNLOAD_ERROR_INCOMPLETE_DATA_READ;
            }

            return 200;
        }

Надо будет попробовать подключиться этим кодом к серверу и в дебаггере посмотреть, какие запросы приходят. Может тогда станет понятнее.
Самое главное - вот эта часть:

                do
                {
                    int bytesRead = content.ContentData.Read(buf, 0, buf.Length);
                    if (bytesRead <= 0)
                    {
                        break;
                    }
                    stream.Write(buf, 0, bytesRead);
                    _bytesTransfered += bytesRead;
...........

Я не пойму, что должно происходить на сервере в этот момент :thinking:
Предположу, что при выполнении этих строчек клиентом

                HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);

                if (Headers != null)
                {
                    SetRequestHeaders(request, Headers);
                }

                AddRange(request, rangeFrom, rangeTo);

                webResponse = (HttpWebResponse)request.GetResponse();
                int statusCode = (int)webResponse.StatusCode;
                if (statusCode == 200 || statusCode == 206)
                {
                    stream = webResponse.GetResponseStream();
                }

сервер должен открыть файл на чтение (кажется режим называется что-то типа shared read) и держать его открытым (в отдельном потоке, разумеется), пока клиент выполняет цикл скачивания

                do
                {
                    int bytesRead = content.ContentData.Read(buf, 0, buf.Length);
                    if (bytesRead <= 0)
                    {
                        break;
                    }
                    stream.Write(buf, 0, bytesRead);
                    _bytesTransfered += bytesRead;
...........

При этом, сервер автоматически будет отдавать клиенту нужные байты файла. А когда клиент отвалился - закрываем файл на сервере.
Правильно? :thinking: