diff options
author | MetroWind <chris.corsair@gmail.com> | 2025-09-07 09:42:33 -0700 |
---|---|---|
committer | MetroWind <chris.corsair@gmail.com> | 2025-09-07 09:42:33 -0700 |
commit | ea0d3220db995018335c48eb06b9794235ff436b (patch) | |
tree | 892564cdd4946c6ee9c1051bc31ff5c7bba6ddf1 /src |
Initial commit, mostly just copied from shrt.
Diffstat (limited to 'src')
-rw-r--r-- | src/app.cpp | 342 | ||||
-rw-r--r-- | src/app.hpp | 84 | ||||
-rw-r--r-- | src/app_test.cpp | 205 | ||||
-rw-r--r-- | src/config.cpp | 85 | ||||
-rw-r--r-- | src/config.hpp | 23 | ||||
-rw-r--r-- | src/data.cpp | 54 | ||||
-rw-r--r-- | src/data.hpp | 73 | ||||
-rw-r--r-- | src/data_mock.hpp | 24 | ||||
-rw-r--r-- | src/data_test.cpp | 17 | ||||
-rw-r--r-- | src/main.cpp | 29 |
10 files changed, 936 insertions, 0 deletions
diff --git a/src/app.cpp b/src/app.cpp new file mode 100644 index 0000000..7cfcf0d --- /dev/null +++ b/src/app.cpp | |||
@@ -0,0 +1,342 @@ | |||
1 | #include <format> | ||
2 | #include <stddef.h> | ||
3 | #include <stdint.h> | ||
4 | #include <chrono> | ||
5 | #include <expected> | ||
6 | #include <filesystem> | ||
7 | #include <iterator> | ||
8 | #include <string> | ||
9 | #include <string_view> | ||
10 | #include <unordered_map> | ||
11 | #include <utility> | ||
12 | |||
13 | #include <nlohmann/json.hpp> | ||
14 | #include <spdlog/spdlog.h> | ||
15 | #include <inja.hpp> | ||
16 | #include <mw/http_server.hpp> | ||
17 | #include <mw/url.hpp> | ||
18 | #include <mw/utils.hpp> | ||
19 | #include <mw/error.hpp> | ||
20 | #include <mw/auth.hpp> | ||
21 | |||
22 | #include "app.hpp" | ||
23 | #include "config.hpp" | ||
24 | #include "data.hpp" | ||
25 | #include "mw/error.hpp" | ||
26 | |||
27 | namespace | ||
28 | { | ||
29 | |||
30 | std::unordered_map<std::string, std::string> parseCookies(std::string_view value) | ||
31 | { | ||
32 | std::unordered_map<std::string, std::string> cookies; | ||
33 | size_t begin = 0; | ||
34 | while(true) | ||
35 | { | ||
36 | if(begin >= value.size()) | ||
37 | { | ||
38 | break; | ||
39 | } | ||
40 | |||
41 | size_t semicolon = value.find(';', begin); | ||
42 | if(semicolon == std::string::npos) | ||
43 | { | ||
44 | semicolon = value.size(); | ||
45 | } | ||
46 | |||
47 | std::string_view section = value.substr(begin, semicolon - begin); | ||
48 | |||
49 | begin = semicolon + 1; | ||
50 | // Skip spaces | ||
51 | while(begin < value.size() && value[begin] == ' ') | ||
52 | { | ||
53 | begin++; | ||
54 | } | ||
55 | |||
56 | size_t equal = section.find('='); | ||
57 | if(equal == std::string::npos) continue; | ||
58 | cookies.emplace(section.substr(0, equal), | ||
59 | section.substr(equal+1, semicolon - equal - 1)); | ||
60 | if(semicolon >= value.size()) | ||
61 | { | ||
62 | continue; | ||
63 | } | ||
64 | } | ||
65 | return cookies; | ||
66 | } | ||
67 | |||
68 | void setTokenCookies(const mw::Tokens& tokens, App::Response& res) | ||
69 | { | ||
70 | int64_t expire_sec = 300; | ||
71 | if(tokens.expiration.has_value()) | ||
72 | { | ||
73 | auto expire = std::chrono::duration_cast<std::chrono::seconds>( | ||
74 | *tokens.expiration - mw::Clock::now()); | ||
75 | expire_sec = expire.count(); | ||
76 | } | ||
77 | res.set_header("Set-Cookie", std::format( | ||
78 | "shrt-access-token={}; Max-Age={}", | ||
79 | mw::urlEncode(tokens.access_token), expire_sec)); | ||
80 | // Add refresh token to cookie, with one month expiration. | ||
81 | if(tokens.refresh_token.has_value()) | ||
82 | { | ||
83 | expire_sec = 1800; | ||
84 | if(tokens.refresh_expiration.has_value()) | ||
85 | { | ||
86 | auto expire = std::chrono::duration_cast<std::chrono::seconds>( | ||
87 | *tokens.refresh_expiration - mw::Clock::now()); | ||
88 | expire_sec = expire.count(); | ||
89 | } | ||
90 | |||
91 | res.set_header("Set-Cookie", std::format( | ||
92 | "shrt-refresh-token={}; Max-Age={}", | ||
93 | mw::urlEncode(*tokens.refresh_token), expire_sec)); | ||
94 | } | ||
95 | } | ||
96 | |||
97 | mw::HTTPServer::ListenAddress listenAddrFromConfig(const Configuration& config) | ||
98 | { | ||
99 | if(config.listen_port == 0) | ||
100 | { | ||
101 | mw::SocketFileInfo sock(config.listen_address); | ||
102 | sock.user = config.socket_user; | ||
103 | sock.group = config.socket_group; | ||
104 | sock.permission = config.socket_permission; | ||
105 | return sock; | ||
106 | } | ||
107 | |||
108 | mw::IPSocketInfo sock; | ||
109 | sock.address = config.listen_address; | ||
110 | sock.port = config.listen_port; | ||
111 | return sock; | ||
112 | } | ||
113 | |||
114 | } // namespace | ||
115 | |||
116 | App::App(const Configuration& conf, | ||
117 | std::unique_ptr<DataSourceInterface> data_source, | ||
118 | std::unique_ptr<mw::AuthInterface> openid_auth) | ||
119 | : mw::HTTPServer(listenAddrFromConfig(conf)), | ||
120 | config(conf), | ||
121 | templates((std::filesystem::path(config.data_dir) / "templates" / "") | ||
122 | .string()), | ||
123 | data(std::move(data_source)), | ||
124 | auth(std::move(openid_auth)) | ||
125 | { | ||
126 | auto u = mw::URL::fromStr(conf.base_url); | ||
127 | if(u.has_value()) | ||
128 | { | ||
129 | base_url = *std::move(u); | ||
130 | } | ||
131 | |||
132 | templates.add_callback("url_for", [&](const inja::Arguments& args) -> | ||
133 | std::string | ||
134 | { | ||
135 | switch(args.size()) | ||
136 | { | ||
137 | case 1: | ||
138 | return urlFor(args.at(0)->get_ref<const std::string&>()); | ||
139 | case 2: | ||
140 | return urlFor(args.at(0)->get_ref<const std::string&>(), | ||
141 | args.at(1)->get_ref<const std::string&>()); | ||
142 | default: | ||
143 | return "Invalid number of url_for() arguments"; | ||
144 | } | ||
145 | }); | ||
146 | } | ||
147 | |||
148 | std::string App::urlFor(const std::string& name, const std::string& arg) const | ||
149 | { | ||
150 | if(name == "statics") | ||
151 | { | ||
152 | return mw::URL(base_url).appendPath("_/statics").appendPath(arg).str(); | ||
153 | } | ||
154 | if(name == "index") | ||
155 | { | ||
156 | return base_url.str(); | ||
157 | } | ||
158 | if(name == "shortcut") | ||
159 | { | ||
160 | return mw::URL(base_url).appendPath(arg).str(); | ||
161 | } | ||
162 | if(name == "links") | ||
163 | { | ||
164 | return mw::URL(base_url).appendPath("_/links").str(); | ||
165 | } | ||
166 | if(name == "login") | ||
167 | { | ||
168 | return mw::URL(base_url).appendPath("_/login").str(); | ||
169 | } | ||
170 | if(name == "openid-redirect") | ||
171 | { | ||
172 | return mw::URL(base_url).appendPath("_/openid-redirect").str(); | ||
173 | } | ||
174 | if(name == "new-link") | ||
175 | { | ||
176 | return mw::URL(base_url).appendPath("_/new-link").str(); | ||
177 | } | ||
178 | if(name == "create-link") | ||
179 | { | ||
180 | return mw::URL(base_url).appendPath("_/create-link").str(); | ||
181 | } | ||
182 | if(name == "delete-link-dialog") | ||
183 | { | ||
184 | return mw::URL(base_url).appendPath("_/delete-link").appendPath(arg) | ||
185 | .str(); | ||
186 | } | ||
187 | if(name == "delete-link") | ||
188 | { | ||
189 | return mw::URL(base_url).appendPath("_/delete-link").str(); | ||
190 | } | ||
191 | |||
192 | return ""; | ||
193 | } | ||
194 | |||
195 | void App::handleIndex(Response& res) const | ||
196 | { | ||
197 | res.set_redirect(urlFor("links"), 301); | ||
198 | } | ||
199 | |||
200 | void App::handleLogin(Response& res) const | ||
201 | { | ||
202 | res.set_redirect(auth->initialURL(), 301); | ||
203 | } | ||
204 | |||
205 | void App::handleOpenIDRedirect(const Request& req, Response& res) const | ||
206 | { | ||
207 | if(req.has_param("error")) | ||
208 | { | ||
209 | res.status = 500; | ||
210 | if(req.has_param("error_description")) | ||
211 | { | ||
212 | res.set_content( | ||
213 | std::format("{}: {}.", req.get_param_value("error"), | ||
214 | req.get_param_value("error_description")), | ||
215 | "text/plain"); | ||
216 | } | ||
217 | return; | ||
218 | } | ||
219 | else if(!req.has_param("code")) | ||
220 | { | ||
221 | res.status = 500; | ||
222 | res.set_content("No error or code in auth response", "text/plain"); | ||
223 | return; | ||
224 | } | ||
225 | |||
226 | std::string code = req.get_param_value("code"); | ||
227 | spdlog::debug("OpenID server visited {} with code {}.", req.path, code); | ||
228 | ASSIGN_OR_RESPOND_ERROR(mw::Tokens tokens, auth->authenticate(code), res); | ||
229 | ASSIGN_OR_RESPOND_ERROR(mw::UserInfo user, auth->getUser(tokens), res); | ||
230 | |||
231 | setTokenCookies(tokens, res); | ||
232 | res.set_redirect(urlFor("index"), 301); | ||
233 | } | ||
234 | |||
235 | |||
236 | std::string App::getPath(const std::string& name, | ||
237 | const std::string& arg_name) const | ||
238 | { | ||
239 | return mw::URL::fromStr(urlFor(name, std::string(":") + arg_name)).value() | ||
240 | .path(); | ||
241 | } | ||
242 | |||
243 | void App::setup() | ||
244 | { | ||
245 | { | ||
246 | std::string statics_dir = (std::filesystem::path(config.data_dir) / | ||
247 | "statics").string(); | ||
248 | spdlog::info("Mounting static dir at {}...", statics_dir); | ||
249 | if (!server.set_mount_point("/_/statics", statics_dir)) | ||
250 | { | ||
251 | spdlog::error("Failed to mount statics"); | ||
252 | return; | ||
253 | } | ||
254 | } | ||
255 | |||
256 | server.Get(getPath("index"), [&]([[maybe_unused]] const Request& req, Response& res) | ||
257 | { | ||
258 | handleIndex(res); | ||
259 | }); | ||
260 | server.Get(getPath("login"), [&]([[maybe_unused]] const Request& req, Response& res) | ||
261 | { | ||
262 | handleLogin(res); | ||
263 | }); | ||
264 | server.Get(getPath("openid-redirect"), [&](const Request& req, Response& res) | ||
265 | { | ||
266 | handleOpenIDRedirect(req, res); | ||
267 | }); | ||
268 | } | ||
269 | |||
270 | mw::E<App::SessionValidation> App::validateSession(const Request& req) const | ||
271 | { | ||
272 | if(!req.has_header("Cookie")) | ||
273 | { | ||
274 | spdlog::debug("Request has no cookie."); | ||
275 | return SessionValidation::invalid(); | ||
276 | } | ||
277 | |||
278 | auto cookies = parseCookies(req.get_header_value("Cookie")); | ||
279 | if(auto it = cookies.find("shrt-access-token"); | ||
280 | it != std::end(cookies)) | ||
281 | { | ||
282 | spdlog::debug("Cookie has access token."); | ||
283 | mw::Tokens tokens; | ||
284 | tokens.access_token = it->second; | ||
285 | mw::E<mw::UserInfo> user = auth->getUser(tokens); | ||
286 | if(user.has_value()) | ||
287 | { | ||
288 | return SessionValidation::valid(*std::move(user)); | ||
289 | } | ||
290 | } | ||
291 | // No access token or access token expired | ||
292 | if(auto it = cookies.find("shrt-refresh-token"); | ||
293 | it != std::end(cookies)) | ||
294 | { | ||
295 | spdlog::debug("Cookie has refresh token."); | ||
296 | // Try to refresh the tokens. | ||
297 | ASSIGN_OR_RETURN(mw::Tokens tokens, auth->refreshTokens(it->second)); | ||
298 | ASSIGN_OR_RETURN(mw::UserInfo user, auth->getUser(tokens)); | ||
299 | return SessionValidation::refreshed(std::move(user), std::move(tokens)); | ||
300 | } | ||
301 | return SessionValidation::invalid(); | ||
302 | } | ||
303 | |||
304 | std::optional<App::SessionValidation> App::prepareSession( | ||
305 | const Request& req, Response& res, bool allow_error_and_invalid) const | ||
306 | { | ||
307 | mw::E<SessionValidation> session = validateSession(req); | ||
308 | if(!session.has_value()) | ||
309 | { | ||
310 | if(allow_error_and_invalid) | ||
311 | { | ||
312 | return SessionValidation::invalid(); | ||
313 | } | ||
314 | else | ||
315 | { | ||
316 | res.status = 500; | ||
317 | res.set_content("Failed to validate session.", "text/plain"); | ||
318 | return std::nullopt; | ||
319 | } | ||
320 | } | ||
321 | |||
322 | switch(session->status) | ||
323 | { | ||
324 | case SessionValidation::INVALID: | ||
325 | if(allow_error_and_invalid) | ||
326 | { | ||
327 | return *session; | ||
328 | } | ||
329 | else | ||
330 | { | ||
331 | res.status = 401; | ||
332 | res.set_content("Invalid session.", "text/plain"); | ||
333 | return std::nullopt; | ||
334 | } | ||
335 | case SessionValidation::VALID: | ||
336 | break; | ||
337 | case SessionValidation::REFRESHED: | ||
338 | setTokenCookies(session->new_tokens, res); | ||
339 | break; | ||
340 | } | ||
341 | return *session; | ||
342 | } | ||
diff --git a/src/app.hpp b/src/app.hpp new file mode 100644 index 0000000..092196c --- /dev/null +++ b/src/app.hpp | |||
@@ -0,0 +1,84 @@ | |||
1 | #pragma once | ||
2 | |||
3 | #include <memory> | ||
4 | #include <optional> | ||
5 | #include <string> | ||
6 | |||
7 | #include <inja.hpp> | ||
8 | #include <mw/url.hpp> | ||
9 | #include <mw/http_server.hpp> | ||
10 | #include <mw/error.hpp> | ||
11 | #include <mw/auth.hpp> | ||
12 | |||
13 | #include "data.hpp" | ||
14 | #include "config.hpp" | ||
15 | |||
16 | class App : public mw::HTTPServer | ||
17 | { | ||
18 | public: | ||
19 | using Request = mw::HTTPServer::Request; | ||
20 | using Response = mw::HTTPServer::Response; | ||
21 | |||
22 | App() = delete; | ||
23 | App(const Configuration& conf, | ||
24 | std::unique_ptr<DataSourceInterface> data_source, | ||
25 | std::unique_ptr<mw::AuthInterface> openid_auth); | ||
26 | |||
27 | std::string urlFor(const std::string& name, const std::string& arg="") | ||
28 | const; | ||
29 | |||
30 | private: | ||
31 | void setup() override; | ||
32 | |||
33 | struct SessionValidation | ||
34 | { | ||
35 | enum { VALID, REFRESHED, INVALID } status; | ||
36 | mw::UserInfo user; | ||
37 | mw::Tokens new_tokens; | ||
38 | |||
39 | static SessionValidation valid(mw::UserInfo&& user_info) | ||
40 | { | ||
41 | return {VALID, user_info, {}}; | ||
42 | } | ||
43 | |||
44 | static SessionValidation refreshed(mw::UserInfo&& user_info, mw::Tokens&& tokens) | ||
45 | { | ||
46 | return {REFRESHED, user_info, tokens}; | ||
47 | } | ||
48 | |||
49 | static SessionValidation invalid() | ||
50 | { | ||
51 | return {INVALID, {}, {}}; | ||
52 | } | ||
53 | }; | ||
54 | mw::E<SessionValidation> validateSession(const Request& req) const; | ||
55 | |||
56 | // Query the auth module for the status of the session. If there | ||
57 | // is no session or it fails to query the auth module, set the | ||
58 | // status and body in “res” accordingly, and return nullopt. In | ||
59 | // this case if this function does return a value, it would never | ||
60 | // be an invalid session. | ||
61 | // | ||
62 | // If “allow_error_and_invalid” is true, failure to query and | ||
63 | // invalid session are considered ok, and no status and body would | ||
64 | // be set in “res”. In this case this function just returns an | ||
65 | // invalid session. | ||
66 | std::optional<SessionValidation> prepareSession( | ||
67 | const Request& req, Response& res, | ||
68 | bool allow_error_and_invalid=false) const; | ||
69 | |||
70 | // This gives a path, optionally with the name of an argument, | ||
71 | // that is suitable to bind to a URL handler. For example, | ||
72 | // supposed the URL of the blog post with ID 1 is | ||
73 | // “http://some.domain/blog/p/1”. Calling “getPath("post", "id")” | ||
74 | // would give “/blog/p/:id”. This uses urlFor(), and therefore | ||
75 | // requires that the URL is mapped correctly in that function. | ||
76 | std::string getPath(const std::string& name, const std::string& arg_name="") | ||
77 | const; | ||
78 | |||
79 | Configuration config; | ||
80 | mw::URL base_url; | ||
81 | inja::Environment templates; | ||
82 | std::unique_ptr<DataSourceInterface> data; | ||
83 | std::unique_ptr<mw::AuthInterface> auth; | ||
84 | }; | ||
diff --git a/src/app_test.cpp b/src/app_test.cpp new file mode 100644 index 0000000..a022611 --- /dev/null +++ b/src/app_test.cpp | |||
@@ -0,0 +1,205 @@ | |||
1 | #include <httplib.h> | ||
2 | #include <memory> | ||
3 | #include <iostream> | ||
4 | |||
5 | #include <gmock/gmock.h> | ||
6 | #include <gtest/gtest.h> | ||
7 | #include <mw/test_utils.hpp> | ||
8 | #include <mw/http_client.hpp> | ||
9 | #include <mw/auth_mock.hpp> | ||
10 | |||
11 | #include "app.hpp" | ||
12 | #include "config.hpp" | ||
13 | #include "data.hpp" | ||
14 | #include "data_mock.hpp" | ||
15 | |||
16 | using ::testing::_; | ||
17 | using ::testing::Return; | ||
18 | using ::testing::HasSubstr; | ||
19 | using ::testing::FieldsAre; | ||
20 | using ::testing::ContainsRegex; | ||
21 | |||
22 | void PrintTo(const ShortLink& link, std::ostream* os) | ||
23 | { | ||
24 | *os << "ShortLink(id: " << link.id | ||
25 | << ", shortcut: " << link.shortcut | ||
26 | << ", original_url: " << link.original_url | ||
27 | << ", type: " << link.type | ||
28 | << ", user_id: " << link.user_id | ||
29 | << ", user_name: " << link.user_name | ||
30 | << ", visits: " << link.visits | ||
31 | << ", time_creation: " << link.time_creation << ")"; | ||
32 | } | ||
33 | |||
34 | class UserAppTest : public testing::Test | ||
35 | { | ||
36 | protected: | ||
37 | UserAppTest() | ||
38 | { | ||
39 | config.base_url = "http://localhost:8080/"; | ||
40 | config.listen_address = "localhost"; | ||
41 | config.listen_port = 8080; | ||
42 | config.data_dir = "."; | ||
43 | |||
44 | auto auth = std::make_unique<mw::AuthMock>(); | ||
45 | |||
46 | mw::UserInfo expected_user; | ||
47 | expected_user.name = "mw"; | ||
48 | expected_user.id = "mw"; | ||
49 | mw::Tokens token; | ||
50 | token.access_token = "aaa"; | ||
51 | EXPECT_CALL(*auth, getUser(std::move(token))) | ||
52 | .Times(::testing::AtLeast(0)) | ||
53 | .WillRepeatedly(Return(expected_user)); | ||
54 | auto data = std::make_unique<DataSourceMock>(); | ||
55 | data_source = data.get(); | ||
56 | |||
57 | app = std::make_unique<App>(config, std::move(data), std::move(auth)); | ||
58 | } | ||
59 | |||
60 | Configuration config; | ||
61 | std::unique_ptr<App> app; | ||
62 | const DataSourceMock* data_source; | ||
63 | }; | ||
64 | |||
65 | TEST_F(UserAppTest, CanDenyAccessToLinkList) | ||
66 | { | ||
67 | EXPECT_TRUE(mw::isExpected(app->start())); | ||
68 | { | ||
69 | mw::HTTPSession client; | ||
70 | ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( | ||
71 | mw::HTTPRequest("http://localhost:8080/_/links"))); | ||
72 | EXPECT_EQ(res->status, 401); | ||
73 | } | ||
74 | app->stop(); | ||
75 | app->wait(); | ||
76 | } | ||
77 | |||
78 | TEST_F(UserAppTest, CanHandleLinkList) | ||
79 | { | ||
80 | std::vector<ShortLink> links; | ||
81 | ShortLink link0; | ||
82 | link0.shortcut = "link0"; | ||
83 | link0.original_url = "a"; | ||
84 | link0.id = 1; | ||
85 | link0.user_id = "mw"; | ||
86 | link0.user_name = "mw"; | ||
87 | link0.type = ShortLink::NORMAL; | ||
88 | ShortLink link1; | ||
89 | link1.shortcut = "link1"; | ||
90 | link1.original_url = "b"; | ||
91 | link1.id = 2; | ||
92 | link1.user_id = "mw"; | ||
93 | link1.user_name = "mw"; | ||
94 | link1.type = ShortLink::REGEXP; | ||
95 | links.push_back(std::move(link0)); | ||
96 | links.push_back(std::move(link1)); | ||
97 | |||
98 | EXPECT_CALL(*data_source, getAllLinks("mw")) | ||
99 | .WillOnce(Return(std::move(links))); | ||
100 | |||
101 | EXPECT_TRUE(mw::isExpected(app->start())); | ||
102 | { | ||
103 | mw::HTTPSession client; | ||
104 | ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( | ||
105 | mw::HTTPRequest("http://localhost:8080/_/links") | ||
106 | .addHeader("Cookie", "shrt-access-token=aaa"))); | ||
107 | EXPECT_EQ(res->status, 200) << "Response body: " << res->payloadAsStr(); | ||
108 | EXPECT_THAT(res->payloadAsStr(), ContainsRegex("<td>a</td>[[:space:]]*<td>-</td>")); | ||
109 | EXPECT_THAT(res->payloadAsStr(), ContainsRegex("<td>b</td>[[:space:]]*<td>✅</td>")); | ||
110 | } | ||
111 | app->stop(); | ||
112 | app->wait(); | ||
113 | } | ||
114 | |||
115 | TEST_F(UserAppTest, CanDenyAccessToNewLink) | ||
116 | { | ||
117 | EXPECT_TRUE(mw::isExpected(app->start())); | ||
118 | { | ||
119 | mw::HTTPSession client; | ||
120 | ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( | ||
121 | mw::HTTPRequest("http://localhost:8080/_/new-link"))); | ||
122 | EXPECT_EQ(res->status, 401); | ||
123 | } | ||
124 | app->stop(); | ||
125 | app->wait(); | ||
126 | } | ||
127 | |||
128 | TEST_F(UserAppTest, CanHandleNewLink) | ||
129 | { | ||
130 | EXPECT_TRUE(mw::isExpected(app->start())); | ||
131 | { | ||
132 | mw::HTTPSession client; | ||
133 | ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( | ||
134 | mw::HTTPRequest("http://localhost:8080/_/new-link") | ||
135 | .addHeader("Cookie", "shrt-access-token=aaa"))); | ||
136 | EXPECT_EQ(res->status, 200); | ||
137 | EXPECT_THAT(res->payloadAsStr(), HasSubstr("Create a New Link")); | ||
138 | } | ||
139 | app->stop(); | ||
140 | app->wait(); | ||
141 | } | ||
142 | |||
143 | TEST_F(UserAppTest, CanDenyAccessToCreateLink) | ||
144 | { | ||
145 | EXPECT_TRUE(mw::isExpected(app->start())); | ||
146 | { | ||
147 | mw::HTTPSession client; | ||
148 | ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.post( | ||
149 | mw::HTTPRequest("http://localhost:8080/_/create-link"))); | ||
150 | EXPECT_EQ(res->status, 401); | ||
151 | } | ||
152 | app->stop(); | ||
153 | app->wait(); | ||
154 | } | ||
155 | |||
156 | TEST_F(UserAppTest, CanDenyHandleCreateLink) | ||
157 | { | ||
158 | EXPECT_CALL(*data_source, addLink( | ||
159 | FieldsAre( | ||
160 | _, // id | ||
161 | "abc", // shortcut | ||
162 | "http://darksair.org", // original_url | ||
163 | ShortLink::NORMAL, // type | ||
164 | "mw", // user_id | ||
165 | "", // user_name | ||
166 | _, // visits | ||
167 | _))) // time_creation | ||
168 | .WillOnce(Return(mw::E<void>())); | ||
169 | |||
170 | EXPECT_CALL(*data_source, addLink( | ||
171 | FieldsAre( | ||
172 | _, // id | ||
173 | "xyz", // shortcut | ||
174 | "http://mws.rocks", // original_url | ||
175 | ShortLink::REGEXP, // type | ||
176 | "mw", // user_id | ||
177 | "", // user_name | ||
178 | _, // visits | ||
179 | _))) // time_creation | ||
180 | .WillOnce(Return(mw::E<void>())); | ||
181 | |||
182 | EXPECT_TRUE(mw::isExpected(app->start())); | ||
183 | { | ||
184 | mw::HTTPSession client; | ||
185 | ASSIGN_OR_FAIL(const mw::HTTPResponse* res1, client.post( | ||
186 | mw::HTTPRequest("http://localhost:8080/_/create-link") | ||
187 | .setPayload("shortcut=abc&original_url=http%3A%2F%2Fdarksair%2Eorg" | ||
188 | "®exp=off") | ||
189 | .addHeader("Cookie", "shrt-access-token=aaa") | ||
190 | .setContentType("application/x-www-form-urlencoded"))); | ||
191 | EXPECT_EQ(res1->status, 302); | ||
192 | EXPECT_EQ(res1->header.at("Location"), "http://localhost:8080/"); | ||
193 | |||
194 | ASSIGN_OR_FAIL(const mw::HTTPResponse* res2, client.post( | ||
195 | mw::HTTPRequest("http://localhost:8080/_/create-link") | ||
196 | .setPayload("shortcut=xyz&original_url=http%3A%2F%2Fmws%2Erocks" | ||
197 | "®exp=on") | ||
198 | .addHeader("Cookie", "shrt-access-token=aaa") | ||
199 | .setContentType("application/x-www-form-urlencoded"))); | ||
200 | EXPECT_EQ(res2->status, 302); | ||
201 | EXPECT_EQ(res2->header.at("Location"), "http://localhost:8080/"); | ||
202 | } | ||
203 | app->stop(); | ||
204 | app->wait(); | ||
205 | } | ||
diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..aec29a4 --- /dev/null +++ b/src/config.cpp | |||
@@ -0,0 +1,85 @@ | |||
1 | #include <filesystem> | ||
2 | #include <vector> | ||
3 | #include <fstream> | ||
4 | #include <format> | ||
5 | #include <utility> | ||
6 | |||
7 | #include <ryml.hpp> | ||
8 | #include <ryml_std.hpp> | ||
9 | #include <mw/error.hpp> | ||
10 | |||
11 | #include "config.hpp" | ||
12 | |||
13 | namespace { | ||
14 | |||
15 | mw::E<std::vector<char>> readFile(const std::filesystem::path& path) | ||
16 | { | ||
17 | std::ifstream f(path, std::ios::binary); | ||
18 | std::vector<char> content; | ||
19 | content.reserve(102400); | ||
20 | content.assign(std::istreambuf_iterator<char>(f), | ||
21 | std::istreambuf_iterator<char>()); | ||
22 | if(f.bad() || f.fail()) | ||
23 | { | ||
24 | return std::unexpected(mw::runtimeError( | ||
25 | std::format("Failed to read file {}", path.string()))); | ||
26 | } | ||
27 | |||
28 | return content; | ||
29 | } | ||
30 | |||
31 | } // namespace | ||
32 | |||
33 | mw::E<Configuration> Configuration::fromYaml(const std::filesystem::path& path) | ||
34 | { | ||
35 | auto buffer = readFile(path); | ||
36 | if(!buffer.has_value()) | ||
37 | { | ||
38 | return std::unexpected(buffer.error()); | ||
39 | } | ||
40 | |||
41 | ryml::Tree tree = ryml::parse_in_place(ryml::to_substr(*buffer)); | ||
42 | Configuration config; | ||
43 | if(tree["listen-address"].readable()) | ||
44 | { | ||
45 | tree["listen-address"] >> config.listen_address; | ||
46 | } | ||
47 | if(tree["listen-port"].readable()) | ||
48 | { | ||
49 | tree["listen-port"] >> config.listen_port; | ||
50 | } | ||
51 | if(tree["socket-user"].readable()) | ||
52 | { | ||
53 | tree["socket-user"] >> config.socket_user; | ||
54 | } | ||
55 | if(tree["socket-group"].readable()) | ||
56 | { | ||
57 | tree["socket-group"] >> config.socket_group; | ||
58 | } | ||
59 | if(tree["socket-permission"].readable()) | ||
60 | { | ||
61 | tree["socket-permission"] >> config.socket_permission; | ||
62 | } | ||
63 | if(tree["base-url"].readable()) | ||
64 | { | ||
65 | tree["base-url"] >> config.base_url; | ||
66 | } | ||
67 | if(tree["data-dir"].readable()) | ||
68 | { | ||
69 | tree["data-dir"] >> config.data_dir; | ||
70 | } | ||
71 | if(tree["openid-url-prefix"].readable()) | ||
72 | { | ||
73 | tree["openid-url-prefix"] >> config.openid_url_prefix; | ||
74 | } | ||
75 | if(tree["client-id"].readable()) | ||
76 | { | ||
77 | tree["client-id"] >> config.client_id; | ||
78 | } | ||
79 | if(tree["client-secret"].readable()) | ||
80 | { | ||
81 | tree["client-secret"] >> config.client_secret; | ||
82 | } | ||
83 | |||
84 | return mw::E<Configuration>{std::in_place, std::move(config)}; | ||
85 | } | ||
diff --git a/src/config.hpp b/src/config.hpp new file mode 100644 index 0000000..1f4e01f --- /dev/null +++ b/src/config.hpp | |||
@@ -0,0 +1,23 @@ | |||
1 | #pragma once | ||
2 | |||
3 | #include <filesystem> | ||
4 | #include <string> | ||
5 | |||
6 | #include <mw/error.hpp> | ||
7 | |||
8 | struct Configuration | ||
9 | { | ||
10 | std::string listen_address = "localhost"; | ||
11 | // Set this to 0 to listen to socket file. | ||
12 | int listen_port = 8123; | ||
13 | std::string socket_user = ""; | ||
14 | std::string socket_group = ""; | ||
15 | int socket_permission = 0; | ||
16 | std::string base_url = "http://localhost:8123/"; | ||
17 | std::string data_dir = "."; | ||
18 | std::string openid_url_prefix; | ||
19 | std::string client_id; | ||
20 | std::string client_secret; | ||
21 | |||
22 | static mw::E<Configuration> fromYaml(const std::filesystem::path& path); | ||
23 | }; | ||
diff --git a/src/data.cpp b/src/data.cpp new file mode 100644 index 0000000..78cb500 --- /dev/null +++ b/src/data.cpp | |||
@@ -0,0 +1,54 @@ | |||
1 | #include <memory> | ||
2 | #include <string> | ||
3 | #include <expected> | ||
4 | |||
5 | #include <mw/database.hpp> | ||
6 | #include <mw/error.hpp> | ||
7 | #include <mw/utils.hpp> | ||
8 | |||
9 | #include "data.hpp" | ||
10 | |||
11 | namespace | ||
12 | { | ||
13 | |||
14 | } // namespace | ||
15 | |||
16 | mw::E<std::unique_ptr<DataSourceSQLite>> | ||
17 | DataSourceSQLite::fromFile(const std::string& db_file) | ||
18 | { | ||
19 | auto data_source = std::make_unique<DataSourceSQLite>(); | ||
20 | ASSIGN_OR_RETURN(data_source->db, mw::SQLite::connectFile(db_file)); | ||
21 | |||
22 | // Perform schema upgrade here. | ||
23 | // | ||
24 | // data_source->upgradeSchema1To2(); | ||
25 | |||
26 | // Update this line when schema updates. | ||
27 | DO_OR_RETURN(data_source->setSchemaVersion(1)); | ||
28 | DO_OR_RETURN(data_source->db->execute( | ||
29 | "CREATE TABLE IF NOT EXISTS Users " | ||
30 | "(id INTEGER PRIMARY KEY, openid_uid TEXT, name TEXT);")); | ||
31 | DO_OR_RETURN(data_source->db->execute( | ||
32 | "CREATE TABLE IF NOT EXISTS LinkItems " | ||
33 | "(id INTEGER PRIMARY KEY," | ||
34 | " FOREIGN KEY(owner_id) REFERENCES Users(id) NOT NULL," | ||
35 | " FOREIGN KEY(parent_id) REFERENCES LinkItems(id)," | ||
36 | " name TEXT NOT NULL, url TEXT, description TEXT," | ||
37 | " visibility INTEGER NOT NULL, time INTEGER NOT NULL);")); | ||
38 | return data_source; | ||
39 | } | ||
40 | |||
41 | mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::newFromMemory() | ||
42 | { | ||
43 | return fromFile(":memory:"); | ||
44 | } | ||
45 | |||
46 | mw::E<int64_t> DataSourceSQLite::getSchemaVersion() const | ||
47 | { | ||
48 | return db->evalToValue<int64_t>("PRAGMA user_version;"); | ||
49 | } | ||
50 | |||
51 | mw::E<void> DataSourceSQLite::setSchemaVersion(int64_t v) const | ||
52 | { | ||
53 | return db->execute(std::format("PRAGMA user_version = {};", v)); | ||
54 | } | ||
diff --git a/src/data.hpp b/src/data.hpp new file mode 100644 index 0000000..dc64e6f --- /dev/null +++ b/src/data.hpp | |||
@@ -0,0 +1,73 @@ | |||
1 | #pragma once | ||
2 | |||
3 | #include <memory> | ||
4 | #include <string> | ||
5 | #include <optional> | ||
6 | #include <vector> | ||
7 | |||
8 | #include <mw/database.hpp> | ||
9 | #include <mw/error.hpp> | ||
10 | #include <mw/utils.hpp> | ||
11 | |||
12 | struct LinkItem | ||
13 | { | ||
14 | enum Visibility {PUBLIC, PRIVATE}; | ||
15 | |||
16 | int64_t id; | ||
17 | int64_t owner_id; | ||
18 | // Top-level items don’t have parents. | ||
19 | std::optional<int64_t> parent_id; | ||
20 | std::string name; | ||
21 | // If this is empty, it’s a parent. | ||
22 | std::string url; | ||
23 | std::string description; | ||
24 | Visibility visibility; | ||
25 | mw::Time time; | ||
26 | }; | ||
27 | |||
28 | struct User | ||
29 | { | ||
30 | int64_t id; | ||
31 | std::string openid_uid; | ||
32 | std::string name; | ||
33 | }; | ||
34 | |||
35 | class DataSourceInterface | ||
36 | { | ||
37 | public: | ||
38 | virtual ~DataSourceInterface() = default; | ||
39 | |||
40 | // The schema version is the version of the database. It starts | ||
41 | // from 1. Every time the schema change, someone should increase | ||
42 | // this number by 1, manually, by hand. The intended use is to | ||
43 | // help with database migration. | ||
44 | virtual mw::E<int64_t> getSchemaVersion() const = 0; | ||
45 | |||
46 | virtual std::vector<LinkItem> items(std::optional<int64_t> parent) = 0; | ||
47 | |||
48 | protected: | ||
49 | virtual mw::E<void> setSchemaVersion(int64_t v) const = 0; | ||
50 | }; | ||
51 | |||
52 | class DataSourceSQLite : public DataSourceInterface | ||
53 | { | ||
54 | public: | ||
55 | explicit DataSourceSQLite(std::unique_ptr<mw::SQLite> conn) | ||
56 | : db(std::move(conn)) {} | ||
57 | ~DataSourceSQLite() override = default; | ||
58 | |||
59 | static mw::E<std::unique_ptr<DataSourceSQLite>> | ||
60 | fromFile(const std::string& db_file); | ||
61 | static mw::E<std::unique_ptr<DataSourceSQLite>> newFromMemory(); | ||
62 | |||
63 | mw::E<int64_t> getSchemaVersion() const override; | ||
64 | |||
65 | // Do not use. | ||
66 | DataSourceSQLite() = default; | ||
67 | |||
68 | protected: | ||
69 | mw::E<void> setSchemaVersion(int64_t v) const override; | ||
70 | |||
71 | private: | ||
72 | std::unique_ptr<mw::SQLite> db; | ||
73 | }; | ||
diff --git a/src/data_mock.hpp b/src/data_mock.hpp new file mode 100644 index 0000000..363eb9e --- /dev/null +++ b/src/data_mock.hpp | |||
@@ -0,0 +1,24 @@ | |||
1 | #pragma once | ||
2 | |||
3 | #include <vector> | ||
4 | #include <string> | ||
5 | #include <optional> | ||
6 | |||
7 | #include <gmock/gmock.h> | ||
8 | #include <mw/error.hpp> | ||
9 | |||
10 | #include "data.hpp" | ||
11 | |||
12 | class DataSourceMock : public DataSourceInterface | ||
13 | { | ||
14 | public: | ||
15 | ~DataSourceMock() override = default; | ||
16 | |||
17 | MOCK_METHOD(mw::E<int64_t>, getSchemaVersion, (), (const override)); | ||
18 | |||
19 | protected: | ||
20 | mw::E<void> setSchemaVersion([[maybe_unused]] int64_t v) const override | ||
21 | { | ||
22 | return {}; | ||
23 | } | ||
24 | }; | ||
diff --git a/src/data_test.cpp b/src/data_test.cpp new file mode 100644 index 0000000..f1a1a1c --- /dev/null +++ b/src/data_test.cpp | |||
@@ -0,0 +1,17 @@ | |||
1 | #include <vector> | ||
2 | |||
3 | #include <gtest/gtest.h> | ||
4 | #include <gmock/gmock.h> | ||
5 | #include <mw/error.hpp> | ||
6 | #include <mw/utils.hpp> | ||
7 | #include <mw/test_utils.hpp> | ||
8 | |||
9 | #include "data.hpp" | ||
10 | |||
11 | using ::testing::IsEmpty; | ||
12 | |||
13 | TEST(DataSource, CanAddAndDeleteLink) | ||
14 | { | ||
15 | ASSIGN_OR_FAIL(std::unique_ptr<DataSourceSQLite> data, | ||
16 | DataSourceSQLite::newFromMemory()); | ||
17 | } | ||
diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..1aceef1 --- /dev/null +++ b/src/main.cpp | |||
@@ -0,0 +1,29 @@ | |||
1 | #include <memory> | ||
2 | |||
3 | #include <cxxopts.hpp> | ||
4 | #include <spdlog/spdlog.h> | ||
5 | #include <mw/url.hpp> | ||
6 | #include <mw/error.hpp> | ||
7 | |||
8 | #include "config.hpp" | ||
9 | #include "data.hpp" | ||
10 | #include "app.hpp" | ||
11 | |||
12 | int main(int argc, char** argv) | ||
13 | { | ||
14 | cxxopts::Options cmd_options( | ||
15 | "shrt", "A naively simple URL shortener"); | ||
16 | cmd_options.add_options() | ||
17 | ("c,config", "Config file", | ||
18 | cxxopts::value<std::string>()->default_value("/etc/shrt.yaml")) | ||
19 | ("h,help", "Print this message."); | ||
20 | auto opts = cmd_options.parse(argc, argv); | ||
21 | |||
22 | if(opts.count("help")) | ||
23 | { | ||
24 | std::cout << cmd_options.help() << std::endl; | ||
25 | return 0; | ||
26 | } | ||
27 | |||
28 | return 0; | ||
29 | } | ||