Skip to content

Commit 5ce7a60

Browse files
committed
wip: introduce file servers to sisk
1 parent 65ee2f3 commit 5ce7a60

File tree

10 files changed

+730
-3
lines changed

10 files changed

+730
-3
lines changed

cadente/Sisk.Cadente.CoreEngine/CadenteHttpServerEngine.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@
88
// File name: CadenteHttpServerEngine.cs
99
// Repository: https://github.com/sisk-http/core
1010

11-
using System;
1211
using System.Net;
13-
using System.Runtime.InteropServices.JavaScript;
1412
using System.Threading.Channels;
1513
using Sisk.Core.Http;
1614
using Sisk.Core.Http.Engine;

src/Helpers/PathHelper.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,27 @@ namespace Sisk.Core.Helpers;
1515
/// Provides useful path-dedicated helper members.
1616
/// </summary>
1717
public sealed class PathHelper {
18+
/// <summary>
19+
/// Splits the specified path into its individual segments, removing empty entries and trimming whitespace.
20+
/// </summary>
21+
/// <param name="path">The path to split.</param>
22+
/// <returns>An array of path segments.</returns>
23+
public static string [] Split ( string path ) {
24+
return path.Split ( [ '/', '\\' ], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
25+
}
26+
27+
/// <summary>
28+
/// Removes the last segment from the specified path.
29+
/// </summary>
30+
/// <param name="path">The path to process.</param>
31+
/// <returns>The path without its final segment, or <see cref="string.Empty"/> if no segments remain.</returns>
32+
public static string Pop ( string path ) {
33+
var segments = Split ( path );
34+
if (segments.Length == 0)
35+
return string.Empty;
36+
return string.Join ( '/', segments, 0, segments.Length - 1 );
37+
}
38+
1839
/// <summary>
1940
/// Combines the specified URL paths into one.
2041
/// </summary>
@@ -59,11 +80,17 @@ public static string FilesystemCombinePaths ( params string [] paths ) {
5980
/// </summary>
6081
/// <param name="path">The path to normalize.</param>
6182
/// <param name="directorySeparator">The directory separator.</param>
62-
public static string NormalizePath ( string path, char directorySeparator = '/' ) {
83+
/// <param name="surroundWithDelimiters">
84+
/// <see langword="true"/> to ensure the result starts and ends with <paramref name="directorySeparator"/>;
85+
/// otherwise, <see langword="false"/>. Defaults to <see langword="false"/>.
86+
/// </param>
87+
public static string NormalizePath ( string path, char directorySeparator = '/', bool surroundWithDelimiters = false ) {
6388
string [] parts = path.Split ( pathNormalizationChars, StringSplitOptions.RemoveEmptyEntries );
6489
string result = string.Join ( directorySeparator, parts );
6590
if (path.StartsWith ( '/' ) || path.StartsWith ( '\\' ))
6691
result = directorySeparator + result;
92+
if (surroundWithDelimiters)
93+
result = directorySeparator + result.Trim ( '/', '\\' ) + directorySeparator;
6794
return result;
6895
}
6996
}

src/Http/FileContent.cs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// The Sisk Framework source code
2+
// Copyright (c) 2024- PROJECT PRINCIPIUM and all Sisk contributors
3+
//
4+
// The code below is licensed under the MIT license as
5+
// of the date of its publication, available at
6+
//
7+
// File name: FileContent.cs
8+
// Repository: https://github.com/sisk-http/core
9+
10+
using System.Net;
11+
using Sisk.Core.Helpers;
12+
13+
namespace Sisk.Core.Http;
14+
15+
/// <summary>
16+
/// Provides HTTP content based on a file.
17+
/// </summary>
18+
public sealed class FileContent : HttpContent {
19+
20+
const string DefaultMimeType = "application/octet-stream";
21+
22+
/// <summary>
23+
/// Gets the file information for the content.
24+
/// </summary>
25+
public FileInfo File { get; }
26+
27+
/// <summary>
28+
/// Initializes a new instance of the <see cref="FileContent"/> class with the specified file.
29+
/// </summary>
30+
/// <param name="file">The file to be used as content.</param>
31+
public FileContent ( FileInfo file ) {
32+
File = file;
33+
34+
var mimeType = MimeHelper.GetMimeType ( File.Extension, DefaultMimeType );
35+
if (mimeType == DefaultMimeType) {
36+
Headers.ContentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue ( "attachment" ) {
37+
FileName = File.Name
38+
};
39+
}
40+
else {
41+
Headers.ContentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue ( "inline" );
42+
}
43+
44+
Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue ( mimeType );
45+
}
46+
47+
/// <summary>
48+
/// Initializes a new instance of the <see cref="FileContent"/> class with the specified file path.
49+
/// </summary>
50+
/// <param name="filePath">The path to the file to be used as content.</param>
51+
public FileContent ( string filePath ) : this ( new FileInfo ( filePath ) ) {
52+
}
53+
54+
void ThrowIfFileNotFound () {
55+
if (!File.Exists)
56+
throw new FileNotFoundException ( "The specified file was not found.", File.FullName );
57+
}
58+
59+
/// <inheritdoc/>
60+
protected override async Task SerializeToStreamAsync ( Stream stream, TransportContext? context, CancellationToken cancellationToken ) {
61+
ThrowIfFileNotFound ();
62+
63+
using var fs = File.OpenRead ();
64+
await fs.CopyToAsync ( stream, cancellationToken );
65+
}
66+
67+
/// <inheritdoc/>
68+
protected override Task SerializeToStreamAsync ( Stream stream, TransportContext? context ) {
69+
return SerializeToStreamAsync ( stream, context, default );
70+
}
71+
72+
/// <inheritdoc/>
73+
protected override void SerializeToStream ( Stream stream, TransportContext? context, CancellationToken cancellationToken ) {
74+
ThrowIfFileNotFound ();
75+
76+
using var fs = File.OpenRead ();
77+
fs.CopyTo ( stream );
78+
}
79+
80+
/// <inheritdoc/>
81+
protected override Stream CreateContentReadStream ( CancellationToken cancellationToken ) {
82+
ThrowIfFileNotFound ();
83+
84+
return File.OpenRead ();
85+
}
86+
87+
/// <inheritdoc/>
88+
protected override Task<Stream> CreateContentReadStreamAsync () {
89+
return Task.FromResult ( CreateContentReadStream ( default ) );
90+
}
91+
92+
/// <inheritdoc/>
93+
protected override Task<Stream> CreateContentReadStreamAsync ( CancellationToken cancellationToken ) {
94+
return Task.FromResult ( CreateContentReadStream ( cancellationToken ) );
95+
}
96+
97+
/// <inheritdoc/>
98+
protected override bool TryComputeLength ( out long length ) {
99+
ThrowIfFileNotFound ();
100+
101+
length = File.Length;
102+
return true;
103+
}
104+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// The Sisk Framework source code
2+
// Copyright (c) 2024- PROJECT PRINCIPIUM and all Sisk contributors
3+
//
4+
// The code below is licensed under the MIT license as
5+
// of the date of its publication, available at
6+
//
7+
// File name: HttpFileAudioConverter.cs
8+
// Repository: https://github.com/sisk-http/core
9+
10+
namespace Sisk.Core.Http.FileSystem.Converters;
11+
12+
internal class HttpFileAudioConverter : HttpFileRangedContentStream {
13+
14+
static string [] AllowedExtensions = [ ".mp3", ".ogg", ".wav", ".flac", ".ogv" ];
15+
16+
public override bool CanConvert ( FileInfo file ) {
17+
return AllowedExtensions.Contains ( file.Extension.ToLowerInvariant () );
18+
}
19+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// The Sisk Framework source code
2+
// Copyright (c) 2024- PROJECT PRINCIPIUM and all Sisk contributors
3+
//
4+
// The code below is licensed under the MIT license as
5+
// of the date of its publication, available at
6+
//
7+
// File name: HttpFileRangedContentStream.cs
8+
// Repository: https://github.com/sisk-http/core
9+
10+
using System.Buffers;
11+
using System.Globalization;
12+
using System.Net;
13+
using Sisk.Core.Helpers;
14+
using Sisk.Core.Http;
15+
using Sisk.Core.Http.FileSystem;
16+
17+
abstract class HttpFileRangedContentStream : HttpFileServerFileConverter {
18+
19+
public virtual int ChunkSize => 5 * (int) SizeHelper.UnitMb;
20+
21+
public override HttpResponse Convert ( FileInfo file, HttpRequest request ) {
22+
23+
ArgumentOutOfRangeException.ThrowIfNegativeOrZero ( ChunkSize );
24+
25+
using var fs = file.OpenRead ();
26+
long length = fs.Length;
27+
28+
string? rangeHeader = request.Headers.Range;
29+
30+
bool hasRange = TryGetSingleRange (
31+
rangeHeader,
32+
length,
33+
out long rangeStart,
34+
out long rangeEnd,
35+
out bool isRangeSatisfiable
36+
);
37+
38+
if (hasRange && !isRangeSatisfiable) {
39+
return new HttpResponse ( HttpStatusCode.RequestedRangeNotSatisfiable )
40+
.WithHeader ( HttpKnownHeaderNames.ContentRange, $"bytes */{length}" );
41+
}
42+
43+
if (!hasRange) {
44+
var resStream = request.GetResponseStream ();
45+
46+
resStream.SetStatus ( HttpStatusCode.OK );
47+
resStream.SetHeader ( HttpKnownHeaderNames.AcceptRanges, "bytes" );
48+
resStream.SetHeader ( HttpKnownHeaderNames.ContentType, MimeHelper.GetMimeType ( file.Extension ) );
49+
resStream.SetContentLength ( length );
50+
51+
var buffer = ArrayPool<byte>.Shared.Rent ( ChunkSize );
52+
try {
53+
int read;
54+
while ((read = fs.Read ( buffer, 0, buffer.Length )) > 0) {
55+
resStream.ResponseStream.Write ( buffer, 0, read );
56+
}
57+
}
58+
finally {
59+
ArrayPool<byte>.Shared.Return ( buffer );
60+
}
61+
62+
return resStream.Close ();
63+
}
64+
65+
long requestedLength = rangeEnd - rangeStart + 1;
66+
67+
long maxLength = ChunkSize > 0
68+
? Math.Min ( requestedLength, ChunkSize )
69+
: requestedLength;
70+
71+
long actualEnd = rangeStart + maxLength - 1;
72+
73+
var partialResStream = request.GetResponseStream ();
74+
75+
partialResStream.SetStatus ( HttpStatusCode.PartialContent );
76+
partialResStream.SetHeader ( HttpKnownHeaderNames.AcceptRanges, "bytes" );
77+
partialResStream.SetHeader ( HttpKnownHeaderNames.ContentType, MimeHelper.GetMimeType ( file.Extension ) );
78+
partialResStream.SetHeader (
79+
HttpKnownHeaderNames.ContentRange,
80+
$"bytes {rangeStart}-{actualEnd}/{length}"
81+
);
82+
partialResStream.SetContentLength ( maxLength );
83+
84+
fs.Position = rangeStart;
85+
86+
// Buffer de leitura (no máximo ChunkSize, e nunca mais do que o que falta enviar)
87+
var rangeBuffer = ArrayPool<byte>.Shared.Rent ( (int) Math.Min ( ChunkSize, maxLength ) );
88+
try {
89+
long remaining = maxLength;
90+
while (remaining > 0) {
91+
int read = fs.Read ( rangeBuffer, 0, (int) Math.Min ( rangeBuffer.Length, remaining ) );
92+
if (read <= 0)
93+
break;
94+
95+
if (request.Method != HttpMethod.Head)
96+
partialResStream.ResponseStream.Write ( rangeBuffer, 0, read );
97+
remaining -= read;
98+
}
99+
}
100+
finally {
101+
ArrayPool<byte>.Shared.Return ( rangeBuffer );
102+
}
103+
104+
return partialResStream.Close ();
105+
}
106+
107+
private static bool TryGetSingleRange ( string? rangeHeader, long entityLength, out long rangeStart, out long rangeEnd, out bool isSatisfiable ) {
108+
rangeStart = 0;
109+
rangeEnd = 0;
110+
isSatisfiable = false;
111+
112+
if (string.IsNullOrWhiteSpace ( rangeHeader ))
113+
return false;
114+
115+
rangeHeader = rangeHeader.Trim ();
116+
117+
if (!rangeHeader.StartsWith ( "bytes=", StringComparison.OrdinalIgnoreCase ))
118+
return false;
119+
120+
var rangeSpec = rangeHeader.Substring ( "bytes=".Length ).Trim ();
121+
if (string.IsNullOrEmpty ( rangeSpec ))
122+
return false;
123+
124+
if (rangeSpec.Contains ( ',' ))
125+
return false;
126+
127+
var parts = rangeSpec.Split ( '-', 2 );
128+
if (parts.Length != 2)
129+
return false;
130+
131+
string startPart = parts [ 0 ].Trim ();
132+
string endPart = parts [ 1 ].Trim ();
133+
134+
// Caso 1: bytes=start-end
135+
if (startPart.Length > 0 && endPart.Length > 0) {
136+
if (!long.TryParse ( startPart, NumberStyles.None, CultureInfo.InvariantCulture, out var start ) ||
137+
!long.TryParse ( endPart, NumberStyles.None, CultureInfo.InvariantCulture, out var end )) {
138+
return false;
139+
}
140+
141+
if (start < 0 || end < start)
142+
return false;
143+
144+
if (start >= entityLength) {
145+
isSatisfiable = false;
146+
return true;
147+
}
148+
149+
rangeStart = start;
150+
rangeEnd = Math.Min ( end, entityLength - 1 );
151+
isSatisfiable = true;
152+
return true;
153+
}
154+
155+
if (startPart.Length > 0 && endPart.Length == 0) {
156+
if (!long.TryParse ( startPart, NumberStyles.None, CultureInfo.InvariantCulture, out var start ))
157+
return false;
158+
159+
if (start < 0)
160+
return false;
161+
162+
if (start >= entityLength) {
163+
isSatisfiable = false;
164+
return true;
165+
}
166+
167+
rangeStart = start;
168+
rangeEnd = entityLength - 1;
169+
isSatisfiable = true;
170+
return true;
171+
}
172+
173+
if (startPart.Length == 0 && endPart.Length > 0) {
174+
if (!long.TryParse ( endPart, NumberStyles.None, CultureInfo.InvariantCulture, out var suffixLength ))
175+
return false;
176+
177+
if (suffixLength <= 0)
178+
return false;
179+
180+
if (entityLength == 0) {
181+
isSatisfiable = false;
182+
return true;
183+
}
184+
185+
if (suffixLength >= entityLength) {
186+
rangeStart = 0;
187+
}
188+
else {
189+
rangeStart = entityLength - suffixLength;
190+
}
191+
192+
rangeEnd = entityLength - 1;
193+
isSatisfiable = true;
194+
return true;
195+
}
196+
197+
return false;
198+
}
199+
}

0 commit comments

Comments
 (0)