aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/app.cpp150
-rw-r--r--src/app.hpp11
-rw-r--r--src/app_test.cpp77
-rw-r--r--src/data.cpp182
-rw-r--r--src/data.hpp27
-rw-r--r--src/data_mock.hpp14
-rw-r--r--src/data_test.cpp46
-rw-r--r--templates/dir.html17
-rw-r--r--templates/links.html39
9 files changed, 424 insertions, 139 deletions
diff --git a/src/app.cpp b/src/app.cpp
index 7cfcf0d..472c266 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -75,7 +75,7 @@ void setTokenCookies(const mw::Tokens& tokens, App::Response& res)
75 expire_sec = expire.count(); 75 expire_sec = expire.count();
76 } 76 }
77 res.set_header("Set-Cookie", std::format( 77 res.set_header("Set-Cookie", std::format(
78 "shrt-access-token={}; Max-Age={}", 78 "webdir-access-token={}; Max-Age={}",
79 mw::urlEncode(tokens.access_token), expire_sec)); 79 mw::urlEncode(tokens.access_token), expire_sec));
80 // Add refresh token to cookie, with one month expiration. 80 // Add refresh token to cookie, with one month expiration.
81 if(tokens.refresh_token.has_value()) 81 if(tokens.refresh_token.has_value())
@@ -89,7 +89,7 @@ void setTokenCookies(const mw::Tokens& tokens, App::Response& res)
89 } 89 }
90 90
91 res.set_header("Set-Cookie", std::format( 91 res.set_header("Set-Cookie", std::format(
92 "shrt-refresh-token={}; Max-Age={}", 92 "webdir-refresh-token={}; Max-Age={}",
93 mw::urlEncode(*tokens.refresh_token), expire_sec)); 93 mw::urlEncode(*tokens.refresh_token), expire_sec));
94 } 94 }
95} 95}
@@ -111,6 +111,21 @@ mw::HTTPServer::ListenAddress listenAddrFromConfig(const Configuration& config)
111 return sock; 111 return sock;
112} 112}
113 113
114nlohmann::json jsonFromItem(const LinkItem& item)
115{
116 return {
117 {"id", item.id},
118 {"owner_id", item.owner_id},
119 {"parent_id", item.parent_id},
120 {"name", item.name},
121 {"url", item.url},
122 {"description", item.description},
123 {"visibility", LinkItem::visibilityToStr(item.visibility)},
124 {"time", mw::timeToSeconds(item.time)},
125 {"time_str", mw::timeToStr(item.time)},
126 };
127}
128
114} // namespace 129} // namespace
115 130
116App::App(const Configuration& conf, 131App::App(const Configuration& conf,
@@ -151,18 +166,6 @@ std::string App::urlFor(const std::string& name, const std::string& arg) const
151 { 166 {
152 return mw::URL(base_url).appendPath("_/statics").appendPath(arg).str(); 167 return mw::URL(base_url).appendPath("_/statics").appendPath(arg).str();
153 } 168 }
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") 169 if(name == "login")
167 { 170 {
168 return mw::URL(base_url).appendPath("_/login").str(); 171 return mw::URL(base_url).appendPath("_/login").str();
@@ -171,22 +174,13 @@ std::string App::urlFor(const std::string& name, const std::string& arg) const
171 { 174 {
172 return mw::URL(base_url).appendPath("_/openid-redirect").str(); 175 return mw::URL(base_url).appendPath("_/openid-redirect").str();
173 } 176 }
174 if(name == "new-link") 177 if(name == "index")
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 { 178 {
184 return mw::URL(base_url).appendPath("_/delete-link").appendPath(arg) 179 return base_url.str();
185 .str();
186 } 180 }
187 if(name == "delete-link") 181 if(name == "dir") // /dir/<username or item_id>
188 { 182 {
189 return mw::URL(base_url).appendPath("_/delete-link").str(); 183 return mw::URL(base_url).appendPath("dir").appendPath(arg).str();
190 } 184 }
191 185
192 return ""; 186 return "";
@@ -194,7 +188,86 @@ std::string App::urlFor(const std::string& name, const std::string& arg) const
194 188
195void App::handleIndex(Response& res) const 189void App::handleIndex(Response& res) const
196{ 190{
197 res.set_redirect(urlFor("links"), 301); 191 res.set_redirect(urlFor("dir", "mw"), 301);
192}
193
194void App::handleDir(const Request& req, Response& res)
195{
196 auto session = prepareSession(req, res, true);
197 if(!req.has_param("owner_or_id"))
198 {
199 res.status = 400;
200 res.set_content("Need parameter", "text/plain");
201 return;
202 }
203 std::string owner_or_id = req.get_param_value("owner_or_id");
204 std::string owner;
205 // If owner_or_id is an integer, it’s the ID of an item. Otherwise
206 // it’s a username.
207 auto item_id_maybe = mw::strToNumber<int64_t>(owner_or_id);
208 std::vector<LinkItem> items;
209 if(item_id_maybe.has_value())
210 {
211 ASSIGN_OR_RESPOND_ERROR(std::optional<LinkItem> item,
212 data->itemByID(*item_id_maybe), res);
213 if(!item.has_value())
214 {
215 res.status = 400;
216 res.set_content("Item not found", "text/plain");
217 return;
218 }
219 ASSIGN_OR_RESPOND_ERROR(std::optional<User> user,
220 data->userByID(item->owner_id), res);
221 if(!user.has_value())
222 {
223 res.status = 500;
224 res.set_content("Owner of item not found", "text/plain");
225 return;
226 }
227 owner = user->name;
228 ASSIGN_OR_RESPOND_ERROR(items, data->itemsByParent(*item_id_maybe),
229 res);
230 }
231 else
232 {
233 ASSIGN_OR_RESPOND_ERROR(std::optional<User> user,
234 data->userByName(owner_or_id), res);
235 if(!user.has_value())
236 {
237 res.status = 404;
238 res.set_content(std::string("Unknown user: ") + owner_or_id,
239 "text/plain");
240 return;
241 }
242 owner = owner_or_id;
243 ASSIGN_OR_RESPOND_ERROR(items, data->itemsTopLevelByUser(user->id),
244 res);
245 }
246
247 nlohmann::json items_data = nlohmann::json::array();
248 for(const LinkItem& item: items)
249 {
250 if(item.visibility == LinkItem::PRIVATE)
251 {
252 if(session->status == SessionValidation::INVALID)
253 {
254 continue;
255 }
256 if(session->user.name != owner)
257 {
258 continue;
259 }
260 }
261 items_data.push_back(jsonFromItem(item));
262 }
263 nlohmann::json data = {
264 {"session_user", session->user.name},
265 {"owner", owner},
266 {"this_url", req.target},
267 {"items", std::move(items_data)},
268 };
269 std::string result = templates.render_file("dir.html", std::move(data));
270 res.set_content(result, "text/html");
198} 271}
199 272
200void App::handleLogin(Response& res) const 273void App::handleLogin(Response& res) const
@@ -253,11 +326,8 @@ void App::setup()
253 } 326 }
254 } 327 }
255 328
256 server.Get(getPath("index"), [&]([[maybe_unused]] const Request& req, Response& res) 329 server.Get(getPath("login"), [&]([[maybe_unused]] const Request& req,
257 { 330 Response& res)
258 handleIndex(res);
259 });
260 server.Get(getPath("login"), [&]([[maybe_unused]] const Request& req, Response& res)
261 { 331 {
262 handleLogin(res); 332 handleLogin(res);
263 }); 333 });
@@ -265,6 +335,16 @@ void App::setup()
265 { 335 {
266 handleOpenIDRedirect(req, res); 336 handleOpenIDRedirect(req, res);
267 }); 337 });
338 server.Get(getPath("index"), [&]([[maybe_unused]] const Request& req,
339 Response& res)
340 {
341 handleIndex(res);
342 });
343 server.Get(getPath("dir", "owner_or_id"),
344 [&]([[maybe_unused]] const Request& req, Response& res)
345 {
346 handleDir(req, res);
347 });
268} 348}
269 349
270mw::E<App::SessionValidation> App::validateSession(const Request& req) const 350mw::E<App::SessionValidation> App::validateSession(const Request& req) const
@@ -276,7 +356,7 @@ mw::E<App::SessionValidation> App::validateSession(const Request& req) const
276 } 356 }
277 357
278 auto cookies = parseCookies(req.get_header_value("Cookie")); 358 auto cookies = parseCookies(req.get_header_value("Cookie"));
279 if(auto it = cookies.find("shrt-access-token"); 359 if(auto it = cookies.find("webdir-access-token");
280 it != std::end(cookies)) 360 it != std::end(cookies))
281 { 361 {
282 spdlog::debug("Cookie has access token."); 362 spdlog::debug("Cookie has access token.");
@@ -289,7 +369,7 @@ mw::E<App::SessionValidation> App::validateSession(const Request& req) const
289 } 369 }
290 } 370 }
291 // No access token or access token expired 371 // No access token or access token expired
292 if(auto it = cookies.find("shrt-refresh-token"); 372 if(auto it = cookies.find("webdir-refresh-token");
293 it != std::end(cookies)) 373 it != std::end(cookies))
294 { 374 {
295 spdlog::debug("Cookie has refresh token."); 375 spdlog::debug("Cookie has refresh token.");
diff --git a/src/app.hpp b/src/app.hpp
index 09c7143..02a1206 100644
--- a/src/app.hpp
+++ b/src/app.hpp
@@ -27,9 +27,10 @@ public:
27 std::string urlFor(const std::string& name, const std::string& arg="") 27 std::string urlFor(const std::string& name, const std::string& arg="")
28 const; 28 const;
29 29
30 void handleIndex(Response& res) const;
31 void handleLogin(Response& res) const; 30 void handleLogin(Response& res) const;
32 void handleOpenIDRedirect(const Request& req, Response& res) const; 31 void handleOpenIDRedirect(const Request& req, Response& res) const;
32 void handleIndex(Response& res) const;
33 void handleDir(const Request& req, Response& res);
33 34
34private: 35private:
35 void setup() override; 36 void setup() override;
@@ -63,10 +64,10 @@ private:
63 // this case if this function does return a value, it would never 64 // this case if this function does return a value, it would never
64 // be an invalid session. 65 // be an invalid session.
65 // 66 //
66 // If “allow_error_and_invalid” is true, failure to query and 67 // If “allow_error_and_invalid” is true, this function will never
67 // invalid session are considered ok, and no status and body would 68 // return nullopt. Failure to query and invalid session are
68 // be set in “res”. In this case this function just returns an 69 // considered ok, and no status and body would be set in “res”. In
69 // invalid session. 70 // this case this function just returns an invalid session.
70 std::optional<SessionValidation> prepareSession( 71 std::optional<SessionValidation> prepareSession(
71 const Request& req, Response& res, 72 const Request& req, Response& res,
72 bool allow_error_and_invalid=false) const; 73 bool allow_error_and_invalid=false) const;
diff --git a/src/app_test.cpp b/src/app_test.cpp
index 06f2084..d1f41a5 100644
--- a/src/app_test.cpp
+++ b/src/app_test.cpp
@@ -19,46 +19,47 @@ using ::testing::HasSubstr;
19using ::testing::FieldsAre; 19using ::testing::FieldsAre;
20using ::testing::ContainsRegex; 20using ::testing::ContainsRegex;
21 21
22// class UserAppTest : public testing::Test 22class UserAppTest : public testing::Test
23// { 23{
24// protected: 24protected:
25// UserAppTest() 25 UserAppTest()
26// { 26 {
27// config.base_url = "http://localhost:8080/"; 27 config.base_url = "http://localhost:8080/";
28// config.listen_address = "localhost"; 28 config.listen_address = "localhost";
29// config.listen_port = 8080; 29 config.listen_port = 8080;
30// config.data_dir = "."; 30 config.data_dir = ".";
31 31
32// auto auth = std::make_unique<mw::AuthMock>(); 32 auto auth = std::make_unique<mw::AuthMock>();
33 33
34// mw::UserInfo expected_user; 34 mw::UserInfo expected_user;
35// expected_user.name = "mw"; 35 expected_user.name = "mw";
36// expected_user.id = "mw"; 36 expected_user.id = "mw";
37// mw::Tokens token; 37 mw::Tokens token;
38// token.access_token = "aaa"; 38 token.access_token = "aaa";
39// EXPECT_CALL(*auth, getUser(std::move(token))) 39 EXPECT_CALL(*auth, getUser(std::move(token)))
40// .Times(::testing::AtLeast(0)) 40 .Times(::testing::AtLeast(0))
41// .WillRepeatedly(Return(expected_user)); 41 .WillRepeatedly(Return(expected_user));
42// auto data = std::make_unique<DataSourceMock>(); 42 auto data = std::make_unique<DataSourceMock>();
43// data_source = data.get(); 43 data_source = data.get();
44 44
45// app = std::make_unique<App>(config, std::move(data), std::move(auth)); 45 app = std::make_unique<App>(config, std::move(data), std::move(auth));
46// } 46 }
47 47
48// Configuration config; 48 Configuration config;
49// std::unique_ptr<App> app; 49 std::unique_ptr<App> app;
50// const DataSourceMock* data_source; 50 const DataSourceMock* data_source;
51// }; 51};
52 52
53// TEST_F(UserAppTest, CanDenyAccessToLinkList) 53TEST_F(UserAppTest, CanShowItemsOfDefaultUser)
54// { 54{
55// EXPECT_TRUE(mw::isExpected(app->start())); 55 EXPECT_TRUE(mw::isExpected(app->start()));
56// { 56 {
57// mw::HTTPSession client; 57 mw::HTTPSession client;
58// ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( 58 ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get(
59// mw::HTTPRequest("http://localhost:8080/_/links"))); 59 mw::HTTPRequest("http://localhost:8080/")));
60// EXPECT_EQ(res->status, 401); 60 EXPECT_EQ(res->status, 301);
61// } 61 EXPECT_EQ(res->header.at("Location"), "http://localhost:8080/mw");
62// app->stop(); 62 }
63// app->wait(); 63 app->stop();
64// } 64 app->wait();
65}
diff --git a/src/data.cpp b/src/data.cpp
index d6c4c5f..4960afc 100644
--- a/src/data.cpp
+++ b/src/data.cpp
@@ -6,6 +6,7 @@
6#include <mw/database.hpp> 6#include <mw/database.hpp>
7#include <mw/error.hpp> 7#include <mw/error.hpp>
8#include <mw/utils.hpp> 8#include <mw/utils.hpp>
9#include <utility>
9 10
10#include "data.hpp" 11#include "data.hpp"
11 12
@@ -39,6 +40,18 @@ LinkItem linkFromTuple(const LinkTuple& t)
39 40
40} // namespace 41} // namespace
41 42
43std::string LinkItem::visibilityToStr(Visibility v)
44{
45 switch(v)
46 {
47 case PUBLIC:
48 return "public";
49 case PRIVATE:
50 return "private";
51 }
52 std::unreachable();
53}
54
42mw::E<std::unique_ptr<DataSourceSQLite>> 55mw::E<std::unique_ptr<DataSourceSQLite>>
43DataSourceSQLite::fromFile(const std::string& db_file) 56DataSourceSQLite::fromFile(const std::string& db_file)
44{ 57{
@@ -53,7 +66,7 @@ DataSourceSQLite::fromFile(const std::string& db_file)
53 DO_OR_RETURN(data_source->setSchemaVersion(1)); 66 DO_OR_RETURN(data_source->setSchemaVersion(1));
54 DO_OR_RETURN(data_source->db->execute( 67 DO_OR_RETURN(data_source->db->execute(
55 "CREATE TABLE IF NOT EXISTS Users " 68 "CREATE TABLE IF NOT EXISTS Users "
56 "(id INTEGER PRIMARY KEY, openid_uid TEXT, name TEXT);")); 69 "(id INTEGER PRIMARY KEY, openid_uid TEXT UNIQUE, name TEXT UNIQUE);"));
57 DO_OR_RETURN(data_source->db->execute( 70 DO_OR_RETURN(data_source->db->execute(
58 "CREATE TABLE IF NOT EXISTS LinkItems " 71 "CREATE TABLE IF NOT EXISTS LinkItems "
59 "(id INTEGER PRIMARY KEY, owner_id INTEGER NOT NULL," 72 "(id INTEGER PRIMARY KEY, owner_id INTEGER NOT NULL,"
@@ -80,25 +93,138 @@ mw::E<void> DataSourceSQLite::setSchemaVersion(int64_t v) const
80 return db->execute(std::format("PRAGMA user_version = {};", v)); 93 return db->execute(std::format("PRAGMA user_version = {};", v));
81} 94}
82 95
83mw::E<std::vector<LinkItem>> 96mw::E<std::optional<User>>
84DataSourceSQLite::items(std::optional<int64_t> parent) 97DataSourceSQLite::userByOpenIDUID(const std::string& uid) const
98{
99 if(db == nullptr)
100 {
101 return std::unexpected(mw::runtimeError("Database is not connected."));
102 }
103 ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr(
104 "SELECT id, openid_uid, name FROM Users WHERE openid_uid = ?;"));
105 DO_OR_RETURN(sql.bind(uid));
106 ASSIGN_OR_RETURN(auto users, (db->eval<int64_t, std::string, std::string>(
107 std::move(sql))));
108 if(users.empty())
109 {
110 return std::nullopt;
111 }
112 if(users.size() > 1)
113 {
114 return std::unexpected(mw::runtimeError("Found duplicated users"));
115 }
116 User u;
117 u.id = std::get<0>(users[0]);
118 u.openid_uid = std::get<1>(users[0]);
119 u.name = std::get<2>(users[0]);
120 return u;
121}
122
123mw::E<int64_t> DataSourceSQLite::addUser(User&& u) const
85{ 124{
125 if(db == nullptr)
126 {
127 return std::unexpected(mw::runtimeError("Database is not connected."));
128 }
129 ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr(
130 "INSERT INTO Users (id, openid_uid, name) VALUES (NULL, ?, ?)"));
131 DO_OR_RETURN(sql.bind(u.openid_uid, u.name));
132 DO_OR_RETURN(db->execute(std::move(sql)));
133 return db->lastInsertRowID();
134}
135
136mw::E<std::optional<User>> DataSourceSQLite::userByID(const int64_t id) const
137{
138 if(db == nullptr)
139 {
140 return std::unexpected(mw::runtimeError("Database is not connected."));
141 }
142 ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr(
143 "SELECT id, openid_uid, name FROM Users WHERE id = ?;"));
144 DO_OR_RETURN(sql.bind(id));
145 ASSIGN_OR_RETURN(auto users, (db->eval<int64_t, std::string, std::string>(
146 std::move(sql))));
147 if(users.empty())
148 {
149 return std::nullopt;
150 }
151 if(users.size() > 1)
152 {
153 return std::unexpected(mw::runtimeError("Found duplicated users"));
154 }
155 User u;
156 u.id = std::get<0>(users[0]);
157 u.openid_uid = std::get<1>(users[0]);
158 u.name = std::get<2>(users[0]);
159 return u;
160}
161
162mw::E<std::optional<User>> DataSourceSQLite::userByName(const std::string& name)
163 const
164{
165 if(db == nullptr)
166 {
167 return std::unexpected(mw::runtimeError("Database is not connected."));
168 }
169 ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr(
170 "SELECT id, openid_uid, name FROM Users WHERE name = ?;"));
171 DO_OR_RETURN(sql.bind(name));
172 ASSIGN_OR_RETURN(auto users, (db->eval<int64_t, std::string, std::string>(
173 std::move(sql))));
174 if(users.empty())
175 {
176 return std::nullopt;
177 }
178 if(users.size() > 1)
179 {
180 return std::unexpected(mw::runtimeError("Found duplicated users"));
181 }
182 User u;
183 u.id = std::get<0>(users[0]);
184 u.openid_uid = std::get<1>(users[0]);
185 u.name = std::get<2>(users[0]);
186 return u;
187}
188
189mw::E<std::optional<LinkItem>> DataSourceSQLite::itemByID(int64_t id) const
190{
191 if(db == nullptr)
192 {
193 return std::unexpected(mw::runtimeError("Database is not connected."));
194 }
86 std::vector<LinkTuple> links; 195 std::vector<LinkTuple> links;
87 if(!parent.has_value()) 196 // Querying root-level links.
197 ASSIGN_OR_RETURN(mw::SQLiteStatement stat, db->statementFromStr(
198 "SELECT id, owner_id, parent_id, name, url, description, visibility,"
199 " time from LinkItems WHERE id = ?;"));
200 DO_OR_RETURN(stat.bind(id));
201 ASSIGN_OR_RETURN(links, (db->eval<LINK_TUPLE_TYPES>(std::move(stat))));
202 if(links.empty())
88 { 203 {
89 // Querying root-level links. 204 return std::nullopt;
90 ASSIGN_OR_RETURN(links, (db->eval<LINK_TUPLE_TYPES>(
91 "SELECT id, owner_id, parent_id, name, url, description, visibility,"
92 " time from LinkItems WHERE parent_id IS NULL;")));
93 } 205 }
94 else 206 if(links.size() > 1)
207 {
208 return std::unexpected(mw::runtimeError("Duplicate item ID"));
209 }
210
211 return linkFromTuple(links[0]);
212}
213
214mw::E<std::vector<LinkItem>> DataSourceSQLite::itemsByParent(int64_t parent)
215 const
216{
217 if(db == nullptr)
95 { 218 {
96 ASSIGN_OR_RETURN(mw::SQLiteStatement stat, db->statementFromStr( 219 return std::unexpected(mw::runtimeError("Database is not connected."));
97 "SELECT id, owner_id, parent_id, name, url, description, visibility,"
98 " time from LinkItems WHERE parent_id = ?;"));
99 DO_OR_RETURN(stat.bind<int64_t>(*parent));
100 ASSIGN_OR_RETURN(links, (db->eval<LINK_TUPLE_TYPES>(std::move(stat))));
101 } 220 }
221 std::vector<LinkTuple> links;
222 // Querying root-level links.
223 ASSIGN_OR_RETURN(mw::SQLiteStatement stat, db->statementFromStr(
224 "SELECT id, owner_id, parent_id, name, url, description, visibility,"
225 " time from LinkItems WHERE parent_id = ?;"));
226 DO_OR_RETURN(stat.bind(parent));
227 ASSIGN_OR_RETURN(links, (db->eval<LINK_TUPLE_TYPES>(std::move(stat))));
102 std::vector<LinkItem> result; 228 std::vector<LinkItem> result;
103 for(const LinkTuple& t : links) 229 for(const LinkTuple& t : links)
104 { 230 {
@@ -107,8 +233,34 @@ DataSourceSQLite::items(std::optional<int64_t> parent)
107 return result; 233 return result;
108} 234}
109 235
110mw::E<int64_t> DataSourceSQLite::addLink(LinkItem&& link) 236mw::E<std::vector<LinkItem>>
237DataSourceSQLite::itemsTopLevelByUser(int64_t user_id) const
111{ 238{
239 if(db == nullptr)
240 {
241 return std::unexpected(mw::runtimeError("Database is not connected."));
242 }
243 std::vector<LinkTuple> links;
244 // Querying root-level links.
245 ASSIGN_OR_RETURN(mw::SQLiteStatement stat, db->statementFromStr(
246 "SELECT id, owner_id, parent_id, name, url, description, visibility,"
247 " time from LinkItems WHERE parent_id IS NULL AND owner_id = ?;"));
248 DO_OR_RETURN(stat.bind(user_id));
249 ASSIGN_OR_RETURN(links, (db->eval<LINK_TUPLE_TYPES>(std::move(stat))));
250 std::vector<LinkItem> result;
251 for(const LinkTuple& t : links)
252 {
253 result.push_back(linkFromTuple(t));
254 }
255 return result;
256}
257
258mw::E<int64_t> DataSourceSQLite::addLink(LinkItem&& link) const
259{
260 if(db == nullptr)
261 {
262 return std::unexpected(mw::runtimeError("Database is not connected."));
263 }
112 ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr( 264 ASSIGN_OR_RETURN(mw::SQLiteStatement sql, db->statementFromStr(
113 "INSERT INTO LinkItems (id, owner_id, parent_id, name, url," 265 "INSERT INTO LinkItems (id, owner_id, parent_id, name, url,"
114 " description, visibility, time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")); 266 " description, visibility, time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"));
diff --git a/src/data.hpp b/src/data.hpp
index 006f1f2..45e867a 100644
--- a/src/data.hpp
+++ b/src/data.hpp
@@ -12,6 +12,7 @@
12struct LinkItem 12struct LinkItem
13{ 13{
14 enum Visibility {PUBLIC, PRIVATE}; 14 enum Visibility {PUBLIC, PRIVATE};
15 static std::string visibilityToStr(Visibility v);
15 16
16 int64_t id; 17 int64_t id;
17 int64_t owner_id; 18 int64_t owner_id;
@@ -43,8 +44,18 @@ public:
43 // help with database migration. 44 // help with database migration.
44 virtual mw::E<int64_t> getSchemaVersion() const = 0; 45 virtual mw::E<int64_t> getSchemaVersion() const = 0;
45 46
46 virtual mw::E<std::vector<LinkItem>> items(std::optional<int64_t> parent) = 0; 47 virtual mw::E<std::optional<User>>
47 virtual mw::E<int64_t> addLink(LinkItem&& link) = 0; 48 userByOpenIDUID(const std::string& uid) const = 0;
49 virtual mw::E<int64_t> addUser(User&& u) const = 0;
50 virtual mw::E<std::optional<User>> userByID(const int64_t) const = 0;
51 virtual mw::E<std::optional<User>> userByName(const std::string& name) const = 0;
52 virtual mw::E<std::optional<LinkItem>> itemByID(int64_t id) const = 0;
53 // Get all children of “parent”.
54 virtual mw::E<std::vector<LinkItem>> itemsByParent(int64_t parent) const = 0;
55 // Get all top-level items owned by “username”.
56 virtual mw::E<std::vector<LinkItem>>
57 itemsTopLevelByUser(int64_t user_id) const = 0;
58 virtual mw::E<int64_t> addLink(LinkItem&& link) const = 0;
48 59
49protected: 60protected:
50 virtual mw::E<void> setSchemaVersion(int64_t v) const = 0; 61 virtual mw::E<void> setSchemaVersion(int64_t v) const = 0;
@@ -63,8 +74,16 @@ public:
63 74
64 mw::E<int64_t> getSchemaVersion() const override; 75 mw::E<int64_t> getSchemaVersion() const override;
65 76
66 mw::E<std::vector<LinkItem>> items(std::optional<int64_t> parent) override; 77 mw::E<std::optional<User>>
67 mw::E<int64_t> addLink(LinkItem&& link) override; 78 userByOpenIDUID(const std::string& uid) const override;
79 mw::E<int64_t> addUser(User&& u) const override;
80 mw::E<std::optional<User>> userByID(const int64_t) const override;
81 mw::E<std::optional<User>> userByName(const std::string& name) const override;
82 mw::E<std::optional<LinkItem>> itemByID(int64_t id) const override;
83 mw::E<std::vector<LinkItem>> itemsByParent(int64_t parent) const override;
84 mw::E<std::vector<LinkItem>> itemsTopLevelByUser(int64_t user_id) const override;
85
86 mw::E<int64_t> addLink(LinkItem&& link) const override;
68 87
69 // Do not use. 88 // Do not use.
70 DataSourceSQLite() = default; 89 DataSourceSQLite() = default;
diff --git a/src/data_mock.hpp b/src/data_mock.hpp
index 363eb9e..2740165 100644
--- a/src/data_mock.hpp
+++ b/src/data_mock.hpp
@@ -15,6 +15,20 @@ public:
15 ~DataSourceMock() override = default; 15 ~DataSourceMock() override = default;
16 16
17 MOCK_METHOD(mw::E<int64_t>, getSchemaVersion, (), (const override)); 17 MOCK_METHOD(mw::E<int64_t>, getSchemaVersion, (), (const override));
18 MOCK_METHOD(mw::E<std::optional<User>>, userByOpenIDUID,
19 (const std::string& uid), (const override));
20 MOCK_METHOD(mw::E<int64_t>, addUser, (User&& u), (const override));
21 MOCK_METHOD(mw::E<std::optional<User>>, userByID, (const int64_t),
22 (const override));
23 MOCK_METHOD(mw::E<std::optional<User>>, userByName,
24 (const std::string& name), (const override));
25 MOCK_METHOD(mw::E<std::optional<LinkItem>>, itemByID, (int64_t id),
26 (const override));
27 MOCK_METHOD(mw::E<std::vector<LinkItem>>, itemsByParent, (int64_t parent),
28 (const override));
29 MOCK_METHOD(mw::E<std::vector<LinkItem>>, itemsTopLevelByUser,
30 (int64_t user_id), (const override));
31 MOCK_METHOD(mw::E<int64_t>, addLink, (LinkItem&& link), (const override));
18 32
19protected: 33protected:
20 mw::E<void> setSchemaVersion([[maybe_unused]] int64_t v) const override 34 mw::E<void> setSchemaVersion([[maybe_unused]] int64_t v) const override
diff --git a/src/data_test.cpp b/src/data_test.cpp
index 633a102..309d44f 100644
--- a/src/data_test.cpp
+++ b/src/data_test.cpp
@@ -10,10 +10,48 @@
10 10
11using ::testing::IsEmpty; 11using ::testing::IsEmpty;
12 12
13TEST(DataSource, CanGetUser)
14{
15 ASSIGN_OR_FAIL(std::unique_ptr<DataSourceSQLite> data,
16 DataSourceSQLite::newFromMemory());
17 User u;
18 u.openid_uid = "aaa";
19 u.name = "bbb";
20 EXPECT_TRUE(data->addUser(std::move(u)).has_value());
21 ASSIGN_OR_FAIL(auto user_maybe, data->userByOpenIDUID("aaa"));
22 EXPECT_TRUE(user_maybe.has_value());
23 EXPECT_EQ(user_maybe->name, "bbb");
24
25 ASSIGN_OR_FAIL(user_maybe, data->userByOpenIDUID("bbb"));
26 EXPECT_FALSE(user_maybe.has_value());
27}
28
29TEST(DataSource, WontAddDuplicateUser)
30{
31 ASSIGN_OR_FAIL(std::unique_ptr<DataSourceSQLite> data,
32 DataSourceSQLite::newFromMemory());
33 User u0;
34 u0.openid_uid = "aaa";
35 u0.name = "bbb";
36 User u1;
37 u1.openid_uid = "aaa";
38 u1.name = "ccc";
39 User u2;
40 u2.openid_uid = "ddd";
41 u2.name = "bbb";
42 EXPECT_TRUE(data->addUser(std::move(u0)).has_value());
43 EXPECT_FALSE(data->addUser(std::move(u1)).has_value());
44 EXPECT_FALSE(data->addUser(std::move(u2)).has_value());
45}
46
13TEST(DataSource, CanAddLink) 47TEST(DataSource, CanAddLink)
14{ 48{
15 ASSIGN_OR_FAIL(std::unique_ptr<DataSourceSQLite> data, 49 ASSIGN_OR_FAIL(std::unique_ptr<DataSourceSQLite> data,
16 DataSourceSQLite::newFromMemory()); 50 DataSourceSQLite::newFromMemory());
51 User u;
52 u.openid_uid = "aaa";
53 u.name = "bbb";
54 EXPECT_TRUE(data->addUser(std::move(u)).has_value());
17 LinkItem l0; 55 LinkItem l0;
18 l0.owner_id = 1; 56 l0.owner_id = 1;
19 l0.parent_id = std::nullopt; 57 l0.parent_id = std::nullopt;
@@ -34,8 +72,10 @@ TEST(DataSource, CanAddLink)
34 EXPECT_EQ(l0id, 1); 72 EXPECT_EQ(l0id, 1);
35 ASSIGN_OR_FAIL(int64_t l1id, data->addLink(std::move(l1))); 73 ASSIGN_OR_FAIL(int64_t l1id, data->addLink(std::move(l1)));
36 EXPECT_EQ(l1id, 2); 74 EXPECT_EQ(l1id, 2);
37 ASSIGN_OR_FAIL(auto ls, data->items(1)); 75 ASSIGN_OR_FAIL(auto ls, data->itemsTopLevelByUser(1));
76 ASSERT_EQ(ls.size(), 1);
77 EXPECT_EQ(ls[0].name, "aaa");
78 ASSIGN_OR_FAIL(ls, data->itemsByParent(1));
38 ASSERT_EQ(ls.size(), 1); 79 ASSERT_EQ(ls.size(), 1);
39 EXPECT_EQ(ls[0].parent_id, 1); 80 EXPECT_EQ(ls[0].name, "ddd");
40 EXPECT_EQ(ls[0].visibility, LinkItem::PRIVATE);
41} 81}
diff --git a/templates/dir.html b/templates/dir.html
new file mode 100644
index 0000000..6cfdcb4
--- /dev/null
+++ b/templates/dir.html
@@ -0,0 +1,17 @@
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 {% include "head.html" %}
5 <meta property="og:title" content="{{ owner }}’s web index">
6 <meta property="og:type" content="website">
7 <meta property="og:url" content="{{ this_url }}">
8 <title>shrt</title>
9 </head>
10 <body>
11 <div id="Body">
12 {% include "nav.html" %}
13 <div>{{ items }}</div>
14 {% include "footer.html" %}
15 </div>
16 </body>
17</html>
diff --git a/templates/links.html b/templates/links.html
deleted file mode 100644
index f3b3e3f..0000000
--- a/templates/links.html
+++ /dev/null
@@ -1,39 +0,0 @@
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 {% include "head.html" %}
5 <meta property="og:title" content="shrt">
6 <meta property="og:type" content="website">
7 <meta property="og:url" content="{{ url_for("links") }}">
8 <title>shrt</title>
9 </head>
10 <body>
11 <div id="Body" class="Window">
12 {% include "nav.html" %}
13 <div class="Toolbar">
14 <a class="FloatButton" href="{{ url_for("new-link") }}">➕</a>
15 </div>
16 <div id="Links">
17 <table class="TableView">
18 <thead><tr>
19 <th>Shortcut</th>
20 <th>URL</th>
21 <th>Regexp?</th>
22 <th>Actions</th>
23 </tr></thead>
24 <tbody>
25 {% for link in links %}
26 <tr>
27 <td>{{ link.shortcut }}</td>
28 <td>{{ link.original_url }}</td>
29 <td>{{ link.type_is_regexp_str }}</td>
30 <td><a href="{{ url_for("delete-link", link.id_str) }}">❌</a></td>
31 </tr>
32 {% endfor %}
33 </tbody>
34 </table>
35 </div>
36 {% include "footer.html" %}
37 </div>
38 </body>
39</html>