aboutsummaryrefslogtreecommitdiff
path: root/Sources/gopherHandler.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/gopherHandler.swift')
-rw-r--r--Sources/gopherHandler.swift350
1 files changed, 350 insertions, 0 deletions
diff --git a/Sources/gopherHandler.swift b/Sources/gopherHandler.swift
new file mode 100644
index 0000000..2d369bf
--- /dev/null
+++ b/Sources/gopherHandler.swift
@@ -0,0 +1,350 @@
+import ArgumentParser
+import Foundation
+import Logging
+import NIO
+
+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
+ let enableSearch: Bool
+ let disableGophermap: Bool
+
+ init(
+ logger: Logger,
+ gopherdata_dir: String = "./example-gopherdata", gopherdata_host: String = "localhost",
+ gopherdata_port: Int = 70, enableSearch: Bool = false,
+ disableGophermap: Bool = false
+ ) {
+ self.gopherdata_dir = gopherdata_dir
+ self.gopherdata_host = gopherdata_host
+ self.gopherdata_port = gopherdata_port
+ self.logger = logger
+ self.enableSearch = enableSearch
+ self.disableGophermap = disableGophermap
+ }
+
+ 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.replacingOccurrences(of: "\r\n", with: "<GopherSequence>").replacingOccurrences(of: "\n", with: "<Linebreak>"))'")
+ } 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 {
+ logger.info("Handling request for '\(path.path)'")
+
+ // 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())
+ }
+
+ // Now check if there is still a prefix
+ if sanitizedPath.hasPrefix("/") {
+ sanitizedPath = String(sanitizedPath.dropFirst())
+ }
+
+ let full_path = base_dir.appendingPathComponent(sanitizedPath)
+ return full_path
+ }
+
+ func generateGopherItem(
+ item_name: String, item_path: URL, item_host: String? = nil, item_port: String? = nil
+ ) -> String {
+ let myItemHost = item_host ?? gopherdata_host
+ let myItemPort = item_port ?? String(gopherdata_port)
+ let base_path = URL(fileURLWithPath: gopherdata_dir)
+ var relative_path = item_path.path.replacingOccurrences(of: base_path.path, with: "")
+ if !relative_path.hasPrefix("/") {
+ relative_path = "/\(relative_path)"
+ }
+ return "\(item_name)\t\(relative_path)\t\(myItemHost)\t\(myItemPort)\r\n"
+ }
+
+ func generateGopherMap(path: URL) -> [String] {
+ var items: [String] = []
+
+ var basePath = URL(fileURLWithPath: gopherdata_dir).path
+ if basePath.hasSuffix("/") {
+ basePath = String(basePath.dropLast())
+ }
+
+ let fm = FileManager.default
+ do {
+ print("Reading directory: \(path.path)")
+ let itemsInDirectory = try fm.contentsOfDirectory(at: path, includingPropertiesForKeys: nil)
+ for item in itemsInDirectory {
+ let isDirectory = try item.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false
+ let name = item.lastPathComponent
+ if isDirectory {
+ items.append(generateGopherItem(item_name: "1\(name)", item_path: item))
+ } else {
+ let fileType = getFileType(fileExtension: item.pathExtension)
+ let gopherFileType = fileTypeToGopherItem(fileType: fileType)
+ items.append(generateGopherItem(item_name: "\(gopherFileType)\(name)", item_path: item))
+ }
+ }
+ } catch {
+ print("Error reading directory: \(path.path)")
+ }
+ return items
+ }
+
+ func prepareGopherMenu(path: URL = URL(string: "/")!) -> String {
+ var gopherResponse: [String] = []
+
+ let fm = FileManager.default
+
+ do {
+ let gophermap_path = path.appendingPathComponent("gophermap")
+ if fm.fileExists(atPath: gophermap_path.path) && !disableGophermap {
+ 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 {
+ print("No gophermap found for \(path.path)")
+ gopherResponse = generateGopherMap(path: path)
+ }
+ } catch {
+ logger.error("Error reading directory: \(path.path)")
+ gopherResponse.append("3Error reading directory...\r\n")
+ }
+
+ // Append Search
+ if enableSearch {
+ let search_line = "7Search Server\t/search\t\(gopherdata_host)\t\(gopherdata_port)\r\n"
+ gopherResponse.append(search_line)
+ }
+
+
+ return gopherResponse.joined(separator: "")
+ }
+
+ // TODO: Refactor
+ 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(_ originalRequest: String) -> ResponseType {
+ var request = originalRequest
+
+ // Fix for "Gopher" (iOS) client sending an extra \n
+ if request.hasSuffix("\n\n") {
+ request = String(request.dropLast())
+ }
+
+ if request == "\r\n" { // Empty request
+ return .string(prepareGopherMenu(path: preparePath()))
+ }
+
+ // Again, fix for the iOS client. Might as well make my own client
+ if request.hasSuffix("\n") {
+ request = String(request.dropLast())
+ }
+
+ if request.contains("\t") {
+ if enableSearch {
+ var searchQuery = request.components(separatedBy: "\t")[1]
+ searchQuery = searchQuery.replacingOccurrences(of: "\r\n", with: "")
+ return .string(performSearch(query: searchQuery.lowercased()))
+ } else {
+ return .string("3Search is disabled on this server.\r\n")
+ }
+ }
+
+ //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))
+ }
+}