aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Package.resolved50
-rw-r--r--Package.swift31
-rw-r--r--README.md41
-rw-r--r--Sources/main.swift505
-rw-r--r--example-gopherdata/example/example.txt1
-rw-r--r--example-gopherdata/gophermap27
-rw-r--r--example-gopherdata/hello_world.txt1
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