Compare commits

...

25 Commits

Author SHA1 Message Date
yhirose d2c7b447d5 Fix #289: Fixed build problem with Visual C++ 2019-12-13 09:12:50 -05:00
yhirose 72b20c08da Better API names 2019-12-13 06:56:00 -05:00
yhirose afd6d5f9dc Removed compress parameter and added compress method on client 2019-12-12 23:09:59 -05:00
yhirose e5827ad16f Fixed build error 2019-12-12 23:09:34 -05:00
yhirose 5324b3d661 Improved multipart form data interface 2019-12-12 22:48:09 -05:00
yhirose 151ccba57e Code cleanup 2019-12-12 21:50:12 -05:00
yhirose 69a28d50f6 Fix #287 2019-12-12 12:50:45 -05:00
Yuji Hirose 048f31109f Updated README 2019-12-10 13:14:23 -05:00
Yuji Hirose d064fb7ff2 Fixed warning 2019-12-10 13:08:07 -05:00
Yuji Hirose 3c2736bb2a Fixed regex syntax error 2019-12-10 13:07:49 -05:00
Yuji Hirose fd4e1b4112 Fix #266 2019-12-10 12:10:14 -05:00
yhirose f6a2365ca5 Fix #282 2019-12-06 12:21:15 -05:00
yhirose df1ff7510b Made code more readable 2019-12-06 12:02:08 -05:00
yhirose 379905bd34 Merge branch 'whitespace-and-libcxx-compat' of https://github.com/matvore/cpp-httplib 2019-12-06 09:51:21 -05:00
yhirose 66719ae3d4 Merge pull request #283 from barryam3/noexcept
Remove use of exceptions.
2019-12-05 21:32:06 -05:00
Matthew DeVore bc9251ea49 Work around incompatibility in <regex> in libc++
libc++ (the implementation of the C++ standard library usually used by
Clang) throws an exception for the regex used by parse_headers before
this patch for certain strings. Work around this by simplifying the
regex and parsing the header lines "by hand" partially. I have repro'd
this problem with Xcode 11.1 which I believe uses libc++ version 8.

