diff options
author | MetroWind <chris.corsair@gmail.com> | 2025-09-21 21:34:34 -0700 |
---|---|---|
committer | MetroWind <chris.corsair@gmail.com> | 2025-09-21 21:34:34 -0700 |
commit | e9686b6ab684785d5f9acbc98942beae94817562 (patch) | |
tree | 10ffe1b7b209aee2f0513cd0c42def2c07272ea2 | |
parent | b2e812941766e11394bdb124ff73d1fe544849a2 (diff) |
-rw-r--r-- | src/app.cpp | 150 | ||||
-rw-r--r-- | src/app.hpp | 11 | ||||
-rw-r--r-- | src/app_test.cpp | 77 | ||||
-rw-r--r-- | src/data.cpp | 182 | ||||
-rw-r--r-- | src/data.hpp | 27 | ||||
-rw-r--r-- | src/data_mock.hpp | 14 | ||||
-rw-r--r-- | src/data_test.cpp | 46 | ||||
-rw-r--r-- | templates/dir.html | 17 | ||||
-rw-r--r-- | templates/links.html | 39 |
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 | ||
114 | nlohmann::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 | ||
116 | App::App(const Configuration& conf, | 131 | App::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 | ||
195 | void App::handleIndex(Response& res) const | 189 | void App::handleIndex(Response& res) const |
196 | { | 190 | { |
197 | res.set_redirect(urlFor("links"), 301); | 191 | res.set_redirect(urlFor("dir", "mw"), 301); |
192 | } | ||
193 | |||
194 | void 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 | ||
200 | void App::handleLogin(Response& res) const | 273 | void 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 | ||
270 | mw::E<App::SessionValidation> App::validateSession(const Request& req) const | 350 | mw::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 | ||
34 | private: | 35 | private: |
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; | |||
19 | using ::testing::FieldsAre; | 19 | using ::testing::FieldsAre; |
20 | using ::testing::ContainsRegex; | 20 | using ::testing::ContainsRegex; |
21 | 21 | ||
22 | // class UserAppTest : public testing::Test | 22 | class UserAppTest : public testing::Test |
23 | // { | 23 | { |
24 | // protected: | 24 | protected: |
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) | 53 | TEST_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 | ||
43 | std::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 | |||
42 | mw::E<std::unique_ptr<DataSourceSQLite>> | 55 | mw::E<std::unique_ptr<DataSourceSQLite>> |
43 | DataSourceSQLite::fromFile(const std::string& db_file) | 56 | DataSourceSQLite::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 | ||
83 | mw::E<std::vector<LinkItem>> | 96 | mw::E<std::optional<User>> |
84 | DataSourceSQLite::items(std::optional<int64_t> parent) | 97 | DataSourceSQLite::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 | |||
123 | mw::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 | |||
136 | mw::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 | |||
162 | mw::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 | |||
189 | mw::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 | |||
214 | mw::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 | ||
110 | mw::E<int64_t> DataSourceSQLite::addLink(LinkItem&& link) | 236 | mw::E<std::vector<LinkItem>> |
237 | DataSourceSQLite::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 | |||
258 | mw::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 @@ | |||
12 | struct LinkItem | 12 | struct 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 | ||
49 | protected: | 60 | protected: |
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 | ||
19 | protected: | 33 | protected: |
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 | ||
11 | using ::testing::IsEmpty; | 11 | using ::testing::IsEmpty; |
12 | 12 | ||
13 | TEST(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 | |||
29 | TEST(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 | |||
13 | TEST(DataSource, CanAddLink) | 47 | TEST(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> | ||