diff options
-rw-r--r-- | Package.resolved | 50 | ||||
-rw-r--r-- | Package.swift | 31 | ||||
-rw-r--r-- | README.md | 41 | ||||
-rw-r--r-- | Sources/main.swift | 505 | ||||
-rw-r--r-- | example-gopherdata/example/example.txt | 1 | ||||
-rw-r--r-- | example-gopherdata/gophermap | 27 | ||||
-rw-r--r-- | example-gopherdata/hello_world.txt | 1 |
7 files changed, 656 insertions, 0 deletions
diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..d51c750 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,50 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", + "version" : "1.2.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307", + "version" : "1.0.5" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", + "version" : "1.5.3" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio", + "state" : { + "revision" : "702cd7c56d5d44eeba73fdf83918339b26dc855c", + "version" : "2.62.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c387f16 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "swift-gopher", + dependencies: [ + .package( + url: "https://github.com/apple/swift-nio", + from: "2.0.0" + ), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .executableTarget( + name: "swift-gopher", + dependencies: [ + .product( + name: "NIO", + package: "swift-nio" + ), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + ] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..5354ef4 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +``` + + ad88888ba 88 ad88 + d8" "8b "" d8" ,d + Y8, 88 88 + `Y8aaaaa, 8b db d8 88 MM88MMM MM88MMM + `"""""8b, `8b d88b d8' 88 88 88 + `8b `8b d8'`8b d8' 88 88 88 + Y8a a8P `8bd8' `8bd8' 88 88 88, + "Y88888P" YP YP 88 88 "Y888 + + + + ,ad8888ba, 88 + d8"' `"8b 88 + d8' 88 + 88 ,adPPYba, 8b,dPPYba, 88,dPPYba, ,adPPYba, 8b,dPPYba, + 88 88888 a8" "8a 88P' "8a 88P' "8a a8P_____88 88P' "Y8 + Y8, 88 8b d8 88 d8 88 88 8PP""""""" 88 + Y8a. .a88 "8a, ,a8" 88b, ,a8" 88 88 "8b, ,aa 88 + `"Y88888P" `"YbbdP"' 88`YbbdP"' 88 88 `"Ybbd8"' 88 + 88 + 88 +``` + +# Swift-Gopher + +## Get Started +``` +git clone https://github.com/navanchauhan +cd swift-gopher +swift build && swift run swift-gopher +``` + +Then, you can either use lynx or curl (or other Gopher clients) to connect to the server. + +``` +lynx gopher://localhost:8080 +# Or, +curl gopher://localhost:8080 +```
\ No newline at end of file diff --git a/Sources/main.swift b/Sources/main.swift new file mode 100644 index 0000000..7776780 --- /dev/null +++ b/Sources/main.swift @@ -0,0 +1,505 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import ArgumentParser +import Foundation +import Logging +import NIO + +@main +struct swiftGopher: ParsableCommand { + @Option var gopherHostName: String = "localhost" + @Option var port: Int = 8080 + @Option var gopherDataDir: String = "./example-gopherdata" + @Option var host: String = "0.0.0.0" + + public mutating func run() throws { + let eventLoopGroup = MultiThreadedEventLoopGroup( + numberOfThreads: System.coreCount + ) + + defer { + try! eventLoopGroup.syncShutdownGracefully() + } + + let localGopherDataDir = gopherDataDir + let localGopherHostName = gopherHostName + let localPort = port + + let logger = Logger(label: "com.navanchauhan.gopher.server") + + let serverBootstrap = ServerBootstrap( + group: eventLoopGroup + ) + .serverChannelOption( + ChannelOptions.backlog, + value: 256 + ) + .serverChannelOption( + ChannelOptions.socketOption( + .so_reuseaddr + ), + value: 1 + ) + .childChannelInitializer { channel in + channel.pipeline.addHandlers([ + BackPressureHandler(), + GopherHandler( + logger: logger, + gopherdata_dir: localGopherDataDir, + gopherdata_host: localGopherHostName, + gopherdata_port: localPort + ), + ]) + } + .childChannelOption( + ChannelOptions.socketOption( + .so_reuseaddr + ), + value: 1 + ) + .childChannelOption( + ChannelOptions.maxMessagesPerRead, + value: 16 + ) + .childChannelOption( + ChannelOptions.recvAllocator, + value: AdaptiveRecvByteBufferAllocator() + ) + + let defaultHost = host + let defaultPort = port + + let channel = try serverBootstrap.bind( + host: defaultHost, + port: defaultPort + ).wait() + + logger.info("Server started and listening on \(channel.localAddress!)") + try channel.closeFuture.wait() + logger.info("Server closed") + } +} + +enum ResponseType { + case string(String) + case data(Data) +} + +enum gopherFileType { + case text + case directory + case nameserver + case error + case binhex + case bindos + case uuencoded + case indexSearch + case telnet + case binary + case redundantServer + case tn3270Session + case gif + case image + case bitmap + case movie + case sound + case doc + case html + case message + case png + case rtf + case wavfile + case pdf + case xml +} + +func getFileType(fileExtension: String) -> gopherFileType { + switch fileExtension { + case "txt": + return .text + case "md": + return .text + case "html": + return .html + case "pdf": + return .pdf + case "png": + return .png + case "gif": + return .gif + case "jpg": + return .image + case "jpeg": + return .image + case "mp3": + return .sound + case "wav": + return .wavfile + case "mp4": + return .movie + case "mov": + return .movie + case "avi": + return .movie + case "rtf": + return .rtf + case "xml": + return .xml + default: + return .binary + } +} + +func fileTypeToGopherItem(fileType: gopherFileType) -> String { + switch fileType { + case .text: + return "0" + case .directory: + return "1" + case .nameserver: + return "2" + case .error: + return "3" + case .binhex: + return "4" + case .bindos: + return "5" + case .uuencoded: + return "6" + case .indexSearch: + return "7" + case .telnet: + return "8" + case .binary: + return "9" + case .redundantServer: + return "+" + case .tn3270Session: + return "T" + case .gif: + return "g" + case .image: + return "I" + case .bitmap: + return "b" + case .movie: + return "M" + case .sound: + return "s" + case .doc: + return "d" + case .html: + return "h" + case .message: + return "i" + case .png: + return "p" + case .rtf: + return "t" + case .wavfile: + return "w" + case .pdf: + return "P" + case .xml: + return "x" + } +} + +final class GopherHandler: ChannelInboundHandler { + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + + let gopherdata_dir: String + let gopherdata_host: String + let gopherdata_port: Int + let logger: Logger + + init( + logger: Logger, + gopherdata_dir: String = "./example-gopherdata", gopherdata_host: String = "localhost", + gopherdata_port: Int = 70 + ) { + self.gopherdata_dir = gopherdata_dir + self.gopherdata_host = gopherdata_host + self.gopherdata_port = gopherdata_port + self.logger = logger + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let input = self.unwrapInboundIn(data) + + guard let requestString = input.getString(at: 0, length: input.readableBytes) else { + return + } + + if let remoteAddress = context.remoteAddress { + logger.info("Received request from \(remoteAddress) for \(requestString)") + } else { + logger.warning("Unable to retrieve remote address") + } + + + let response = processGopherRequest(requestString) + + var buffer: ByteBuffer + + switch response { + case .string(let string): + buffer = context.channel.allocator.buffer(string: string) + case .data(let data): + buffer = context.channel.allocator.buffer(bytes: data) + } + + context.writeAndFlush(self.wrapOutboundOut(buffer)).whenComplete { _ in + context.close(mode: .all, promise: nil) + } + } + + func requestHandler(path: URL) -> ResponseType { + // Check if path is a directory or a file + let fm = FileManager.default + var isDir: ObjCBool = false + + if fm.fileExists(atPath: path.path, isDirectory: &isDir) { + if isDir.boolValue { + return .string(prepareGopherMenu(path: path)) + } else { + // Check if file is plain text or binary + let fileExtension = path.pathExtension + let fileType = getFileType(fileExtension: fileExtension) + + if fileType == .text || fileType == .html { + do { + let fileContents = try String(contentsOfFile: path.path, encoding: .utf8) + + return .string(fileContents) + } catch { + logger.error("Error reading file: \(path.path)") + return .string("3Error reading file...\r\n") + } + } else { + // Handle binary file + do { + let fileContents = try Data(contentsOf: path) + return .data(fileContents) + } catch { + logger.error("Error reading file: \(path.path)") + return .string("3Error reading file...\r\n") + } + } + + } + } else { + logger.error("Error reading file: \(path.path)") + return .string("3Error reading file...\r\n") + } + + } + + func preparePath(path: String = "/") -> URL { + var sanitizedPath = sanitizeSelectorPath(path: path) + let base_dir = URL(fileURLWithPath: gopherdata_dir) + if base_dir.path.hasSuffix("/") && sanitizedPath.hasPrefix("/") { + sanitizedPath = String(sanitizedPath.dropFirst()) + } + let full_path = base_dir.appendingPathComponent(sanitizedPath) + return full_path + } + + func prepareGopherMenu(path: URL = URL(string: "/")!) -> String { + var gopherResponse: [String] = [] + + let fm = FileManager.default + let absolute_base_path = URL(fileURLWithPath: gopherdata_dir) + let relative_path = path.path.replacingOccurrences(of: absolute_base_path.path, with: "") + + do { + let gophermap_path = path.appendingPathComponent("gophermap") + if fm.fileExists(atPath: gophermap_path.path) { + let gophermap_contents = try String(contentsOfFile: gophermap_path.path, encoding: .utf8) + let gophermap_lines = gophermap_contents.components(separatedBy: "\n") + for originalLine in gophermap_lines { + // Only keep first 80 characters + var line = String(originalLine)//.prefix(80) + if "0123456789+gIT:;<dhprsPXi".contains(line.prefix(1)) && line.count > 1 { + if line.hasSuffix("\n") { + line = String(line.dropLast()) + } + if line.prefix(1) == "i" { + gopherResponse.append("\(line)\r\n") + continue + } + + let regex = try! NSRegularExpression(pattern: "\\t+| {2,}") + let nsString = line as NSString + let range = NSRange(location: 0, length: nsString.length) + let matches = regex.matches(in: String(line), range: range) + + var lastRangeEnd = 0 + var components = [String]() + + for match in matches { + let range = NSRange( + location: lastRangeEnd, length: match.range.location - lastRangeEnd) + components.append(nsString.substring(with: range)) + lastRangeEnd = match.range.location + match.range.length + } + + if lastRangeEnd < nsString.length { + components.append(nsString.substring(from: lastRangeEnd)) + } + + if components.count < 3 { + continue + } + + let item_name = components[0] + let item_path = components[1] + let item_host = components[2] + let item_port = components.count > 3 ? components[3] : "70" + + let item_line = "\(item_name)\t\(item_path)\t\(item_host)\t\(item_port)\r\n" + gopherResponse.append(item_line) + } else { + line = line.replacingOccurrences(of: "\n", with: "") + gopherResponse.append("i\(line)\r\n") + } + } + } else { + let items = try fm.contentsOfDirectory(at: path, includingPropertiesForKeys: nil) + + for item in items { + let item_name = item.lastPathComponent + var item_type = "" + if try item.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false { + item_type = "1" + } else { + let fileType = getFileType(fileExtension: item.pathExtension) + item_type = fileTypeToGopherItem(fileType: fileType) + } + let item_path = "\(relative_path)\(relative_path.hasSuffix("/") ? "" : "/")\(item_name)" + .replacingOccurrences(of: "//", with: "/") + let item_host = gopherdata_host + let item_port = gopherdata_port + let item_line = "\(item_type)\(item_name)\t\(item_path)\t\(item_host)\t\(item_port)\r\n" + gopherResponse.append(item_line) + } + } + } catch { + logger.error("Error reading directory: \(path.path)") + gopherResponse.append("3Error reading directory...\r\n") + } + + // Append Search + let search_line = "7Search Server\t/swiftSearch\t\(gopherdata_host)\t\(gopherdata_port)\r\n" + gopherResponse.append(search_line) + + return gopherResponse.joined(separator: "") + } + + func performSearch(query: String) -> String { + // Really basic search implementation + + var search_results = [String: String]() + + let fm = FileManager.default + + let base_dir = URL(fileURLWithPath: gopherdata_dir) + + let enumerator = fm.enumerator(at: base_dir, includingPropertiesForKeys: nil) + + while let file = enumerator?.nextObject() as? URL { + let file_name = file.lastPathComponent + let file_path = file.path + let file_type = + try? file.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false ? "1" : "0" + + if file_type == "0" { + // Check if file name or contents match the query + let file_contents = try? String(contentsOfFile: file_path, encoding: .utf8) + if file_name.lowercased().contains(query) + || (file_contents?.lowercased().contains(query) ?? false) + { + search_results[file_name] = file_path + } + } else { + // Check if directory name matches the query + if file_name.lowercased().contains(query) { + search_results[file_name] = file_path + } + } + } + + // Prepare Gopher menu with search results + var gopherResponse: [String] = [] + + for (_, file_path) in search_results { + let item_type = + try? URL(fileURLWithPath: file_path).resourceValues(forKeys: [.isDirectoryKey]).isDirectory + ?? false ? "1" : "0" + let item_host = gopherdata_host + let item_port = gopherdata_port + let item_path = file_path.replacingOccurrences(of: base_dir.path, with: "") + let item_line = + "\(item_type ?? "0")\(item_path)\t\(item_path)\t\(item_host)\t\(item_port)\r\n" + gopherResponse.append(item_line) + } + + if gopherResponse.count == 0 { + gopherResponse.append("iNo results found for the query \(query)\r\n") + } + + return gopherResponse.joined(separator: "") + + } + + func sanitizeSelectorPath(path: String) -> String { + // Replace \r\n with empty string + var sanitizedRequest = path.replacingOccurrences(of: "\r\n", with: "") + + // Basic escape against directory traversal + while sanitizedRequest.contains("..") { + sanitizedRequest = sanitizedRequest.replacingOccurrences(of: "..", with: "") + } + + while sanitizedRequest.contains("//") { + sanitizedRequest = sanitizedRequest.replacingOccurrences(of: "//", with: "/") + } + + return sanitizedRequest + } + + func channelReadComplete(context: ChannelHandlerContext) { + context.flush() + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + print("Error: \(error)") + logger.error("Error: \(error)") + context.close(promise: nil) + } + + private func processGopherRequest(_ request: String) -> ResponseType { + // Implement your logic to handle the Gopher request and return a response + // For example, you can retrieve a document based on the request string + // Return the document or an appropriate response as a string + + // Example response (you should replace this with actual logic) + if request == "\r\n" { + return .string(prepareGopherMenu(path: preparePath())) + } else if !request.contains("\t") { + //TODO: Potential Bug in Gopher implementation? curl gopher://localhost:8080/new_folder/ does not work but curl gopher://localhost:8080//new_folder/ works (tested with gopher://gopher.meulie.net//EFFector/ as well) + return requestHandler(path: preparePath(path: request)) + } else if request.contains("\t") { + var searchQuery = request.components(separatedBy: "\t")[1] + searchQuery = searchQuery.replacingOccurrences(of: "\r\n", with: "") + return .string(performSearch(query: searchQuery.lowercased())) + } + + return + .string( + "Hello from Gopher Server! You requested: \(request), but this request could not be processed.\r\n" + ) + } +} diff --git a/example-gopherdata/example/example.txt b/example-gopherdata/example/example.txt new file mode 100644 index 0000000..9e4e0d6 --- /dev/null +++ b/example-gopherdata/example/example.txt @@ -0,0 +1 @@ +Try searching for the word "example" using the search function
\ No newline at end of file diff --git a/example-gopherdata/gophermap b/example-gopherdata/gophermap new file mode 100644 index 0000000..3baab94 --- /dev/null +++ b/example-gopherdata/gophermap @@ -0,0 +1,27 @@ + + ad88888ba 88 ad88 + d8" "8b "" d8" ,d + Y8, 88 88 + `Y8aaaaa, 8b db d8 88 MM88MMM MM88MMM + `"""""8b, `8b d88b d8' 88 88 88 + `8b `8b d8'`8b d8' 88 88 88 + Y8a a8P `8bd8' `8bd8' 88 88 88, + "Y88888P" YP YP 88 88 "Y888 + + + + ,ad8888ba, 88 + d8"' `"8b 88 + d8' 88 + 88 ,adPPYba, 8b,dPPYba, 88,dPPYba, ,adPPYba, 8b,dPPYba, + 88 88888 a8" "8a 88P' "8a 88P' "8a a8P_____88 88P' "Y8 + Y8, 88 8b d8 88 d8 88 88 8PP""""""" 88 + Y8a. .a88 "8a, ,a8" 88b, ,a8" 88 88 "8b, ,aa 88 + `"Y88888P" `"YbbdP"' 88`YbbdP"' 88 88 `"Ybbd8"' 88 + 88 + 88 +i Welcome to your Gopherspace! +1example /example localhost 8080 +0hello_world.txt /hello_world.txt localhost 8080 +1Other the gopher servers in the world (via Floodgap) /world gopher.floodgap.com 70 + diff --git a/example-gopherdata/hello_world.txt b/example-gopherdata/hello_world.txt new file mode 100644 index 0000000..be5b84b --- /dev/null +++ b/example-gopherdata/hello_world.txt @@ -0,0 +1 @@ +Hi there! This file is being served by your Gopher Server
\ No newline at end of file |