aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMetroWind <chris.corsair@gmail.com>2025-09-07 09:42:33 -0700
committerMetroWind <chris.corsair@gmail.com>2025-09-07 09:42:33 -0700
commitea0d3220db995018335c48eb06b9794235ff436b (patch)
tree892564cdd4946c6ee9c1051bc31ff5c7bba6ddf1 /src
Initial commit, mostly just copied from shrt.
Diffstat (limited to 'src')
-rw-r--r--src/app.cpp342
-rw-r--r--src/app.hpp84
-rw-r--r--src/app_test.cpp205
-rw-r--r--src/config.cpp85
-rw-r--r--src/config.hpp23
-rw-r--r--src/data.cpp54
-rw-r--r--src/data.hpp73
-rw-r--r--src/data_mock.hpp24
-rw-r--r--src/data_test.cpp17
-rw-r--r--src/main.cpp29
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
27namespace
28{
29
30std::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
68void 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
97mw::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
116App::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
148std::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
195void App::handleIndex(Response& res) const
196{
197 res.set_redirect(urlFor("links"), 301);
198}
199
200void App::handleLogin(Response& res) const
201{
202 res.set_redirect(auth->initialURL(), 301);
203}
204
205void 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
236std::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
243void 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
270mw::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
304std::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
16class App : public mw::HTTPServer
17{
18public:
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
30private:
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
16using ::testing::_;
17using ::testing::Return;
18using ::testing::HasSubstr;
19using ::testing::FieldsAre;
20using ::testing::ContainsRegex;
21
22void 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
34class UserAppTest : public testing::Test
35{
36protected:
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
65TEST_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
78TEST_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
115TEST_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
128TEST_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
143TEST_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
156TEST_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 "&regexp=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 "&regexp=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
13namespace {
14
15mw::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
33mw::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
8struct 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
11namespace
12{
13
14} // namespace
15
16mw::E<std::unique_ptr<DataSourceSQLite>>
17DataSourceSQLite::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
41mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::newFromMemory()
42{
43 return fromFile(":memory:");
44}
45
46mw::E<int64_t> DataSourceSQLite::getSchemaVersion() const
47{
48 return db->evalToValue<int64_t>("PRAGMA user_version;");
49}
50
51mw::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
12struct 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
28struct User
29{
30 int64_t id;
31 std::string openid_uid;
32 std::string name;
33};
34
35class DataSourceInterface
36{
37public:
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
48protected:
49 virtual mw::E<void> setSchemaVersion(int64_t v) const = 0;
50};
51
52class DataSourceSQLite : public DataSourceInterface
53{
54public:
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
68protected:
69 mw::E<void> setSchemaVersion(int64_t v) const override;
70
71private:
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
12class DataSourceMock : public DataSourceInterface
13{
14public:
15 ~DataSourceMock() override = default;
16
17 MOCK_METHOD(mw::E<int64_t>, getSchemaVersion, (), (const override));
18
19protected:
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
11using ::testing::IsEmpty;
12
13TEST(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
12int 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}