1 module irc.url; 2 3 import std.array; 4 import std.conv : to, ConvException; 5 import std.regex; 6 import std..string : icmp, indexOf; 7 8 import irc.protocol : channelPrefixes; 9 10 /// Result of the $(MREF parse) and $(MREF tryParse) functions, 11 /// containing the parsed connection information. 12 struct ConnectionInfo 13 { 14 /// Server address. 15 string address; 16 17 /// Explicitly specified server port, or $(D 0) when unspecified. 18 ushort explicitPort; 19 20 /** 21 * Server port. 22 * 23 * Evaluates to $(MREF ConnectionInfo.explicitPort) when an explicit 24 * port was specified, and $(MREF ConnectionInfo.defaultPort) otherwise. 25 */ 26 ushort port() @property @safe pure nothrow 27 { 28 return explicitPort == 0? defaultPort : explicitPort; 29 } 30 31 /// Security protocol. Is $(D true) for TLS/SSL, 32 /// and $(D false) for no security. 33 bool secure; 34 35 /// Channels to join immediately after a successful connect. Can be empty. 36 string[] channels; 37 38 /// Key/passphrase to use when joining channels. 39 /// Is $(D null) when unspecified. 40 string channelKey; 41 42 /// Default port for the specified security protocol. 43 /// $(D 6697) for TLS/SSL, and $(D 6667) otherwise. 44 ushort defaultPort() @property @safe pure nothrow 45 { 46 return secure? 6697 : 6667; 47 } 48 } 49 50 /// 51 class IrcUrlException : Exception 52 { 53 /// Same as $(MREF ParseError.location). 54 size_t location; 55 56 this(string msg, size_t location, string file = __FILE__, size_t line = __LINE__, Throwable next = null) @safe pure nothrow 57 { 58 this.location = location; 59 super(msg, file, line, next); 60 } 61 } 62 63 /** 64 * Parse IRC URLs (also known as "chat links"). 65 * 66 * Channels without a valid prefix are automatically 67 * prefixed with '#'. 68 */ 69 // TODO: describe supported URL format in detail 70 ConnectionInfo parse(string url) @safe 71 { 72 ConnectionInfo info; 73 74 if(auto error = url.tryParse(info)) 75 throw new IrcUrlException(error.message, error.location); 76 77 return info; 78 } 79 80 /// 81 unittest 82 { 83 ConnectionInfo info; 84 85 info = parse("ircs://irc.example.com:6697/foo,bar"); 86 87 assert(info.address == "irc.example.com"); 88 assert(info.explicitPort == 6697); 89 assert(info.port == 6697); 90 assert(info.secure); 91 assert(info.channels == ["#foo", "#bar"]); 92 93 info = parse("irc://irc.example.org/foo?pass"); 94 95 assert(info.address == "irc.example.org"); 96 assert(info.explicitPort == 0); 97 assert(info.port == 6667); // No explicit port, so it falls back to the default IRC port 98 assert(!info.secure); 99 assert(info.channels == ["#foo"]); 100 assert(info.channelKey == "pass"); 101 } 102 103 /// 104 struct ParseError 105 { 106 /// Error message. 107 string message; 108 109 /// Location in input (zero-based column) the error occured. 110 /// Ranges from $(D 0 .. $ - 1), where the $(D $) symbol is the length of the input. 111 size_t location = 0; 112 113 private bool wasError = true; 114 115 /// Boolean whether or not an error occured. If an error did not occur, 116 /// $(D message) and $(D location) will not have meaningful values. 117 bool opCast(T)() @safe pure if(is(T == bool)) 118 { 119 return wasError; 120 } 121 122 /// 123 @safe pure unittest 124 { 125 auto error = ParseError("error occured!", 0); 126 assert(error); // conversion to $(D bool) 127 } 128 } 129 130 /** 131 * Same as $(MREF parse), but returning an error message instead of throwing. 132 * Useful for high-volume parsing. 133 */ 134 ParseError tryParse(string url, out ConnectionInfo info) @trusted /+ @safe nothrow +/ 135 { 136 static urlPattern = 137 ctRegex!( 138 `^([^:]+)://` ~ // Protocol 139 `([^:/]+)(:\+?[^/]+)?` ~ // Address and optional port 140 `/?([^\?]+)?` ~ // Optional channel list 141 `\??(.*)$`, // Optional channel key 142 "ix" 143 ); 144 145 typeof(url.match(urlPattern)) m; 146 147 try m = url.match(urlPattern); 148 catch(Exception ex) 149 { 150 return ParseError(ex.msg); 151 } 152 153 if(!m) 154 return ParseError("input is not a URL"); 155 156 auto captures = m.captures; 157 captures.popFront(); // skip whole match 158 159 // Handle protocol 160 auto protocol = captures.front; 161 captures.popFront(); 162 163 if(protocol.icmp("irc") != 0 && protocol.icmp("ircs") != 0) 164 return ParseError(`connection protocol must be "irc" or "ircs", not ` ~ protocol); 165 166 info.secure = protocol.icmp("ircs") == 0; 167 168 // Handle address 169 info.address = captures.front; 170 captures.popFront(); 171 172 // Handle port 173 auto strPort = captures.front; 174 175 auto pre = captures.pre; 176 auto post = captures.post; 177 auto hit = captures.hit; 178 179 if(strPort.length > 1) 180 { 181 strPort.popFront; // Skip colon 182 183 if(strPort.front == '+') 184 { 185 info.secure = true; 186 strPort.popFront; 187 } 188 189 try info.explicitPort = to!ushort(strPort); 190 catch(ConvException e) 191 return ParseError("Error parsing port: " ~ e.msg, url.indexOf(strPort)); // TODO: shouldn't have to search 192 } 193 194 captures.popFront(); 195 196 // Handle channels 197 auto tail = captures.front; 198 199 if(!tail.empty) 200 { 201 info.channels = tail.split(","); 202 203 foreach(ref channel; info.channels) 204 { 205 switch(channel[0]) 206 { 207 foreach(prefix; channelPrefixes) 208 case prefix: 209 break; 210 211 default: 212 channel = '#' ~ channel; 213 } 214 } 215 } 216 217 captures.popFront(); 218 219 // Handle channel key 220 auto key = captures.front; 221 222 if(!key.empty) 223 info.channelKey = key; 224 225 return ParseError(null, 0, false); 226 } 227 228 /// Parse list of URLs and write any errors to $(D stderr) 229 /// with column information. 230 unittest 231 { 232 import std.stdio : stderr; 233 234 auto urls = ["irc://example.com", "ircs://example.org/foo?pass"]; 235 236 foreach(url; urls) 237 { 238 ConnectionInfo info; 239 240 if(auto error = url.tryParse(info)) 241 { 242 stderr.writefln("Error parsing URL:\n%s\n%*s\n%s", url, error.location + 1, "^", error.message); 243 continue; 244 } 245 246 // Use `info` 247 } 248 } 249 250 unittest 251 { 252 import std.stdio : writeln; 253 254 static struct Test 255 { 256 string url; 257 ConnectionInfo expectedResult; 258 } 259 260 auto tests = [ 261 Test("irc://example.com", 262 ConnectionInfo("example.com", 0, false) 263 ), 264 Test("ircs://example.com", 265 ConnectionInfo("example.com", 0, true) 266 ), 267 Test("irc://example.org:6667", 268 ConnectionInfo("example.org", 6667, false) 269 ), 270 Test("irc://example.org:+6697", 271 ConnectionInfo("example.org", 6697, true) 272 ), 273 Test("iRc://example.info/example", 274 ConnectionInfo("example.info", 0, false, ["#example"]) 275 ), 276 Test("IRCS://example.info/example?passphrase", 277 ConnectionInfo("example.info", 0, true, ["#example"], "passphrase") 278 ), 279 Test("irc://test/example,test", 280 ConnectionInfo("test", 0, false, ["#example", "#test"]) 281 ), 282 Test("ircs://test/example,test,foo?pass", 283 ConnectionInfo("test", 0, true, ["#example", "#test", "#foo"], "pass") 284 ), 285 Test("ircs://example.com:+6697/foo,bar,baz?pass", 286 ConnectionInfo("example.com", 6697, true, ["#foo", "#bar", "#baz"], "pass") 287 ) 288 ]; 289 290 foreach(i, ref test; tests) 291 { 292 immutable msg = "test #" ~ to!string(i + 1); 293 294 ConnectionInfo result; 295 auto error = tryParse(test.url, result); 296 297 scope(failure) debug writeln(error); 298 299 assert(!error); 300 301 scope(failure) debug writeln(result); 302 303 assert(result.address == test.expectedResult.address, msg); 304 assert(result.port == test.expectedResult.port, msg); 305 assert(result.secure == test.expectedResult.secure, msg); 306 assert(result.channels == test.expectedResult.channels, msg); 307 assert(result.channelKey == test.expectedResult.channelKey, msg); 308 } 309 310 // TODO: test error paths 311 }