├── .gitignore ├── README.md ├── science ├── pipeline.drawio ├── pipeline.png └── screenshots-both.png ├── test ├── another-folder │ └── placeholder.txt ├── folder+with+special&symbols# │ └── placeholder.txt ├── hello#world.txt ├── hello%world.txt ├── hello&world.txt ├── hello+world.txt ├── hello=world.txt ├── привіт-світ.txt └── こんにちは.txt └── webapp ├── FileServer.csproj ├── assets ├── css │ └── style.css ├── html │ ├── BreadcrumbWidget.html │ ├── DirectoryWidget.html │ ├── ErrorView.html │ ├── FileWidget.html │ ├── FsView.html │ └── _FormatSpec.md └── img │ ├── favicon.svg │ ├── icon-back.svg │ ├── icon-dir.svg │ ├── icon-download.svg │ ├── icon-file.svg │ └── icon-file2.svg └── src ├── Handlers ├── DownloadHandler.cs └── FsHandler.cs ├── Presentations ├── HtmlPresentation.cs ├── IFileServerPresentation.cs └── TextPresentation.cs ├── Program.cs ├── Startup.cs └── Utils ├── EnvHelper.cs ├── FileSizeFormat.cs └── FileSizeFormatter.cs /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | bin/ 3 | Bin/ 4 | obj/ 5 | Obj/ 6 | .vscode/ 7 | *.log 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # File server 2 | 3 | __Special thanks [@lriy816](https://github.com/lriy816) for finding a bug with non-ASCII characters!__ 4 | 5 | **Yet another version of file server by me**. 6 | 7 | **Note: this is more a file explorer / browser, than a static file server. If you need a static server for static website assets, i recommend using built-in static serving capabilities of your framework**. 8 | 9 | Last revision: 15 December 2024. 10 | 11 | Old version with Vue 2 front-end is available on branch [v1](https://github.com/dmitriynaumov1024/fileserver-aspnetcore/tree/v1). 12 | 13 | This variant of file server works without any front-end framework and is customizable (there are html building blocks, icons and a stylesheet in webapp/assets/). 14 | 15 | Why this file server? Why once again? Because this one: 16 | 17 | - has front-end without any single line of javascript, looks neat on both mobile and desktop: 18 | 19 | ![screenshots-both.png](./science/screenshots-both.png) 20 | 21 | - has much more understandable request pipeline: 22 | 23 | ![pipeline.png](./science/pipeline.png) 24 | 25 | - is easily customizable, even though it's pretty solid, you can adapt it for your needs. 26 | 27 | ## Prerequisites 28 | 29 | - Any OS that supports .NET 30 | - .NET SDK v6.0 31 | 32 | ## Usage 33 | 34 | **Quick tryout** 35 | ``` 36 | cd webapp 37 | dotnet run -- 38 | ``` 39 | 40 | **Publish self-contained for Linux and run it** 41 | ``` 42 | dotnet publish --os linux --self-contained -p:PublishTrimmed=True -o ./dist 43 | ./dist/FileServer 44 | ``` 45 | 46 | **Manual minification of self-contained** 47 | ``` 48 | # this can save up to 5 megabytes 49 | rm ./dist/*Mvc*.dll 50 | rm ./dist/*Razor*.dll 51 | rm ./dist/*HttpSys*.dll 52 | rm ./dist/*IIS*.dll 53 | rm ./dist/*SignalR*.dll 54 | ``` 55 | 56 | ## Web outline 57 | 58 | - `/fs/{path}` - returs directory or file at given path relative to root-directory 59 | - `/assets/{path}` - returns asset file from webapp/assets/ 60 | - `/download/{path}` - returns file at given path relative to root-directory as downloadable attachment 61 | 62 | ## Copyright 63 | 64 | - **.NET**: respecting original copyright, [more info here](https://dotnet.microsoft.com/) 65 | - **File server**: MIT License © 2022 - 2024 Dmitriy Naumov naumov1024@gmail.com 66 | 67 | Last revision: 15 December 2024. 68 | -------------------------------------------------------------------------------- /science/pipeline.drawio: -------------------------------------------------------------------------------- 1 | 7Vpbc5s4FP41ntl9iAcJbOPH5tLtzjSdzCYz3falIxuBtQXECjk2+fV7ZAQGiaQkMc5llhejgy7onE/fdyQ8cs+S7R+CZKtLHtB4hJ1gO3LPRxgj5LnwoyxFaZm5uDREggW60t5wze6oNjraumYBzVsVJeexZFnbuORpSpeyZSNC8E27Wsjj9qgZiahluF6S2LZ+ZYFclVYfz/b2T5RFq2pkNJ2XTxJSVdYzyVck4JuGyb0YuWeCc1neJdszGivnVX4p232852n9YoKmsk+DH98X+UkSfvka3l2hxfnm5vtf4QmalN3cknitZ6zfVhaVC/KfVC7VXJyRe0ryrHRzyLYUej7NOEslFRe38BrK1whs9VRVi4DkK1VzV1jJJK4qScF/0jMecwGWlKcw3GlMFjS+4jmTjKdgXlLVOTy4pUIyCMtno8KCS8mTRoUPMYvUA8kz9bq6VPcTsjiuxhxht7zKV87UfJNtpIA8JndrQcdLnmRr3ZCn8iNJWKyA/IVLDk66JmkOP5c85bqGBjC41T3VnoX3ott7Y4ZqJMASojyhUhRQRTdwHQ0evXqm1bLYNLDoj32NuVUDib6uSfQCiOrO9xiBGw2Tx0DGQsw5kQQsArpnFOwWggRfp0GNgc2KSXqdkaV6ugFvt3HR09G6d9zh+BZgDxAFbEQBTe0ooMrfzRhMhooBtmJwI8BFIRdAO2phvLMI1B7XEXDxS0fAtSJwJWgO03mX/q9FrKjc+tL+n1gu5kKueMRT0AiuuH/nyn+olIX2DFlL3nY0zF4Ufys3jSf+pDJ82xmcusL5VjuyLBW69HhBoPFip4qVVJmB2vV/RQUDFynN0cYqK5rXsaSBlbQYkYQsiYiIyl/xuB1xQWOA8G27/67w6aZXSv8biuW3kTI3AJDztVhS3WiPgQ9CkKJRbZdV5A8M4xqUjBwDUmWPe4DVU3w65jxrzQv675rm0sIirDTZI9tpJiPaZCUsZl6TsCBQw3SSSJtmanA9glQOnL/4Xjt/sWlj2sEa7lCsMbUiCPuGkEVg+w1yvYSkAdzGDEKhtg9R/vv/oe0pCR2ZKfKOqQizQytCSw9eQAzeFO9jg/dn/XjfJnYr156P582r3W0522fLSS1Tx5QT/xCAHRJ4viptmWysByh9q5YK3O9XgyoUo8bSKBt5nveYZdSZAfXHfgmyB1yOe64Rd5jcyH1SbmSj1dgNeY7R0aFWxT3jDLoq5pZEf7q5/KwEGrQU3j0t5IqlSrFpnNOD6HNAQ7KO5buTaDQz4texa651+yj5VzXY6+U85DyR9Pqy1OFZBRl7czPD6i29Bj1NB6IV1zxM8I9AK8g+tmTpyUJ9IKCKV0Z4umOABRSmkbq7ZXQzDLvENJRvnVs882Qa4/HEYhfkdLCL9wDEn8cu9gbdCuBRPmbsQ37U7xkafOanjCRfEjreNcgEy+mYZNmPiKaQZi1f7GAATSE37IGgSZc8zYcC0PQQ8tRIf90ZfgWnis2N5DMz7BfaXSJzf2ZK0yvfXk6MUfExFG9msSGQBc2LXNLkEML2Lo+1jGMM386YOzVtuIz5bZwSPPHYrD/x/HJr35eg8CAEZW6VXfPze3+CAkLCTn2hdqqMjrPTrwhrWILq2OpLmZ0BEhUZYejVOWcCMjQuij/TkA+Tjb8P3kIGcWHXs5lrsk+6nnkkD8X9P5dKQOz//+Ve/Ac= -------------------------------------------------------------------------------- /science/pipeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitriynaumov1024/fileserver-aspnetcore/5b48b908e495cadc016ca1bd64e69140042aae4e/science/pipeline.png -------------------------------------------------------------------------------- /science/screenshots-both.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitriynaumov1024/fileserver-aspnetcore/5b48b908e495cadc016ca1bd64e69140042aae4e/science/screenshots-both.png -------------------------------------------------------------------------------- /test/another-folder/placeholder.txt: -------------------------------------------------------------------------------- 1 | placeholder.txt -------------------------------------------------------------------------------- /test/folder+with+special&symbols#/placeholder.txt: -------------------------------------------------------------------------------- 1 | placeholder.txt -------------------------------------------------------------------------------- /test/hello#world.txt: -------------------------------------------------------------------------------- 1 | hello#world.txt -------------------------------------------------------------------------------- /test/hello%world.txt: -------------------------------------------------------------------------------- 1 | hello%world.txt -------------------------------------------------------------------------------- /test/hello&world.txt: -------------------------------------------------------------------------------- 1 | hello&world.txt -------------------------------------------------------------------------------- /test/hello+world.txt: -------------------------------------------------------------------------------- 1 | hello+world.txt -------------------------------------------------------------------------------- /test/hello=world.txt: -------------------------------------------------------------------------------- 1 | hello=world.txt -------------------------------------------------------------------------------- /test/привіт-світ.txt: -------------------------------------------------------------------------------- 1 | привіт світ 2 | -------------------------------------------------------------------------------- /test/こんにちは.txt: -------------------------------------------------------------------------------- 1 | こんにちは.txt -------------------------------------------------------------------------------- /webapp/FileServer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0 4 | FileServer 5 | false 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /webapp/assets/css/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | display: flow-root; 9 | font-family: "Nimbus Sans", "Helvetica", system-ui, sans-serif; 10 | min-height: 100vh; 11 | } 12 | 13 | h1, h2, h3, h4, h5, h6 { 14 | margin: 0 auto; 15 | line-height: 1.5; 16 | } 17 | 18 | ul, ol { 19 | margin: 0; 20 | padding: 0; 21 | } 22 | 23 | li { 24 | list-style-type: none; 25 | } 26 | 27 | .width-container { 28 | width: 100%; 29 | margin: auto; 30 | } 31 | 32 | @media screen and (min-width: 500px) { 33 | .width-container { 34 | width: 480px; 35 | } 36 | } 37 | 38 | @media screen and (min-width: 740px) { 39 | .width-container { 40 | width: 640px; 41 | } 42 | } 43 | 44 | @media screen and (min-width: 1200px) { 45 | .width-container { 46 | width: 800px; 47 | } 48 | } 49 | 50 | @media screen and (min-width: 2000px) { 51 | .width-container { 52 | width: 1280px; 53 | } 54 | } 55 | 56 | a { 57 | color: #2343e0; 58 | } 59 | 60 | a:visited { 61 | color: #2343e0; 62 | } 63 | 64 | .pad-05 { 65 | padding: 8px; 66 | } 67 | 68 | .pad-1 { 69 | padding: 16px; 70 | } 71 | 72 | .pad-15 { 73 | padding: 24px; 74 | } 75 | 76 | .pad-2 { 77 | padding: 32px; 78 | } 79 | 80 | .margin-bottom-05.margin-bottom-05 { 81 | margin-bottom: 8px; 82 | } 83 | 84 | .margin-bottom-1.margin-bottom-1 { 85 | margin-bottom: 16px; 86 | } 87 | 88 | .margin-bottom-2.margin-bottom-2 { 89 | margin-bottom: 32px; 90 | } 91 | 92 | .item-dir, .item-file { 93 | display: flex; 94 | flex-direction: row; 95 | flex-wrap: nowrap; 96 | } 97 | 98 | .item-dir>:nth-child(1), 99 | .item-file>:nth-child(1) { 100 | flex-grow: 1; 101 | } 102 | 103 | .item-dir, .item-file, 104 | .item-dir span, .item-file span, 105 | .item-dir a, .item-file a { 106 | font-weight: normal; 107 | text-decoration: none; 108 | color: inherit; 109 | } 110 | 111 | .item-dir, .item-file { 112 | cursor: pointer; 113 | user-select: none; 114 | padding: 1px 2px 2px; 115 | border-radius: 0; 116 | border-top: 1px solid #e8e9eb; 117 | } 118 | 119 | .item-dir img, .item-file img { 120 | width: 32px; 121 | margin-top: 5px; 122 | margin-bottom: 3px; 123 | display: block; 124 | } 125 | 126 | .item-dir img:not(:last-child), 127 | .item-file img:not(:last-child) { 128 | float: left; 129 | margin-right: 4px; 130 | } 131 | 132 | .item-name-text { 133 | display: inline-block; 134 | padding-top: 4px; 135 | min-width: 80%; 136 | font-size: 90%; 137 | font-weight: normal; 138 | } 139 | 140 | .item-size-text, .item-lastmod-text { 141 | display: inline-block; 142 | min-width: 120px; 143 | text-decoration: none; 144 | font-weight: normal; 145 | } 146 | 147 | .breadcrumbs { 148 | overflow-y: hidden; 149 | overflow-x: scroll; 150 | padding-bottom: 4px; 151 | border-bottom: 1px solid #e8e9eb; 152 | } 153 | 154 | .breadcrumb { 155 | text-decoration: none; 156 | white-space: pre; 157 | padding: 5px 1px 5px 0px; 158 | } 159 | 160 | .breadcrumb:hover { 161 | text-decoration: underline; 162 | background-color: rgba(190, 210, 255, 0.2); 163 | } 164 | 165 | @media screen and (min-width: 4.50in) { 166 | 167 | body>main.card { 168 | width: max(4.40in, 60%); 169 | margin: 2em auto 0; 170 | border: 1px solid #eeeeee; 171 | border-radius: 8px; 172 | box-shadow: 0 5px 20px 1px rgba(100, 100, 100, 0.08); 173 | background-color: #ffffff; 174 | overflow: hidden; 175 | } 176 | 177 | .item-dir, .item-file { 178 | padding: 2px 4px 1px; 179 | } 180 | 181 | .item-name-text { 182 | padding-top: 8px; 183 | font-size: 100%; 184 | } 185 | 186 | .item-dir img, .item-file img { 187 | width: 48px; 188 | margin-top: 4px; 189 | margin-bottom: 3px; 190 | } 191 | 192 | .item-dir img:not(:last-child), 193 | .item-file img:not(:last-child) { 194 | margin-right: 16px; 195 | } 196 | } 197 | 198 | .text-small { 199 | font-size: 80%; 200 | } 201 | 202 | .text-gray.text-gray { 203 | color: #a9aaab; 204 | } 205 | 206 | .text-center { 207 | text-align: center; 208 | } 209 | -------------------------------------------------------------------------------- /webapp/assets/html/BreadcrumbWidget.html: -------------------------------------------------------------------------------- 1 | {1} / -------------------------------------------------------------------------------- /webapp/assets/html/DirectoryWidget.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 | {1} 4 |
    5 | directory 6 | {2:yyyy-MM-dd HH:mm:ss} 7 |
  • -------------------------------------------------------------------------------- /webapp/assets/html/ErrorView.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {0} 8 | 9 | 10 | 11 | 12 |
    13 |

    {1}

    14 |

    {2}

    15 |
    16 | 19 | 20 | -------------------------------------------------------------------------------- /webapp/assets/html/FileWidget.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 | {2}
    4 | {3} 5 | {4:yyyy-MM-dd HH:mm:ss}
    6 |
  • -------------------------------------------------------------------------------- /webapp/assets/html/FsView.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {0} 8 | 9 | 10 | 11 | 12 |
    13 | 14 |
    15 |
    16 |

    {1}

    17 |
      18 | {3} 19 | {4} 20 |
    21 |
    22 | 25 | 26 | -------------------------------------------------------------------------------- /webapp/assets/html/_FormatSpec.md: -------------------------------------------------------------------------------- 1 | # Format specifications 2 | 3 | Razor syntax is overkill in this use case, so let's try to just String.Format() 4 | 5 | ## ErrorView.html 6 | - {0}: Page title 7 | - {1}: Subheader e.g. "Error 404" 8 | - {2}: Additional text e.g. "file /Example.txt was not found" 9 | 10 | ## FsView.html 11 | - {0}: Page title 12 | - {1}: Subheader 13 | - {2}: Navigation breadcrumbs 14 | - {3}: Directories 15 | - {4}: Files 16 | 17 | ## DirectoryWidget.html 18 | - {0}: Browse link 19 | - {1}: Name 20 | - {2}: Last modified 21 | 22 | ## FileWidget.html 23 | - {0}: Preview link 24 | - {1}: Download link 25 | - {2}: Name 26 | - {3}: Size 27 | - {4}: Last modified 28 | 29 | ## BreadcrumbWidget.html 30 | - {0}: Link 31 | - {1}: Name 32 | 33 | 34 | -------------------------------------------------------------------------------- /webapp/assets/img/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /webapp/assets/img/icon-back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /webapp/assets/img/icon-dir.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /webapp/assets/img/icon-download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /webapp/assets/img/icon-file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /webapp/assets/img/icon-file2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /webapp/src/Handlers/DownloadHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Http; 7 | 8 | public class DownloadHandler : FsHandler 9 | { 10 | public DownloadHandler () : base () { } 11 | 12 | public override async Task ServeDirectory (HttpContext context, string path) 13 | { 14 | await Presentation.ServeBadRequest(context, "Error 400: Bad request", 15 | $"Directories like {context.Request.Path} can not be downloaded at the moment."); 16 | } 17 | 18 | public override async Task ServeFile (HttpContext context, string path) 19 | { 20 | string requestPath = Uri.UnescapeDataString(context.Request.Path); 21 | try { 22 | string filename = Uri.EscapeDataString(Path.GetFileName(path)); 23 | context.Response.Headers["Content-Type"] = "application/octet-stream"; 24 | context.Response.Headers["Content-Disposition"] = $"attachment; filename*=utf-8''{filename}"; 25 | await context.Response.SendFileAsync(path); 26 | } 27 | catch (FileNotFoundException) { 28 | await Presentation.ServeNotFound(context, "Error 404: Not found", 29 | $"No file or directory was found at {requestPath}"); 30 | } 31 | catch (Exception ex) { 32 | Console.WriteLine(ex); 33 | await Presentation.ServeInternalError(context, "Error 500: Internal server error", 34 | $"Something went wrong while trying to download {requestPath}"); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /webapp/src/Handlers/FsHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.StaticFiles; 8 | 9 | public class FsHandler 10 | { 11 | static FileExtensionContentTypeProvider Mime = new(); 12 | 13 | static bool ForbiddenSymbolsIn (string path) => 14 | path.Contains("//") || path.Contains("\\") || 15 | path.Contains("../") || path.Contains("..\\") || 16 | path.Contains(":"); 17 | 18 | public string RootDir { get; set; } 19 | public IFileServerPresentation Presentation { get; set; } 20 | 21 | public FsHandler () 22 | { 23 | this.Presentation = new TextPresentation(); 24 | } 25 | 26 | public virtual async Task Run (HttpContext context) 27 | { 28 | string path = Uri.UnescapeDataString((string)context.Request.Path); 29 | 30 | if (path.Length > 0 && path.StartsWith("/")) { 31 | path = path.Substring(1); 32 | } 33 | path = Path.Join(RootDir, path); 34 | 35 | if (ForbiddenSymbolsIn(context.Request.Path)) { 36 | await Presentation.ServeForbidden(context, "Error 403: Forbidden", 37 | $"Path {context.Request.Path} is not allowed."); 38 | } 39 | else if (Directory.Exists(path)) { 40 | await this.ServeDirectory(context, path); 41 | } 42 | else if (File.Exists(path)) { 43 | await this.ServeFile(context, path); 44 | } 45 | else { 46 | await Presentation.ServeNotFound(context, "Error 404: Not found", 47 | $"No file or directory was found at {context.Request.Path}"); 48 | } 49 | } 50 | 51 | public virtual async Task ServeDirectory (HttpContext context, string path) 52 | { 53 | try { 54 | await Presentation.ServeDirectory(context, new DirectoryInfo(path)); 55 | } 56 | catch (DirectoryNotFoundException) { 57 | await Presentation.ServeNotFound(context, "Error 404: Not found", 58 | $"No file or directory was found at {context.Request.Path}"); 59 | } 60 | catch (Exception ex) { 61 | Console.WriteLine(ex); 62 | await Presentation.ServeInternalError(context, "Error 500: Internal server error", 63 | $"Something went wrong while trying to access {context.Request.Path}"); 64 | } 65 | } 66 | 67 | public virtual async Task ServeFile (HttpContext context, string path) 68 | { 69 | try { 70 | string contentType = "unknown/unknown"; 71 | Mime.TryGetContentType(path, out contentType); 72 | context.Response.Headers.Append ("Content-Type", contentType); 73 | context.Response.Headers.Append ("Cache-Control", "public, max-age=100000"); 74 | await context.Response.SendFileAsync(path); 75 | } 76 | catch (FileNotFoundException) { 77 | await Presentation.ServeNotFound(context, "Error 404: Not found", 78 | $"No file or directory was found at {context.Request.Path}"); 79 | } 80 | catch (Exception ex) { 81 | Console.WriteLine(ex); 82 | await Presentation.ServeInternalError(context, "Error 500: Internal server error", 83 | $"Something went wrong while trying to access {context.Request.Path}"); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /webapp/src/Presentations/HtmlPresentation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.StaticFiles; 8 | 9 | public class HtmlPresentation : IFileServerPresentation 10 | { 11 | static string 12 | ErrorView = File.ReadAllText("assets/html/ErrorView.html"), 13 | FsView = File.ReadAllText("assets/html/FsView.html"), 14 | DirectoryWidget = File.ReadAllText("assets/html/DirectoryWidget.html"), 15 | FileWidget = File.ReadAllText("assets/html/FileWidget.html"), 16 | BreadcrumbWidget = File.ReadAllText("assets/html/BreadcrumbWidget.html"); 17 | 18 | public FileSizeFormatter FileSizeFormatter { get; set; } = new(); 19 | 20 | static string UriEscape (string text) => Uri.EscapeDataString(text); 21 | 22 | public async Task ServeDirectory (HttpContext context, DirectoryInfo dir) 23 | { 24 | context.Response.StatusCode = 200; 25 | context.Response.Headers["Content-Type"] = "text/html"; 26 | 27 | var dirsFormatted = dir.EnumerateDirectories() 28 | .OrderBy(dir => dir.Name) 29 | .Select(dir => String.Format (DirectoryWidget, 30 | Path.Join("/fs", context.Request.Path, UriEscape(dir.Name)), 31 | dir.Name, 32 | dir.LastWriteTimeUtc 33 | )).ToList(); 34 | 35 | var filesFormatted = dir.EnumerateFiles() 36 | .OrderBy(file => file.Name) 37 | .Select(file => String.Format(FileWidget, 38 | Path.Join("/fs", context.Request.Path, UriEscape(file.Name)), 39 | Path.Join("/download", context.Request.Path, UriEscape(file.Name)), 40 | file.Name, 41 | this.FileSizeFormatter.Format("", file.Length, null), 42 | file.LastWriteTimeUtc 43 | )).ToList(); 44 | 45 | var pathParts = ((string)context.Request.Path) 46 | .Split("/", StringSplitOptions.RemoveEmptyEntries); 47 | 48 | var breadcrumbs = new (string name, string link)[pathParts.Length>0 ? pathParts.Length : 1]; 49 | 50 | breadcrumbs[0] = ("", "/fs/"); 51 | for (int i=1; i 0) { 58 | dirsFormatted.Insert(0, String.Format(DirectoryWidget, 59 | breadcrumbs[breadcrumbs.Length - 1].link, 60 | "..", 61 | null 62 | )); 63 | } 64 | 65 | var breadcrumbsFormatted = breadcrumbs 66 | .Select(b => String.Format(BreadcrumbWidget, b.link, b.name)); 67 | 68 | await context.Response.WriteAsync ( String.Format ( 69 | FsView, 70 | context.Request.Path + " - File server", 71 | pathParts.Length > 0 ? dir.Name : "Index of /", 72 | String.Join("", breadcrumbsFormatted), 73 | String.Join("\n", dirsFormatted), 74 | String.Join("\n", filesFormatted) 75 | )); 76 | } 77 | 78 | public async Task ServeBadRequest (HttpContext context, string subheader, string text) 79 | { 80 | context.Response.StatusCode = 400; 81 | context.Response.Headers["Content-Type"] = "text/html"; 82 | await context.Response.WriteAsync ( String.Format ( 83 | ErrorView, "Bad request", subheader, text 84 | )); 85 | } 86 | 87 | public async Task ServeForbidden (HttpContext context, string subheader, string text) 88 | { 89 | context.Response.StatusCode = 403; 90 | context.Response.Headers["Content-Type"] = "text/html"; 91 | await context.Response.WriteAsync ( String.Format ( 92 | ErrorView, "Forbidden", subheader, text 93 | )); 94 | } 95 | 96 | public async Task ServeNotFound (HttpContext context, string subheader, string text) 97 | { 98 | context.Response.StatusCode = 404; 99 | context.Response.Headers["Content-Type"] = "text/html"; 100 | await context.Response.WriteAsync ( String.Format ( 101 | ErrorView, "Not found", subheader, text 102 | )); 103 | } 104 | 105 | public async Task ServeInternalError (HttpContext context, string subheader, string text) 106 | { 107 | context.Response.StatusCode = 500; 108 | context.Response.Headers["Content-Type"] = "text/html"; 109 | await context.Response.WriteAsync ( String.Format ( 110 | ErrorView, "Internal error", subheader, text 111 | )); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /webapp/src/Presentations/IFileServerPresentation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.StaticFiles; 8 | 9 | public interface IFileServerPresentation 10 | { 11 | Task ServeDirectory (HttpContext context, DirectoryInfo dir); 12 | Task ServeBadRequest (HttpContext context, string subheader, string text); 13 | Task ServeForbidden (HttpContext context, string subheader, string text); 14 | Task ServeNotFound (HttpContext context, string subheader, string text); 15 | Task ServeInternalError (HttpContext context, string subheader, string text); 16 | } 17 | -------------------------------------------------------------------------------- /webapp/src/Presentations/TextPresentation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.StaticFiles; 8 | 9 | public class TextPresentation : IFileServerPresentation 10 | { 11 | public FileSizeFormatter FileSizeFormatter { get; set; } = new(); 12 | 13 | public async Task ServeDirectory (HttpContext context, DirectoryInfo dir) 14 | { 15 | context.Response.StatusCode = 200; 16 | context.Response.Headers["Content-Type"] = "text/plain"; 17 | 18 | var dirs = dir.EnumerateDirectories().OrderBy(dir => dir.Name) 19 | .Select(dir => $"{dir.Name}/ dir {dir.LastWriteTimeUtc:yyyy-MM-dd HH:mm:ss}\n"); 20 | 21 | var files = dir.EnumerateFiles().OrderBy(file => file.Name) 22 | .Select(file => String.Format(this.FileSizeFormatter, 23 | "{0} {1} {2}\n", file.Name, file.Length, file.LastWriteTimeUtc)); 24 | 25 | await context.Response.WriteAsync( String.Format ( 26 | "Current time: {0:yyyy-MM-dd HH:mm:ss}\nPath: {1}\n {2} \n{3} \n", 27 | DateTime.Now, context.Request.Path, 28 | String.Join("", dirs), String.Join("", files) 29 | )); 30 | } 31 | 32 | public async Task ServeBadRequest (HttpContext context, string subheader, string text) 33 | { 34 | context.Response.StatusCode = 400; 35 | context.Response.Headers["Content-Type"] = "text/plain"; 36 | await context.Response.WriteAsync ( String.Format ( 37 | "{0} \n{1}", subheader, text 38 | )); 39 | } 40 | 41 | public async Task ServeForbidden (HttpContext context, string subheader, string text) 42 | { 43 | context.Response.StatusCode = 403; 44 | context.Response.Headers["Content-Type"] = "text/plain"; 45 | await context.Response.WriteAsync ( String.Format ( 46 | "{0} \n{1}", subheader, text 47 | )); 48 | } 49 | 50 | public async Task ServeNotFound (HttpContext context, string subheader, string text) 51 | { 52 | context.Response.StatusCode = 404; 53 | context.Response.Headers["Content-Type"] = "text/plain"; 54 | await context.Response.WriteAsync ( String.Format ( 55 | "{0} \n{1}", subheader, text 56 | )); 57 | } 58 | 59 | public async Task ServeInternalError (HttpContext context, string subheader, string text) 60 | { 61 | context.Response.StatusCode = 500; 62 | context.Response.Headers["Content-Type"] = "text/plain"; 63 | await context.Response.WriteAsync ( String.Format ( 64 | "{0} \n{1}", subheader, text 65 | )); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /webapp/src/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Hosting; 8 | 9 | public class Program 10 | { 11 | public static void Main (string[] args) 12 | { 13 | if (args.Length < 2) { 14 | Console.WriteLine("Expected arguments: "); 15 | return; 16 | } 17 | 18 | Startup.RootDirectory = args[0]; 19 | Startup.Port = args[1]; 20 | 21 | // create app 22 | var app = new WebHostBuilder() 23 | .UseKestrel() 24 | .ConfigureServices(Startup.ConfigureServices) 25 | .Configure(Startup.Configure) 26 | .UseUrls($"http://0.0.0.0:{Startup.Port}") 27 | .Build(); 28 | 29 | // serve 30 | app.Run(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /webapp/src/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.Extensions.FileProviders; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Hosting; 12 | 13 | public class Startup 14 | { 15 | public static string RootDirectory { get; set; } 16 | public static string Port { get; set; } 17 | 18 | public static bool RootDirectoryIsAbsolute => RootDirectory.StartsWith("/") 19 | || RootDirectory.Contains(":/") || RootDirectory.Contains(":\\"); 20 | 21 | public static string AssetsRootDirectory => 22 | Path.Combine(Directory.GetCurrentDirectory(), "./assets"); 23 | 24 | public static string FsRootDirectory => RootDirectoryIsAbsolute ? 25 | RootDirectory : Path.Combine(Directory.GetCurrentDirectory(), RootDirectory); 26 | 27 | // This method gets called by the runtime. 28 | // Use this method to add services to the container. 29 | public static void ConfigureServices (IServiceCollection services) 30 | { 31 | services.AddRouting(); 32 | } 33 | 34 | // This method gets called by the runtime. 35 | // Use this method to configure the HTTP request pipeline. 36 | public static void Configure (IApplicationBuilder app) 37 | { 38 | // Enable routing 39 | app.UseRouting(); 40 | 41 | app.Use ( async (context, next) => { 42 | Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {context.Request.Method} {context.Request.Path}"); 43 | await next(); 44 | }); 45 | 46 | var presentation = new HtmlPresentation { 47 | FileSizeFormatter = new () { 48 | SizeFormat = FileSizeFormat.Decimal 49 | } 50 | }; 51 | 52 | var fsHandler = new FsHandler { 53 | RootDir = FsRootDirectory, 54 | Presentation = presentation 55 | }; 56 | 57 | var downloadHandler = new DownloadHandler { 58 | RootDir = FsRootDirectory, 59 | Presentation = presentation 60 | }; 61 | 62 | app.Map ( "/fs", app => { 63 | app.Run (fsHandler.Run); 64 | }); 65 | 66 | app.Map ( "/download", app => { 67 | app.Run (downloadHandler.Run); 68 | }); 69 | 70 | app.UseStaticFiles ( new StaticFileOptions { 71 | RequestPath = "/assets", 72 | FileProvider = new PhysicalFileProvider(AssetsRootDirectory) 73 | }); 74 | 75 | app.Run ( async (context) => { 76 | var path = (string)context.Request.Path; 77 | if (path.Length < 2) { 78 | context.Response.Redirect("/fs/", false, true); 79 | } 80 | else { 81 | await context.Response.WriteAsync($"Nothing here on {path}"); 82 | } 83 | }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /webapp/src/Utils/EnvHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | 6 | public static class env 7 | { 8 | static IDictionary readDotenvVariables () 9 | { 10 | try { 11 | return File.ReadLines(".env") 12 | .Select(line => line.Split('=')) 13 | .Where(words => words.Length == 2) 14 | .ToDictionary(kvpair => kvpair[0], kvpair => kvpair[1]); 15 | } 16 | catch (Exception) { 17 | return new Dictionary(); 18 | } 19 | } 20 | 21 | public static readonly IDictionary dotenvVariables = readDotenvVariables(); 22 | 23 | public static string get (string key) 24 | { 25 | string result; 26 | dotenvVariables.TryGetValue(key, out result); 27 | return result ?? Environment.GetEnvironmentVariable(key); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /webapp/src/Utils/FileSizeFormat.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | public enum FileSizeFormat 4 | { 5 | /* Here file sizes are as-is, 1000 bytes shall be displayed as 1000 bytes */ 6 | Plain = 0, 7 | 8 | /* Here 1Kb is considered 1000 bytes */ 9 | Decimal = 1, 10 | 11 | /* Here 1Kb is considered 1024 bytes */ 12 | Binary = 2 13 | } 14 | -------------------------------------------------------------------------------- /webapp/src/Utils/FileSizeFormatter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | 4 | public class FileSizeFormatter : IFormatProvider, ICustomFormatter 5 | { 6 | public FileSizeFormat SizeFormat { get; set; } 7 | 8 | public FileSizeFormatter () 9 | { 10 | this.SizeFormat = FileSizeFormat.Plain; 11 | } 12 | 13 | public object GetFormat (Type formatType) 14 | { 15 | return this; 16 | } 17 | 18 | public string Format (string fmt, object arg, IFormatProvider formatProvider) 19 | { 20 | if (typeof(long).IsAssignableFrom(arg.GetType())) { 21 | long size = (long)arg; 22 | double dsize = (double)size; 23 | 24 | if (SizeFormat == FileSizeFormat.Plain) { 25 | return $"{size} bytes"; 26 | } 27 | else if (SizeFormat == FileSizeFormat.Decimal) { 28 | return size switch { 29 | >= 1000000000 => $"{(dsize/1000000000):F2} Gb", 30 | >= 1000000 => $"{(dsize/1000000):F2} Mb", 31 | >= 1000 => $"{(dsize/1000):F2} Kb", 32 | _ => $"{size} bytes" 33 | }; 34 | } 35 | else if (SizeFormat == FileSizeFormat.Binary) { 36 | return size switch { 37 | >= 1073741824 => $"{(dsize/1073741824):F2} Gb", 38 | >= 1048576 => $"{(dsize/1048576):F2} Mb", 39 | >= 1024 => $"{(dsize/1024):F2} Kb", 40 | _ => $"{size} bytes" 41 | }; 42 | } 43 | } 44 | else if (arg is IFormattable) { 45 | return (arg as IFormattable).ToString(fmt, CultureInfo.CurrentCulture); 46 | } 47 | 48 | return arg.ToString(); 49 | } 50 | 51 | } 52 | --------------------------------------------------------------------------------