|
|
@@ -0,0 +1,418 @@
|
|
|
+using Invercargill;
|
|
|
+using Invercargill.DataStructures;
|
|
|
+
|
|
|
+namespace Astralis {
|
|
|
+
|
|
|
+ /// A resource that serves files from the filesystem.
|
|
|
+ ///
|
|
|
+ /// Route patterns:
|
|
|
+ /// - Exact match (e.g., "/robots.txt"): Serves a specific file
|
|
|
+ /// - Shallow match (e.g., "/assets/*"): Serves files one level deep from the directory
|
|
|
+ /// - Deep match (e.g., "/static/**"): Serves files recursively from the directory
|
|
|
+ public class FilesystemResource : Object, Endpoint {
|
|
|
+
|
|
|
+ /// The filesystem path to serve files from
|
|
|
+ public string server_path { get; private set; }
|
|
|
+
|
|
|
+ /// The route pattern for this resource
|
|
|
+ public string route { get { return _route; } }
|
|
|
+
|
|
|
+ /// Only GET and HEAD methods are supported for file serving
|
|
|
+ public Method[] methods { owned get {
|
|
|
+ return new Method[] { Method.GET, Method.HEAD };
|
|
|
+ }}
|
|
|
+
|
|
|
+ /// Whether to allow directory listings (only for /* and /** routes)
|
|
|
+ public bool allow_directory_listing { get; set; default = false; }
|
|
|
+
|
|
|
+ /// Default index file to look for in directories (e.g., "index.html")
|
|
|
+ public string? index_file { get; set; default = "index.html"; }
|
|
|
+
|
|
|
+ private string _route;
|
|
|
+ private MatchType _match_type;
|
|
|
+ private string[] _route_components;
|
|
|
+ private string _route_prefix; // For wildcard routes, the prefix before the wildcard
|
|
|
+
|
|
|
+ public FilesystemResource(string route, string server_path) throws FilesystemResourceError {
|
|
|
+ // Validate and parse the route
|
|
|
+ _route = route;
|
|
|
+ _route_components = parse_route_components(route);
|
|
|
+ _match_type = determine_match_type(route);
|
|
|
+ _route_prefix = extract_route_prefix(route, _match_type);
|
|
|
+
|
|
|
+ // Validate server path exists
|
|
|
+ var file = File.new_for_path(server_path);
|
|
|
+ if (!file.query_exists()) {
|
|
|
+ throw new FilesystemResourceError.PATH_NOT_FOUND(
|
|
|
+ "Server path does not exist: %s".printf(server_path)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ this.server_path = server_path;
|
|
|
+ }
|
|
|
+
|
|
|
+ public async HttpResult handle_request(HttpContext http_context, RouteInformation route_context) throws Error {
|
|
|
+ // Get the remaining path components after the route prefix
|
|
|
+ string relative_path = get_relative_path(route_context);
|
|
|
+ print(@"Getting $(relative_path)\n");
|
|
|
+
|
|
|
+ // Build the full filesystem path
|
|
|
+ string full_path = Path.build_filename(server_path, relative_path);
|
|
|
+
|
|
|
+ // Security: Ensure the resolved path is within server_path (prevent path traversal)
|
|
|
+ string resolved_path = canonicalize_path(full_path);
|
|
|
+ string resolved_server_path = canonicalize_path(server_path);
|
|
|
+
|
|
|
+ if (!resolved_path.has_prefix(resolved_server_path)) {
|
|
|
+ return new HttpStringResult("Forbidden", StatusCode.FORBIDDEN);
|
|
|
+ }
|
|
|
+
|
|
|
+ var file = File.new_for_path(resolved_path);
|
|
|
+
|
|
|
+ // Check if file exists
|
|
|
+ if (!file.query_exists()) {
|
|
|
+ return new HttpStringResult("Not Found", StatusCode.NOT_FOUND);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Get file info
|
|
|
+ var info = yield file.query_info_async(
|
|
|
+ "standard::type,standard::content-type,standard::size",
|
|
|
+ FileQueryInfoFlags.NONE
|
|
|
+ );
|
|
|
+
|
|
|
+ var file_type = info.get_file_type();
|
|
|
+
|
|
|
+ // Handle directories
|
|
|
+ if (file_type == FileType.DIRECTORY) {
|
|
|
+ // Try index file first
|
|
|
+ if (index_file != null) {
|
|
|
+ var index_path = Path.build_filename(resolved_path, index_file);
|
|
|
+ var index_file_obj = File.new_for_path(index_path);
|
|
|
+ if (index_file_obj.query_exists()) {
|
|
|
+ return yield serve_file(index_file_obj, http_context);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Directory listing if enabled
|
|
|
+ if (allow_directory_listing) {
|
|
|
+ return yield serve_directory_listing(file, relative_path, http_context);
|
|
|
+ }
|
|
|
+
|
|
|
+ return new HttpStringResult("Forbidden", StatusCode.FORBIDDEN);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Serve regular file
|
|
|
+ return yield serve_file(file, http_context);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async HttpResult serve_file(File file, HttpContext http_context) throws Error {
|
|
|
+ var info = yield file.query_info_async(
|
|
|
+ "standard::content-type,standard::size",
|
|
|
+ FileQueryInfoFlags.NONE
|
|
|
+ );
|
|
|
+
|
|
|
+ uint64 size = info.get_size();
|
|
|
+ string? content_type = info.get_content_type();
|
|
|
+
|
|
|
+ // Open file for reading
|
|
|
+ var stream = yield file.read_async();
|
|
|
+
|
|
|
+ var result = new HttpStreamResult(stream, size);
|
|
|
+
|
|
|
+ // Set content type based on file extension or detected type
|
|
|
+ string mime_type = get_mime_type(file.get_basename(), content_type);
|
|
|
+ result.set_header("Content-Type", mime_type);
|
|
|
+
|
|
|
+ // Don't compress binary files
|
|
|
+ // if (should_skip_compression(mime_type)) {
|
|
|
+ // result.set_flag(HttpResultFlag.DO_NOT_COMPRESS);
|
|
|
+ // }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async HttpResult serve_directory_listing(File directory, string relative_path, HttpContext http_context) throws Error {
|
|
|
+ var builder = new StringBuilder();
|
|
|
+ builder.append("<!DOCTYPE html>\n");
|
|
|
+ builder.append("<html><head><title>Directory: /%s</title></head>\n".printf(relative_path));
|
|
|
+ builder.append("<body><h1>Directory: /%s</h1><ul>\n".printf(relative_path));
|
|
|
+
|
|
|
+ // Parent directory link (if not at root)
|
|
|
+ if (relative_path.length > 0 && relative_path != ".") {
|
|
|
+ builder.append("<li><a href=\"../\">../</a></li>\n");
|
|
|
+ }
|
|
|
+
|
|
|
+ var enumerator = yield directory.enumerate_children_async(
|
|
|
+ "standard::name,standard::type",
|
|
|
+ FileQueryInfoFlags.NONE
|
|
|
+ );
|
|
|
+
|
|
|
+ var entries = new Series<string>();
|
|
|
+
|
|
|
+ List<FileInfo>? infos;
|
|
|
+ while ((infos = yield enumerator.next_files_async(1)) != null) {
|
|
|
+ foreach (var info in infos) {
|
|
|
+ string name = info.get_name();
|
|
|
+ FileType type = info.get_file_type();
|
|
|
+
|
|
|
+ // Use relative links - just the filename, since the browser is already at the directory URL
|
|
|
+ // This avoids path duplication issues
|
|
|
+ string link = Uri.escape_string(name);
|
|
|
+ if (type == FileType.DIRECTORY) {
|
|
|
+ link += "/";
|
|
|
+ }
|
|
|
+
|
|
|
+ string display = name;
|
|
|
+ if (type == FileType.DIRECTORY) {
|
|
|
+ display += "/";
|
|
|
+ }
|
|
|
+
|
|
|
+ entries.add("<li><a href=\"%s\">%s</a></li>\n".printf(
|
|
|
+ link,
|
|
|
+ Markup.escape_text(display)
|
|
|
+ ));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Sort entries alphabetically using insertion sort
|
|
|
+ var sorted = entries.to_array();
|
|
|
+ for (int i = 1; i < sorted.length; i++) {
|
|
|
+ string key = sorted[i];
|
|
|
+ int j = i - 1;
|
|
|
+ while (j >= 0 && strcmp(sorted[j], key) > 0) {
|
|
|
+ sorted[j + 1] = sorted[j];
|
|
|
+ j--;
|
|
|
+ }
|
|
|
+ sorted[j + 1] = key;
|
|
|
+ }
|
|
|
+ foreach (var entry in sorted) {
|
|
|
+ builder.append(entry);
|
|
|
+ }
|
|
|
+
|
|
|
+ builder.append("</ul></body></html>");
|
|
|
+
|
|
|
+ var result = new HttpStringResult(builder.str);
|
|
|
+ result.set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private string get_relative_path(RouteInformation route_context) {
|
|
|
+ // For exact match, use the last component of the route
|
|
|
+ if (_match_type == MatchType.EXACT) {
|
|
|
+ return _route_components.length > 0 ? _route_components[_route_components.length - 1] : "";
|
|
|
+ }
|
|
|
+
|
|
|
+ // For wildcard matches, get the remaining path from the named components
|
|
|
+ string? remaining = null;
|
|
|
+ route_context.named_components.try_get("**", out remaining);
|
|
|
+ if (remaining != null && remaining.length > 0) {
|
|
|
+ return remaining;
|
|
|
+ }
|
|
|
+
|
|
|
+ // If no remaining path, serve from root of server_path
|
|
|
+ return ".";
|
|
|
+ }
|
|
|
+
|
|
|
+ private string canonicalize_path(string path) {
|
|
|
+ // Resolve the path to get the real, absolute path
|
|
|
+ // This handles . and .. components as well as symlinks
|
|
|
+ var file = File.new_for_path(path);
|
|
|
+ return file.get_path() ?? path;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string[] parse_route_components(string route) {
|
|
|
+ return Wrap.array<string>(route.split("/"))
|
|
|
+ .where(c => c.length > 0 && c != "*" && c != "**")
|
|
|
+ .to_array();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static MatchType determine_match_type(string route) {
|
|
|
+ if (route.has_suffix("/**")) {
|
|
|
+ return MatchType.DEEP;
|
|
|
+ } else if (route.has_suffix("/*")) {
|
|
|
+ return MatchType.SHALLOW;
|
|
|
+ } else {
|
|
|
+ return MatchType.EXACT;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string extract_route_prefix(string route, MatchType match_type) {
|
|
|
+ switch (match_type) {
|
|
|
+ case MatchType.DEEP:
|
|
|
+ return route.substring(0, route.length - 3);
|
|
|
+ case MatchType.SHALLOW:
|
|
|
+ return route.substring(0, route.length - 2);
|
|
|
+ default:
|
|
|
+ return route;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string get_mime_type(string filename, string? detected_type) {
|
|
|
+ // Get extension
|
|
|
+ int dot_pos = filename.last_index_of_char('.');
|
|
|
+ if (dot_pos >= 0 && dot_pos < filename.length - 1) {
|
|
|
+ string ext = filename.substring(dot_pos + 1).down();
|
|
|
+ string? mime = get_mime_type_for_extension(ext);
|
|
|
+ if (mime != null) {
|
|
|
+ return mime;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fall back to detected type
|
|
|
+ if (detected_type != null) {
|
|
|
+ // Convert GLib content type to MIME type if needed
|
|
|
+ if (!detected_type.contains("/")) {
|
|
|
+ var mime_type = ContentType.get_mime_type(detected_type);
|
|
|
+ if (mime_type != null) {
|
|
|
+ return mime_type;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return detected_type;
|
|
|
+ }
|
|
|
+
|
|
|
+ return "application/octet-stream";
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string? get_mime_type_for_extension(string ext) {
|
|
|
+ switch (ext) {
|
|
|
+ // Text
|
|
|
+ case "html":
|
|
|
+ case "htm":
|
|
|
+ return "text/html";
|
|
|
+ case "css":
|
|
|
+ return "text/css";
|
|
|
+ case "js":
|
|
|
+ return "application/javascript";
|
|
|
+ case "json":
|
|
|
+ return "application/json";
|
|
|
+ case "xml":
|
|
|
+ return "application/xml";
|
|
|
+ case "txt":
|
|
|
+ return "text/plain";
|
|
|
+ case "md":
|
|
|
+ return "text/markdown";
|
|
|
+ case "csv":
|
|
|
+ return "text/csv";
|
|
|
+ case "svg":
|
|
|
+ return "image/svg+xml";
|
|
|
+
|
|
|
+ // Images
|
|
|
+ case "png":
|
|
|
+ return "image/png";
|
|
|
+ case "jpg":
|
|
|
+ case "jpeg":
|
|
|
+ return "image/jpeg";
|
|
|
+ case "gif":
|
|
|
+ return "image/gif";
|
|
|
+ case "ico":
|
|
|
+ return "image/x-icon";
|
|
|
+ case "webp":
|
|
|
+ return "image/webp";
|
|
|
+ case "bmp":
|
|
|
+ return "image/bmp";
|
|
|
+ case "tiff":
|
|
|
+ case "tif":
|
|
|
+ return "image/tiff";
|
|
|
+
|
|
|
+ // Audio
|
|
|
+ case "mp3":
|
|
|
+ return "audio/mpeg";
|
|
|
+ case "wav":
|
|
|
+ return "audio/wav";
|
|
|
+ case "ogg":
|
|
|
+ return "audio/ogg";
|
|
|
+ case "flac":
|
|
|
+ return "audio/flac";
|
|
|
+ case "m4a":
|
|
|
+ return "audio/mp4";
|
|
|
+
|
|
|
+ // Video
|
|
|
+ case "mp4":
|
|
|
+ return "video/mp4";
|
|
|
+ case "webm":
|
|
|
+ return "video/webm";
|
|
|
+ case "avi":
|
|
|
+ return "video/x-msvideo";
|
|
|
+ case "mov":
|
|
|
+ return "video/quicktime";
|
|
|
+ case "mkv":
|
|
|
+ return "video/x-matroska";
|
|
|
+
|
|
|
+ // Fonts
|
|
|
+ case "woff":
|
|
|
+ return "font/woff";
|
|
|
+ case "woff2":
|
|
|
+ return "font/woff2";
|
|
|
+ case "ttf":
|
|
|
+ return "font/ttf";
|
|
|
+ case "otf":
|
|
|
+ return "font/otf";
|
|
|
+ case "eot":
|
|
|
+ return "application/vnd.ms-fontobject";
|
|
|
+
|
|
|
+ // Documents
|
|
|
+ case "pdf":
|
|
|
+ return "application/pdf";
|
|
|
+ case "doc":
|
|
|
+ return "application/msword";
|
|
|
+ case "docx":
|
|
|
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
|
+ case "xls":
|
|
|
+ return "application/vnd.ms-excel";
|
|
|
+ case "xlsx":
|
|
|
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
|
+ case "ppt":
|
|
|
+ return "application/vnd.ms-powerpoint";
|
|
|
+ case "pptx":
|
|
|
+ return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
|
+
|
|
|
+ // Archives
|
|
|
+ case "zip":
|
|
|
+ return "application/zip";
|
|
|
+ case "gz":
|
|
|
+ return "application/gzip";
|
|
|
+ case "tar":
|
|
|
+ return "application/x-tar";
|
|
|
+ case "rar":
|
|
|
+ return "application/vnd.rar";
|
|
|
+ case "7z":
|
|
|
+ return "application/x-7z-compressed";
|
|
|
+
|
|
|
+ // Other
|
|
|
+ case "wasm":
|
|
|
+ return "application/wasm";
|
|
|
+ case "bin":
|
|
|
+ return "application/octet-stream";
|
|
|
+
|
|
|
+ default:
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // private static bool should_skip_compression(string mime_type) {
|
|
|
+ // // Don't compress already-compressed formats
|
|
|
+ // string type = mime_type.down();
|
|
|
+ // return type.has_prefix("image/") && !type.contains("svg") ||
|
|
|
+ // type.has_prefix("video/") ||
|
|
|
+ // type.has_prefix("audio/") ||
|
|
|
+ // type.contains("zip") ||
|
|
|
+ // type.contains("gzip") ||
|
|
|
+ // type.contains("x-rar") ||
|
|
|
+ // type.contains("x-7z") ||
|
|
|
+ // type.contains("pdf") ||
|
|
|
+ // type.contains("wasm");
|
|
|
+ // }
|
|
|
+ }
|
|
|
+
|
|
|
+ private enum MatchType {
|
|
|
+ EXACT, // No wildcard - exact file match
|
|
|
+ SHALLOW, // /* - one level deep
|
|
|
+ DEEP // /** - recursive
|
|
|
+ }
|
|
|
+
|
|
|
+ public errordomain FilesystemResourceError {
|
|
|
+ PATH_NOT_FOUND,
|
|
|
+ INVALID_ROUTE
|
|
|
+ }
|
|
|
+
|
|
|
+}
|