This may be a bug in libc++ as I can't see why the regex would result in
asymptotic run-time complexity for any strings. However, it may take a
while for libc++ to be fixed and for everyone to migrate to it, so it
makes sense to work around it in this codebase for now.
2019-12-05 17:14:16 -08:00
Matthew DeVore a9e942d755 Properly trim whitespace from headers
HTTP Whitespace and regex whitespace are not the same, so we can't use
\s in regexes when parsing HTTP headers. Instead, explicitly specify
what is considered whitespace in the regex.
2019-12-05 17:14:16 -08:00
Barry McNamara e1785d6723 Remove use of exceptions. 2019-12-05 15:56:55 -08:00
yhirose b9539b8921 Fixed build errors 2019-12-03 10:30:07 -05:00
yhirose 4c93b973ff Fixed typo in README 2019-12-02 09:50:52 -05:00
yhirose 033bc35723 Improve multipart content reader interface 2019-12-02 07:11:12 -05:00
yhirose d910bfc303 Merge pull request #279 from yhirose/multipart
Content receiver support for multipart content (Fix #241)
2019-12-01 22:12:29 -05:00
yhirose b69c0a1dcb Content receiver support for multipart content (Fix #241) 2019-12-01 22:04:26 -05:00
yhirose 5e37e38398 Updated README 2019-11-29 23:33:19 -05:00
yhirose 295e4d58aa Fix #276 2019-11-29 17:07:51 -05:00
11 changed files with 1069 additions and 377 deletions
+60 -10
View File
@@ -50,7 +50,16 @@ svr.listen_after_bind();
### Static File Server
```cpp
svr.set_base_dir("./www");
svr.set_base_dir("./www"); // This is same as `svr.set_base_dir("./www", "/")`;
```
```cpp
svr.set_base_dir("./www", "/public");
```
```cpp
svr.set_base_dir("./www1", "/public"); // 1st order
svr.set_base_dir("./www2", "/public"); // 2nd order
```
### Logging
@@ -81,12 +90,12 @@ svr.Post("/multipart", [&](const auto& req, auto& res) {
const auto& file = req.get_file_value("name1");
// file.filename;
// file.content_type;
auto body = req.body.substr(file.offset, file.length);
// file.content;
});
```
### Stream content with Content provider
### Send content with Content provider
```cpp
const uint64_t DATA_CHUNK_SIZE = 4;
@@ -104,6 +113,33 @@ svr.Get("/stream", [&](const Request &req, Response &res) {
});
```
### Receive content with Content receiver
```cpp
svr.Post("/content_receiver",
[&](const Request &req, Response &res, const ContentReader &content_reader) {
if (req.is_multipart_form_data()) {
MultipartFormDataItems files;
content_reader(
[&](const MultipartFormData &file) {
files.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
files.back().content.append(data, data_length);
return true;
});
} else {
std::string body;
content_reader([&](const char *data, size_t data_length) {
body.append(data, data_length);
return true;
});
res.set_content(body, "text/plain");
}
});
```
### Chunked transfer encoding
```cpp
@@ -119,7 +155,7 @@ svr.Get("/chunked", [&](const Request& req, Response& res) {
});
```
### Default thread pool supporet
### Default thread pool support
Set thread count to 8:
@@ -287,16 +323,23 @@ std::shared_ptr<httplib::Response> res =
This feature was contributed by [underscorediscovery](https://github.com/yhirose/cpp-httplib/pull/23).
### Basic Authentication
### Authentication
NOTE: OpenSSL is required for Digest Authentication, since cpp-httplib uses message digest functions in OpenSSL.
```cpp
httplib::Client cli("httplib.org");
cli.set_auth("user", "pass");
auto res = cli.Get("/basic-auth/hello/world", {
httplib::make_basic_authentication_header("hello", "world")
});
// Basic
auto res = cli.Get("/basic-auth/user/pass");
// res->status should be 200
// res->body should be "{\n \"authenticated\": true, \n \"user\": \"hello\"\n}\n".
// res->body should be "{\n \"authenticated\": true, \n \"user\": \"user\"\n}\n".
// Digest
res = cli.Get("/digest-auth/auth/user/pass/SHA-256");
// res->status should be 200
// res->body should be "{\n \"authenticated\": true, \n \"user\": \"user\"\n}\n".
```
### Range
@@ -344,7 +387,7 @@ httplib::Client cli("yahoo.com");
auto res = cli.Get("/");
res->status; // 301
cli.follow_location(true);
cli.set_follow_location(true);
res = cli.Get("/");
res->status; // 200
```
@@ -380,6 +423,13 @@ The server applies gzip compression to the following MIME type contents:
* application/xml
* application/xhtml+xml
### Compress content on client
```c++
cli.set_compress(true);
res = cli.Post("/resource/foo", "...", "text/plain");
```
NOTE
----
+1 -1
View File
@@ -33,4 +33,4 @@ pem:
openssl req -new -key key.pem | openssl x509 -days 3650 -req -signkey key.pem > cert.pem
clean:
rm server client hello simplesvr upload redirect *.pem
rm server client hello simplesvr upload redirect benchmark *.pem
+2 -5
View File
@@ -27,7 +27,7 @@ string dump_headers(const Headers &headers) {
return s;
}
string dump_multipart_files(const MultipartFiles &files) {
string dump_multipart_files(const MultipartFormDataMap &files) {
string s;
char buf[BUFSIZ];
@@ -46,10 +46,7 @@ string dump_multipart_files(const MultipartFiles &files) {
snprintf(buf, sizeof(buf), "content type: %s\n", file.content_type.c_str());
s += buf;
snprintf(buf, sizeof(buf), "text offset: %lu\n", file.offset);
s += buf;
snprintf(buf, sizeof(buf), "text length: %lu\n", file.length);
snprintf(buf, sizeof(buf), "text length: %lu\n", file.content.size());
s += buf;
s += "----------------\n";
+2 -2
View File
@@ -37,10 +37,10 @@ int main(void) {
svr.Post("/post", [](const Request & req, Response &res) {
auto file = req.get_file_value("file");
cout << "file: " << file.offset << ":" << file.length << ":" << file.filename << endl;
cout << "file length: " << file.content.length() << ":" << file.filename << endl;
ofstream ofs(file.filename, ios::binary);
ofs << req.body.substr(file.offset, file.length);
ofs << file.content;
res.set_content("done", "text/plain");
});
+706 -326
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
import os
border = '// ----------------------------------------------------------------------------'
with open('httplib.h') as f:
lines = f.readlines()
inImplementation = False
os.makedirs('out', exist_ok=True)
with open('out/httplib.h', 'w') as fh:
with open('out/httplib.cc', 'w') as fc:
fc.write('#include "httplib.h"\n')
fc.write('namespace httplib {\n')
for line in lines:
isBorderLine = border in line
if isBorderLine:
inImplementation = not inImplementation
else:
if inImplementation:
fc.write(line.replace('inline ', ''))
pass
else:
fh.write(line)
pass
fc.write('} // namespace httplib\n')
+256 -33
View File
@@ -30,6 +30,14 @@ const std::string JSON_DATA = "{\"hello\":\"world\"}";
const string LARGE_DATA = string(1024 * 1024 * 100, '@'); // 100MB
MultipartFormData& get_file_value(MultipartFormDataItems &files, const char *key) {
auto it = std::find_if(files.begin(), files.end(), [&](const MultipartFormData &file) {
return file.name == key;
});
if (it != files.end()) { return *it; }
throw std::runtime_error("invalid mulitpart form data name error");
}
#ifdef _WIN32
TEST(StartupTest, WSAStartup) {
WSADATA wsaData;
@@ -463,8 +471,50 @@ TEST(BaseAuthTest, FromHTTPWatch) {
"{\n \"authenticated\": true, \n \"user\": \"hello\"\n}\n");
EXPECT_EQ(200, res->status);
}
{
cli.set_auth("hello", "world");
auto res = cli.Get("/basic-auth/hello/world");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(res->body,
"{\n \"authenticated\": true, \n \"user\": \"hello\"\n}\n");
EXPECT_EQ(200, res->status);
}
}
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
TEST(DigestAuthTest, FromHTTPWatch) {
auto host = "httpbin.org";
auto port = 443;
httplib::SSLClient cli(host, port);
{
auto res = cli.Get("/digest-auth/auth/hello/world");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(401, res->status);
}
{
std::vector<std::string> paths = {
"/digest-auth/auth/hello/world/MD5",
"/digest-auth/auth/hello/world/SHA-256",
"/digest-auth/auth/hello/world/SHA-512",
"/digest-auth/auth-init/hello/world/MD5",
"/digest-auth/auth-int/hello/world/MD5",
};
cli.set_auth("hello", "world");
for (auto path: paths) {
auto res = cli.Get(path.c_str());
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(res->body,
"{\n \"authenticated\": true, \n \"user\": \"hello\"\n}\n");
EXPECT_EQ(200, res->status);
}
}
}
#endif
TEST(AbsoluteRedirectTest, Redirect) {
auto host = "httpbin.org";
@@ -474,7 +524,7 @@ TEST(AbsoluteRedirectTest, Redirect) {
httplib::Client cli(host);
#endif
cli.follow_location(true);
cli.set_follow_location(true);
auto res = cli.Get("/absolute-redirect/3");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
@@ -489,7 +539,7 @@ TEST(RedirectTest, Redirect) {
httplib::Client cli(host);
#endif
cli.follow_location(true);
cli.set_follow_location(true);
auto res = cli.Get("/redirect/3");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
@@ -504,7 +554,7 @@ TEST(RelativeRedirectTest, Redirect) {
httplib::Client cli(host);
#endif
cli.follow_location(true);
cli.set_follow_location(true);
auto res = cli.Get("/relative-redirect/3");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
@@ -519,7 +569,7 @@ TEST(TooManyRedirectTest, Redirect) {
httplib::Client cli(host);
#endif
cli.follow_location(true);
cli.set_follow_location(true);
auto res = cli.Get("/redirect/21");
ASSERT_TRUE(res == nullptr);
}
@@ -532,7 +582,7 @@ TEST(YahooRedirectTest, Redirect) {
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(301, res->status);
cli.follow_location(true);
cli.set_follow_location(true);
res = cli.Get("/");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
@@ -540,7 +590,7 @@ TEST(YahooRedirectTest, Redirect) {
TEST(HttpsToHttpRedirectTest, Redirect) {
httplib::SSLClient cli("httpbin.org");
cli.follow_location(true);
cli.set_follow_location(true);
auto res =
cli.Get("/redirect-to?url=http%3A%2F%2Fwww.google.com&status_code=302");
ASSERT_TRUE(res != nullptr);
@@ -567,6 +617,7 @@ protected:
virtual void SetUp() {
svr_.set_base_dir("./www");
svr_.set_base_dir("./www2", "/mount");
svr_.Get("/hi",
[&](const Request & /*req*/, Response &res) {
@@ -675,29 +726,27 @@ protected:
{
const auto &file = req.get_file_value("text1");
EXPECT_EQ("", file.filename);
EXPECT_EQ("text default",
req.body.substr(file.offset, file.length));
EXPECT_EQ("text default", file.content);
}
{
const auto &file = req.get_file_value("text2");
EXPECT_EQ("", file.filename);
EXPECT_EQ("aωb", req.body.substr(file.offset, file.length));
EXPECT_EQ("aωb", file.content);
}
{
const auto &file = req.get_file_value("file1");
EXPECT_EQ("hello.txt", file.filename);
EXPECT_EQ("text/plain", file.content_type);
EXPECT_EQ("h\ne\n\nl\nl\no\n",
req.body.substr(file.offset, file.length));
EXPECT_EQ("h\ne\n\nl\nl\no\n", file.content);
}
{
const auto &file = req.get_file_value("file3");
EXPECT_EQ("", file.filename);
EXPECT_EQ("application/octet-stream", file.content_type);
EXPECT_EQ(0u, file.length);
EXPECT_EQ(0u, file.content.size());
}
})
.Post("/empty",
@@ -752,16 +801,56 @@ protected:
EXPECT_EQ("5", req.get_header_value("Content-Length"));
})
.Post("/content_receiver",
[&](const Request & /*req*/, Response &res,
const ContentReader &content_reader) {
std::string body;
content_reader([&](const char *data, size_t data_length) {
EXPECT_EQ(data_length, 7);
body.append(data, data_length);
return true;
});
EXPECT_EQ(body, "content");
res.set_content(body, "text/plain");
[&](const Request & req, Response &res, const ContentReader &content_reader) {
if (req.is_multipart_form_data()) {
MultipartFormDataItems files;
content_reader(
[&](const MultipartFormData &file) {
files.push_back(file);
return true;
},
[&](const char *data, size_t data_length) {
files.back().content.append(data, data_length);
return true;
});
EXPECT_EQ(5u, files.size());
{
const auto &file = get_file_value(files, "text1");
EXPECT_EQ("", file.filename);
EXPECT_EQ("text default", file.content);
}
{
const auto &file = get_file_value(files, "text2");
EXPECT_EQ("", file.filename);
EXPECT_EQ("aωb", file.content);
}
{
const auto &file = get_file_value(files, "file1");
EXPECT_EQ("hello.txt", file.filename);
EXPECT_EQ("text/plain", file.content_type);
EXPECT_EQ("h\ne\n\nl\nl\no\n", file.content);
}
{
const auto &file = get_file_value(files, "file3");
EXPECT_EQ("", file.filename);
EXPECT_EQ("application/octet-stream", file.content_type);
EXPECT_EQ(0u, file.content.size());
}
} else {
std::string body;
content_reader([&](const char *data, size_t data_length) {
EXPECT_EQ(data_length, 7);
body.append(data, data_length);
return true;
});
EXPECT_EQ(body, "content");
res.set_content(body, "text/plain");
}
})
.Put("/content_receiver",
[&](const Request & /*req*/, Response &res,
@@ -808,14 +897,13 @@ protected:
{
const auto &file = req.get_file_value("key1");
EXPECT_EQ("", file.filename);
EXPECT_EQ("test", req.body.substr(file.offset, file.length));
EXPECT_EQ("test", file.content);
}
{
const auto &file = req.get_file_value("key2");
EXPECT_EQ("", file.filename);
EXPECT_EQ("--abcdefg123",
req.body.substr(file.offset, file.length));
EXPECT_EQ("--abcdefg123", file.content);
}
})
#endif
@@ -1003,9 +1091,42 @@ TEST_F(ServerTest, GetMethodOutOfBaseDir2) {
EXPECT_EQ(404, res->status);
}
TEST_F(ServerTest, InvalidBaseDir) {
EXPECT_EQ(false, svr_.set_base_dir("invalid_dir"));
EXPECT_EQ(true, svr_.set_base_dir("."));
TEST_F(ServerTest, GetMethodDirMountTest) {
auto res = cli_.Get("/mount/dir/test.html");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("test.html", res->body);
}
TEST_F(ServerTest, GetMethodDirMountTestWithDoubleDots) {
auto res = cli_.Get("/mount/dir/../dir/test.html");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("test.html", res->body);
}
TEST_F(ServerTest, GetMethodInvalidMountPath) {
auto res = cli_.Get("/mount/dir/../test.html");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(404, res->status);
}
TEST_F(ServerTest, GetMethodOutOfBaseDirMount) {
auto res = cli_.Get("/mount/../www2/dir/test.html");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(404, res->status);
}
TEST_F(ServerTest, GetMethodOutOfBaseDirMount2) {
auto res = cli_.Get("/mount/dir/../../www2/dir/test.html");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(404, res->status);
}
TEST_F(ServerTest, InvalidBaseDirMount) {
EXPECT_EQ(false, svr_.set_base_dir("./www3", "invalid_mount_point"));
}
TEST_F(ServerTest, EmptyRequest) {
@@ -1279,7 +1400,7 @@ TEST_F(ServerTest, GetStreamedWithRangeMultipart) {
}
TEST_F(ServerTest, GetStreamedEndless) {
size_t offset = 0;
uint64_t offset = 0;
auto res = cli_.Get("/streamed-cancel",
[&](const char * /*data*/, uint64_t data_length) {
if (offset < 100) {
@@ -1414,12 +1535,13 @@ TEST_F(ServerTest, PutWithContentProvider) {
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
TEST_F(ServerTest, PutWithContentProviderWithGzip) {
cli_.set_compress(true);
auto res = cli_.Put(
"/put", 3,
[](size_t /*offset*/, size_t /*length*/, DataSink sink) {
sink("PUT", 3);
},
"text/plain", true);
"text/plain");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
@@ -1427,7 +1549,8 @@ TEST_F(ServerTest, PutWithContentProviderWithGzip) {
}
TEST_F(ServerTest, PutLargeFileWithGzip) {
auto res = cli_.Put("/put-large", LARGE_DATA, "text/plain", true);
cli_.set_compress(true);
auto res = cli_.Put("/put-large", LARGE_DATA, "text/plain");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
@@ -1484,8 +1607,24 @@ TEST_F(ServerTest, PostContentReceiver) {
ASSERT_EQ("content", res->body);
}
TEST_F(ServerTest, PostMulitpartFilsContentReceiver) {
MultipartFormDataItems items = {
{"text1", "text default", "", ""},
{"text2", "aωb", "", ""},
{"file1", "h\ne\n\nl\nl\no\n", "hello.txt", "text/plain"},
{"file2", "{\n \"world\", true\n}\n", "world.json", "application/json"},
{"file3", "", "", "application/octet-stream"},
};
auto res = cli_.Post("/content_receiver", items);
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
}
TEST_F(ServerTest, PostContentReceiverGzip) {
auto res = cli_.Post("/content_receiver", "content", "text/plain", true);
cli_.set_compress(true);
auto res = cli_.Post("/content_receiver", "content", "text/plain");
ASSERT_TRUE(res != nullptr);
ASSERT_EQ(200, res->status);
ASSERT_EQ("content", res->body);
@@ -1666,13 +1805,97 @@ TEST_F(ServerTest, MultipartFormDataGzip) {
{"key2", "--abcdefg123", "", ""},
};
auto res = cli_.Post("/gzipmultipart", items, true);
cli_.set_compress(true);
auto res = cli_.Post("/gzipmultipart", items);
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(200, res->status);
}
#endif
// Sends a raw request to a server listening at HOST:PORT.
static bool send_request(time_t read_timeout_sec, const std::string& req) {
auto client_sock =
detail::create_client_socket(HOST, PORT, /*timeout_sec=*/5);
if (client_sock == INVALID_SOCKET) { return false; }
return detail::process_and_close_socket(
true, client_sock, 1, read_timeout_sec, 0,
[&](Stream& strm, bool /*last_connection*/,
bool &/*connection_close*/) -> bool {
if (req.size() !=
static_cast<size_t>(strm.write(req.data(), req.size()))) {
return false;
}
char buf[512];
detail::stream_line_reader line_reader(strm, buf, sizeof(buf));
while (line_reader.getline()) {}
return true;
});
}
TEST(ServerRequestParsingTest, TrimWhitespaceFromHeaderValues) {
Server svr;
std::string header_value;
svr.Get("/validate-ws-in-headers",
[&](const Request &req, Response &res) {
header_value = req.get_header_value("foo");
res.set_content("ok", "text/plain");
});
thread t = thread([&] { svr.listen(HOST, PORT); });
while (!svr.is_running()) {
msleep(1);
}
// Only space and horizontal tab are whitespace. Make sure other whitespace-
// like characters are not treated the same - use vertical tab and escape.
const std::string req =
"GET /validate-ws-in-headers HTTP/1.1\r\n"
"foo: \t \v bar \e\t \r\n"
"Connection: close\r\n"
"\r\n";
ASSERT_TRUE(send_request(5, req));
svr.stop();
t.join();
EXPECT_EQ(header_value, "\v bar \e");
}
TEST(ServerRequestParsingTest, ReadHeadersRegexComplexity) {
Server svr;
svr.Get("/hi",
[&](const Request & /*req*/, Response &res) {
res.set_content("ok", "text/plain");
});
// Server read timeout must be longer than the client read timeout for the
// bug to reproduce, probably to force the server to process a request
// without a trailing blank line.
const time_t client_read_timeout_sec = 1;
svr.set_read_timeout(client_read_timeout_sec + 1, 0);
bool listen_thread_ok = false;
thread t = thread([&] { listen_thread_ok = svr.listen(HOST, PORT); });
while (!svr.is_running()) {
msleep(1);
}
// A certain header line causes an exception if the header property is parsed
// naively with a single regex. This occurs with libc++ but not libstdc++.
const std::string req =
"GET /hi HTTP/1.1\r\n"
" : "
" ";
ASSERT_TRUE(send_request(client_read_timeout_sec, req));
svr.stop();
t.join();
EXPECT_TRUE(listen_thread_ok);
}
class ServerTestWithAI_PASSIVE : public ::testing::Test {
protected:
ServerTestWithAI_PASSIVE()
+8
View File
@@ -0,0 +1,8 @@
<html>
<head>
</head>
<body>
<a href="/dir/test.html">Test</a>
<a href="/hi">hi</a>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
test.html
+8
View File
@@ -0,0 +1,8 @@
<html>
<head>
</head>
<body>
<a href="/dir/test.html">Test</a>
<a href="/hi">hi</a>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
test.html