From ea0d3220db995018335c48eb06b9794235ff436b Mon Sep 17 00:00:00 2001 From: MetroWind Date: Sun, 7 Sep 2025 09:42:33 -0700 Subject: Initial commit, mostly just copied from shrt. --- src/app.cpp | 342 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/app.hpp | 84 ++++++++++++++ src/app_test.cpp | 205 ++++++++++++++++++++++++++++++++ src/config.cpp | 85 ++++++++++++++ src/config.hpp | 23 ++++ src/data.cpp | 54 +++++++++ src/data.hpp | 73 ++++++++++++ src/data_mock.hpp | 24 ++++ src/data_test.cpp | 17 +++ src/main.cpp | 29 +++++ 10 files changed, 936 insertions(+) create mode 100644 src/app.cpp create mode 100644 src/app.hpp create mode 100644 src/app_test.cpp create mode 100644 src/config.cpp create mode 100644 src/config.hpp create mode 100644 src/data.cpp create mode 100644 src/data.hpp create mode 100644 src/data_mock.hpp create mode 100644 src/data_test.cpp create mode 100644 src/main.cpp (limited to 'src') 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 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app.hpp" +#include "config.hpp" +#include "data.hpp" +#include "mw/error.hpp" + +namespace +{ + +std::unordered_map parseCookies(std::string_view value) +{ + std::unordered_map cookies; + size_t begin = 0; + while(true) + { + if(begin >= value.size()) + { + break; + } + + size_t semicolon = value.find(';', begin); + if(semicolon == std::string::npos) + { + semicolon = value.size(); + } + + std::string_view section = value.substr(begin, semicolon - begin); + + begin = semicolon + 1; + // Skip spaces + while(begin < value.size() && value[begin] == ' ') + { + begin++; + } + + size_t equal = section.find('='); + if(equal == std::string::npos) continue; + cookies.emplace(section.substr(0, equal), + section.substr(equal+1, semicolon - equal - 1)); + if(semicolon >= value.size()) + { + continue; + } + } + return cookies; +} + +void setTokenCookies(const mw::Tokens& tokens, App::Response& res) +{ + int64_t expire_sec = 300; + if(tokens.expiration.has_value()) + { + auto expire = std::chrono::duration_cast( + *tokens.expiration - mw::Clock::now()); + expire_sec = expire.count(); + } + res.set_header("Set-Cookie", std::format( + "shrt-access-token={}; Max-Age={}", + mw::urlEncode(tokens.access_token), expire_sec)); + // Add refresh token to cookie, with one month expiration. + if(tokens.refresh_token.has_value()) + { + expire_sec = 1800; + if(tokens.refresh_expiration.has_value()) + { + auto expire = std::chrono::duration_cast( + *tokens.refresh_expiration - mw::Clock::now()); + expire_sec = expire.count(); + } + + res.set_header("Set-Cookie", std::format( + "shrt-refresh-token={}; Max-Age={}", + mw::urlEncode(*tokens.refresh_token), expire_sec)); + } +} + +mw::HTTPServer::ListenAddress listenAddrFromConfig(const Configuration& config) +{ + if(config.listen_port == 0) + { + mw::SocketFileInfo sock(config.listen_address); + sock.user = config.socket_user; + sock.group = config.socket_group; + sock.permission = config.socket_permission; + return sock; + } + + mw::IPSocketInfo sock; + sock.address = config.listen_address; + sock.port = config.listen_port; + return sock; +} + +} // namespace + +App::App(const Configuration& conf, + std::unique_ptr data_source, + std::unique_ptr openid_auth) + : mw::HTTPServer(listenAddrFromConfig(conf)), + config(conf), + templates((std::filesystem::path(config.data_dir) / "templates" / "") + .string()), + data(std::move(data_source)), + auth(std::move(openid_auth)) +{ + auto u = mw::URL::fromStr(conf.base_url); + if(u.has_value()) + { + base_url = *std::move(u); + } + + templates.add_callback("url_for", [&](const inja::Arguments& args) -> + std::string + { + switch(args.size()) + { + case 1: + return urlFor(args.at(0)->get_ref()); + case 2: + return urlFor(args.at(0)->get_ref(), + args.at(1)->get_ref()); + default: + return "Invalid number of url_for() arguments"; + } + }); +} + +std::string App::urlFor(const std::string& name, const std::string& arg) const +{ + if(name == "statics") + { + return mw::URL(base_url).appendPath("_/statics").appendPath(arg).str(); + } + if(name == "index") + { + return base_url.str(); + } + if(name == "shortcut") + { + return mw::URL(base_url).appendPath(arg).str(); + } + if(name == "links") + { + return mw::URL(base_url).appendPath("_/links").str(); + } + if(name == "login") + { + return mw::URL(base_url).appendPath("_/login").str(); + } + if(name == "openid-redirect") + { + return mw::URL(base_url).appendPath("_/openid-redirect").str(); + } + if(name == "new-link") + { + return mw::URL(base_url).appendPath("_/new-link").str(); + } + if(name == "create-link") + { + return mw::URL(base_url).appendPath("_/create-link").str(); + } + if(name == "delete-link-dialog") + { + return mw::URL(base_url).appendPath("_/delete-link").appendPath(arg) + .str(); + } + if(name == "delete-link") + { + return mw::URL(base_url).appendPath("_/delete-link").str(); + } + + return ""; +} + +void App::handleIndex(Response& res) const +{ + res.set_redirect(urlFor("links"), 301); +} + +void App::handleLogin(Response& res) const +{ + res.set_redirect(auth->initialURL(), 301); +} + +void App::handleOpenIDRedirect(const Request& req, Response& res) const +{ + if(req.has_param("error")) + { + res.status = 500; + if(req.has_param("error_description")) + { + res.set_content( + std::format("{}: {}.", req.get_param_value("error"), + req.get_param_value("error_description")), + "text/plain"); + } + return; + } + else if(!req.has_param("code")) + { + res.status = 500; + res.set_content("No error or code in auth response", "text/plain"); + return; + } + + std::string code = req.get_param_value("code"); + spdlog::debug("OpenID server visited {} with code {}.", req.path, code); + ASSIGN_OR_RESPOND_ERROR(mw::Tokens tokens, auth->authenticate(code), res); + ASSIGN_OR_RESPOND_ERROR(mw::UserInfo user, auth->getUser(tokens), res); + + setTokenCookies(tokens, res); + res.set_redirect(urlFor("index"), 301); +} + + +std::string App::getPath(const std::string& name, + const std::string& arg_name) const +{ + return mw::URL::fromStr(urlFor(name, std::string(":") + arg_name)).value() + .path(); +} + +void App::setup() +{ + { + std::string statics_dir = (std::filesystem::path(config.data_dir) / + "statics").string(); + spdlog::info("Mounting static dir at {}...", statics_dir); + if (!server.set_mount_point("/_/statics", statics_dir)) + { + spdlog::error("Failed to mount statics"); + return; + } + } + + server.Get(getPath("index"), [&]([[maybe_unused]] const Request& req, Response& res) + { + handleIndex(res); + }); + server.Get(getPath("login"), [&]([[maybe_unused]] const Request& req, Response& res) + { + handleLogin(res); + }); + server.Get(getPath("openid-redirect"), [&](const Request& req, Response& res) + { + handleOpenIDRedirect(req, res); + }); +} + +mw::E App::validateSession(const Request& req) const +{ + if(!req.has_header("Cookie")) + { + spdlog::debug("Request has no cookie."); + return SessionValidation::invalid(); + } + + auto cookies = parseCookies(req.get_header_value("Cookie")); + if(auto it = cookies.find("shrt-access-token"); + it != std::end(cookies)) + { + spdlog::debug("Cookie has access token."); + mw::Tokens tokens; + tokens.access_token = it->second; + mw::E user = auth->getUser(tokens); + if(user.has_value()) + { + return SessionValidation::valid(*std::move(user)); + } + } + // No access token or access token expired + if(auto it = cookies.find("shrt-refresh-token"); + it != std::end(cookies)) + { + spdlog::debug("Cookie has refresh token."); + // Try to refresh the tokens. + ASSIGN_OR_RETURN(mw::Tokens tokens, auth->refreshTokens(it->second)); + ASSIGN_OR_RETURN(mw::UserInfo user, auth->getUser(tokens)); + return SessionValidation::refreshed(std::move(user), std::move(tokens)); + } + return SessionValidation::invalid(); +} + +std::optional App::prepareSession( + const Request& req, Response& res, bool allow_error_and_invalid) const +{ + mw::E session = validateSession(req); + if(!session.has_value()) + { + if(allow_error_and_invalid) + { + return SessionValidation::invalid(); + } + else + { + res.status = 500; + res.set_content("Failed to validate session.", "text/plain"); + return std::nullopt; + } + } + + switch(session->status) + { + case SessionValidation::INVALID: + if(allow_error_and_invalid) + { + return *session; + } + else + { + res.status = 401; + res.set_content("Invalid session.", "text/plain"); + return std::nullopt; + } + case SessionValidation::VALID: + break; + case SessionValidation::REFRESHED: + setTokenCookies(session->new_tokens, res); + break; + } + return *session; +} 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 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "data.hpp" +#include "config.hpp" + +class App : public mw::HTTPServer +{ +public: + using Request = mw::HTTPServer::Request; + using Response = mw::HTTPServer::Response; + + App() = delete; + App(const Configuration& conf, + std::unique_ptr data_source, + std::unique_ptr openid_auth); + + std::string urlFor(const std::string& name, const std::string& arg="") + const; + +private: + void setup() override; + + struct SessionValidation + { + enum { VALID, REFRESHED, INVALID } status; + mw::UserInfo user; + mw::Tokens new_tokens; + + static SessionValidation valid(mw::UserInfo&& user_info) + { + return {VALID, user_info, {}}; + } + + static SessionValidation refreshed(mw::UserInfo&& user_info, mw::Tokens&& tokens) + { + return {REFRESHED, user_info, tokens}; + } + + static SessionValidation invalid() + { + return {INVALID, {}, {}}; + } + }; + mw::E validateSession(const Request& req) const; + + // Query the auth module for the status of the session. If there + // is no session or it fails to query the auth module, set the + // status and body in “res” accordingly, and return nullopt. In + // this case if this function does return a value, it would never + // be an invalid session. + // + // If “allow_error_and_invalid” is true, failure to query and + // invalid session are considered ok, and no status and body would + // be set in “res”. In this case this function just returns an + // invalid session. + std::optional prepareSession( + const Request& req, Response& res, + bool allow_error_and_invalid=false) const; + + // This gives a path, optionally with the name of an argument, + // that is suitable to bind to a URL handler. For example, + // supposed the URL of the blog post with ID 1 is + // “http://some.domain/blog/p/1”. Calling “getPath("post", "id")” + // would give “/blog/p/:id”. This uses urlFor(), and therefore + // requires that the URL is mapped correctly in that function. + std::string getPath(const std::string& name, const std::string& arg_name="") + const; + + Configuration config; + mw::URL base_url; + inja::Environment templates; + std::unique_ptr data; + std::unique_ptr auth; +}; 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 @@ +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "app.hpp" +#include "config.hpp" +#include "data.hpp" +#include "data_mock.hpp" + +using ::testing::_; +using ::testing::Return; +using ::testing::HasSubstr; +using ::testing::FieldsAre; +using ::testing::ContainsRegex; + +void PrintTo(const ShortLink& link, std::ostream* os) +{ + *os << "ShortLink(id: " << link.id + << ", shortcut: " << link.shortcut + << ", original_url: " << link.original_url + << ", type: " << link.type + << ", user_id: " << link.user_id + << ", user_name: " << link.user_name + << ", visits: " << link.visits + << ", time_creation: " << link.time_creation << ")"; +} + +class UserAppTest : public testing::Test +{ +protected: + UserAppTest() + { + config.base_url = "http://localhost:8080/"; + config.listen_address = "localhost"; + config.listen_port = 8080; + config.data_dir = "."; + + auto auth = std::make_unique(); + + mw::UserInfo expected_user; + expected_user.name = "mw"; + expected_user.id = "mw"; + mw::Tokens token; + token.access_token = "aaa"; + EXPECT_CALL(*auth, getUser(std::move(token))) + .Times(::testing::AtLeast(0)) + .WillRepeatedly(Return(expected_user)); + auto data = std::make_unique(); + data_source = data.get(); + + app = std::make_unique(config, std::move(data), std::move(auth)); + } + + Configuration config; + std::unique_ptr app; + const DataSourceMock* data_source; +}; + +TEST_F(UserAppTest, CanDenyAccessToLinkList) +{ + EXPECT_TRUE(mw::isExpected(app->start())); + { + mw::HTTPSession client; + ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( + mw::HTTPRequest("http://localhost:8080/_/links"))); + EXPECT_EQ(res->status, 401); + } + app->stop(); + app->wait(); +} + +TEST_F(UserAppTest, CanHandleLinkList) +{ + std::vector links; + ShortLink link0; + link0.shortcut = "link0"; + link0.original_url = "a"; + link0.id = 1; + link0.user_id = "mw"; + link0.user_name = "mw"; + link0.type = ShortLink::NORMAL; + ShortLink link1; + link1.shortcut = "link1"; + link1.original_url = "b"; + link1.id = 2; + link1.user_id = "mw"; + link1.user_name = "mw"; + link1.type = ShortLink::REGEXP; + links.push_back(std::move(link0)); + links.push_back(std::move(link1)); + + EXPECT_CALL(*data_source, getAllLinks("mw")) + .WillOnce(Return(std::move(links))); + + EXPECT_TRUE(mw::isExpected(app->start())); + { + mw::HTTPSession client; + ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( + mw::HTTPRequest("http://localhost:8080/_/links") + .addHeader("Cookie", "shrt-access-token=aaa"))); + EXPECT_EQ(res->status, 200) << "Response body: " << res->payloadAsStr(); + EXPECT_THAT(res->payloadAsStr(), ContainsRegex("a[[:space:]]*-")); + EXPECT_THAT(res->payloadAsStr(), ContainsRegex("b[[:space:]]*✅")); + } + app->stop(); + app->wait(); +} + +TEST_F(UserAppTest, CanDenyAccessToNewLink) +{ + EXPECT_TRUE(mw::isExpected(app->start())); + { + mw::HTTPSession client; + ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( + mw::HTTPRequest("http://localhost:8080/_/new-link"))); + EXPECT_EQ(res->status, 401); + } + app->stop(); + app->wait(); +} + +TEST_F(UserAppTest, CanHandleNewLink) +{ + EXPECT_TRUE(mw::isExpected(app->start())); + { + mw::HTTPSession client; + ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get( + mw::HTTPRequest("http://localhost:8080/_/new-link") + .addHeader("Cookie", "shrt-access-token=aaa"))); + EXPECT_EQ(res->status, 200); + EXPECT_THAT(res->payloadAsStr(), HasSubstr("Create a New Link")); + } + app->stop(); + app->wait(); +} + +TEST_F(UserAppTest, CanDenyAccessToCreateLink) +{ + EXPECT_TRUE(mw::isExpected(app->start())); + { + mw::HTTPSession client; + ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.post( + mw::HTTPRequest("http://localhost:8080/_/create-link"))); + EXPECT_EQ(res->status, 401); + } + app->stop(); + app->wait(); +} + +TEST_F(UserAppTest, CanDenyHandleCreateLink) +{ + EXPECT_CALL(*data_source, addLink( + FieldsAre( + _, // id + "abc", // shortcut + "http://darksair.org", // original_url + ShortLink::NORMAL, // type + "mw", // user_id + "", // user_name + _, // visits + _))) // time_creation + .WillOnce(Return(mw::E())); + + EXPECT_CALL(*data_source, addLink( + FieldsAre( + _, // id + "xyz", // shortcut + "http://mws.rocks", // original_url + ShortLink::REGEXP, // type + "mw", // user_id + "", // user_name + _, // visits + _))) // time_creation + .WillOnce(Return(mw::E())); + + EXPECT_TRUE(mw::isExpected(app->start())); + { + mw::HTTPSession client; + ASSIGN_OR_FAIL(const mw::HTTPResponse* res1, client.post( + mw::HTTPRequest("http://localhost:8080/_/create-link") + .setPayload("shortcut=abc&original_url=http%3A%2F%2Fdarksair%2Eorg" + "®exp=off") + .addHeader("Cookie", "shrt-access-token=aaa") + .setContentType("application/x-www-form-urlencoded"))); + EXPECT_EQ(res1->status, 302); + EXPECT_EQ(res1->header.at("Location"), "http://localhost:8080/"); + + ASSIGN_OR_FAIL(const mw::HTTPResponse* res2, client.post( + mw::HTTPRequest("http://localhost:8080/_/create-link") + .setPayload("shortcut=xyz&original_url=http%3A%2F%2Fmws%2Erocks" + "®exp=on") + .addHeader("Cookie", "shrt-access-token=aaa") + .setContentType("application/x-www-form-urlencoded"))); + EXPECT_EQ(res2->status, 302); + EXPECT_EQ(res2->header.at("Location"), "http://localhost:8080/"); + } + app->stop(); + app->wait(); +} 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 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "config.hpp" + +namespace { + +mw::E> readFile(const std::filesystem::path& path) +{ + std::ifstream f(path, std::ios::binary); + std::vector content; + content.reserve(102400); + content.assign(std::istreambuf_iterator(f), + std::istreambuf_iterator()); + if(f.bad() || f.fail()) + { + return std::unexpected(mw::runtimeError( + std::format("Failed to read file {}", path.string()))); + } + + return content; +} + +} // namespace + +mw::E Configuration::fromYaml(const std::filesystem::path& path) +{ + auto buffer = readFile(path); + if(!buffer.has_value()) + { + return std::unexpected(buffer.error()); + } + + ryml::Tree tree = ryml::parse_in_place(ryml::to_substr(*buffer)); + Configuration config; + if(tree["listen-address"].readable()) + { + tree["listen-address"] >> config.listen_address; + } + if(tree["listen-port"].readable()) + { + tree["listen-port"] >> config.listen_port; + } + if(tree["socket-user"].readable()) + { + tree["socket-user"] >> config.socket_user; + } + if(tree["socket-group"].readable()) + { + tree["socket-group"] >> config.socket_group; + } + if(tree["socket-permission"].readable()) + { + tree["socket-permission"] >> config.socket_permission; + } + if(tree["base-url"].readable()) + { + tree["base-url"] >> config.base_url; + } + if(tree["data-dir"].readable()) + { + tree["data-dir"] >> config.data_dir; + } + if(tree["openid-url-prefix"].readable()) + { + tree["openid-url-prefix"] >> config.openid_url_prefix; + } + if(tree["client-id"].readable()) + { + tree["client-id"] >> config.client_id; + } + if(tree["client-secret"].readable()) + { + tree["client-secret"] >> config.client_secret; + } + + return mw::E{std::in_place, std::move(config)}; +} 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 @@ +#pragma once + +#include +#include + +#include + +struct Configuration +{ + std::string listen_address = "localhost"; + // Set this to 0 to listen to socket file. + int listen_port = 8123; + std::string socket_user = ""; + std::string socket_group = ""; + int socket_permission = 0; + std::string base_url = "http://localhost:8123/"; + std::string data_dir = "."; + std::string openid_url_prefix; + std::string client_id; + std::string client_secret; + + static mw::E fromYaml(const std::filesystem::path& path); +}; 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 @@ +#include +#include +#include + +#include +#include +#include + +#include "data.hpp" + +namespace +{ + +} // namespace + +mw::E> +DataSourceSQLite::fromFile(const std::string& db_file) +{ + auto data_source = std::make_unique(); + ASSIGN_OR_RETURN(data_source->db, mw::SQLite::connectFile(db_file)); + + // Perform schema upgrade here. + // + // data_source->upgradeSchema1To2(); + + // Update this line when schema updates. + DO_OR_RETURN(data_source->setSchemaVersion(1)); + DO_OR_RETURN(data_source->db->execute( + "CREATE TABLE IF NOT EXISTS Users " + "(id INTEGER PRIMARY KEY, openid_uid TEXT, name TEXT);")); + DO_OR_RETURN(data_source->db->execute( + "CREATE TABLE IF NOT EXISTS LinkItems " + "(id INTEGER PRIMARY KEY," + " FOREIGN KEY(owner_id) REFERENCES Users(id) NOT NULL," + " FOREIGN KEY(parent_id) REFERENCES LinkItems(id)," + " name TEXT NOT NULL, url TEXT, description TEXT," + " visibility INTEGER NOT NULL, time INTEGER NOT NULL);")); + return data_source; +} + +mw::E> DataSourceSQLite::newFromMemory() +{ + return fromFile(":memory:"); +} + +mw::E DataSourceSQLite::getSchemaVersion() const +{ + return db->evalToValue("PRAGMA user_version;"); +} + +mw::E DataSourceSQLite::setSchemaVersion(int64_t v) const +{ + return db->execute(std::format("PRAGMA user_version = {};", v)); +} 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 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +struct LinkItem +{ + enum Visibility {PUBLIC, PRIVATE}; + + int64_t id; + int64_t owner_id; + // Top-level items don’t have parents. + std::optional parent_id; + std::string name; + // If this is empty, it’s a parent. + std::string url; + std::string description; + Visibility visibility; + mw::Time time; +}; + +struct User +{ + int64_t id; + std::string openid_uid; + std::string name; +}; + +class DataSourceInterface +{ +public: + virtual ~DataSourceInterface() = default; + + // The schema version is the version of the database. It starts + // from 1. Every time the schema change, someone should increase + // this number by 1, manually, by hand. The intended use is to + // help with database migration. + virtual mw::E getSchemaVersion() const = 0; + + virtual std::vector items(std::optional parent) = 0; + +protected: + virtual mw::E setSchemaVersion(int64_t v) const = 0; +}; + +class DataSourceSQLite : public DataSourceInterface +{ +public: + explicit DataSourceSQLite(std::unique_ptr conn) + : db(std::move(conn)) {} + ~DataSourceSQLite() override = default; + + static mw::E> + fromFile(const std::string& db_file); + static mw::E> newFromMemory(); + + mw::E getSchemaVersion() const override; + + // Do not use. + DataSourceSQLite() = default; + +protected: + mw::E setSchemaVersion(int64_t v) const override; + +private: + std::unique_ptr db; +}; 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 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "data.hpp" + +class DataSourceMock : public DataSourceInterface +{ +public: + ~DataSourceMock() override = default; + + MOCK_METHOD(mw::E, getSchemaVersion, (), (const override)); + +protected: + mw::E setSchemaVersion([[maybe_unused]] int64_t v) const override + { + return {}; + } +}; 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 @@ +#include + +#include +#include +#include +#include +#include + +#include "data.hpp" + +using ::testing::IsEmpty; + +TEST(DataSource, CanAddAndDeleteLink) +{ + ASSIGN_OR_FAIL(std::unique_ptr data, + DataSourceSQLite::newFromMemory()); +} 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 @@ +#include + +#include +#include +#include +#include + +#include "config.hpp" +#include "data.hpp" +#include "app.hpp" + +int main(int argc, char** argv) +{ + cxxopts::Options cmd_options( + "shrt", "A naively simple URL shortener"); + cmd_options.add_options() + ("c,config", "Config file", + cxxopts::value()->default_value("/etc/shrt.yaml")) + ("h,help", "Print this message."); + auto opts = cmd_options.parse(argc, argv); + + if(opts.count("help")) + { + std::cout << cmd_options.help() << std::endl; + return 0; + } + + return 0; +} -- cgit v1.2.3-70-g09d2