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. --- CMakeLists.txt | 136 ++++++++++++++++ COPYING.txt | 20 +++ README.adoc | 103 ++++++++++++ packages/arch/PKGBUILD | 50 ++++++ packages/arch/shrt.install | 9 ++ packages/arch/shrt.service | 12 ++ packages/arch/shrt.yaml | 7 + packages/arch/sysusers-shrt.conf | 1 + 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 ++++ statics/styles.css | 290 +++++++++++++++++++++++++++++++++ templates/delete-link.html | 32 ++++ templates/footer.html | 5 + templates/head.html | 5 + templates/links.html | 39 +++++ templates/nav.html | 17 ++ templates/new-link.html | 36 +++++ 25 files changed, 1698 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 COPYING.txt create mode 100644 README.adoc create mode 100644 packages/arch/PKGBUILD create mode 100644 packages/arch/shrt.install create mode 100644 packages/arch/shrt.service create mode 100644 packages/arch/shrt.yaml create mode 100644 packages/arch/sysusers-shrt.conf 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 create mode 100644 statics/styles.css create mode 100644 templates/delete-link.html create mode 100644 templates/footer.html create mode 100644 templates/head.html create mode 100644 templates/links.html create mode 100644 templates/nav.html create mode 100644 templates/new-link.html diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..1378763 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,136 @@ +# cmake -DCMAKE_CXX_INCLUDE_WHAT_YOU_USE=include-what-you-use -B build . && cmake --build build -j +cmake_minimum_required(VERSION 3.24) + +set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE) + +# For whatever reason, the C++ stdlib is broken on my Mac. +# +# % echo "#include " | c++ -x c++ - +# :1:10: fatal error: 'vector' file not found +# 1 | #include +# | ^~~~~~~~ +# 1 error generated. +if(APPLE) + set(CMAKE_CXX_FLAGS "-I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1") +endif() + +project(Webdir) +option(WEBDIR_BUILD_TESTS "Build unit tests" OFF) + +include(FetchContent) +FetchContent_Declare( + libmw + GIT_REPOSITORY https://github.com/MetroWind/libmw.git +) + +FetchContent_Declare( + cxxopts + GIT_REPOSITORY https://github.com/jarro2783/cxxopts.git + GIT_TAG v3.1.1 +) + +FetchContent_Declare( + ryml + GIT_REPOSITORY https://github.com/biojppm/rapidyaml.git + GIT_TAG + GIT_SHALLOW FALSE # ensure submodules are checked out +) + +FetchContent_Declare( + spdlog + GIT_REPOSITORY https://github.com/gabime/spdlog.git + GIT_TAG v1.12.0 +) + +FetchContent_Declare( + json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 +) + +FetchContent_Declare( + inja + GIT_REPOSITORY https://github.com/pantor/inja.git + GIT_TAG main +) + +set(SPDLOG_USE_STD_FORMAT ON) +set(LIBMW_BUILD_URL ON) +set(LIBMW_BUILD_SQLITE ON) +set(LIBMW_BUILD_HTTP_SERVER ON) +set(INJA_USE_EMBEDDED_JSON FALSE) +set(INJA_BUILD_TESTS FALSE) +FetchContent_MakeAvailable(libmw ryml spdlog cxxopts json inja) + +if(WEBDIR_BUILD_TESTS) + FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz + ) + FetchContent_MakeAvailable(googletest) +endif() + +set(SOURCE_FILES + src/app.cpp + src/app.hpp + src/config.cpp + src/config.hpp + src/data.cpp + src/data.hpp +) + +set(LIBS + cxxopts + mw::mw + mw::url + mw::sqlite + mw::http-server + ryml::ryml + spdlog::spdlog +) + +set(INCLUDES + ${libmw_SOURCE_DIR}/includes + ${json_SOURCE_DIR}/single_include + ${inja_SOURCE_DIR}/single_include/inja +) + +add_executable(webdir ${SOURCE_FILES} src/main.cpp) +set_property(TARGET webdir PROPERTY CXX_STANDARD 23) +set_property(TARGET webdir PROPERTY CXX_EXTENSIONS FALSE) + +set_property(TARGET webdir PROPERTY COMPILE_WARNING_AS_ERROR TRUE) +target_compile_options(webdir PRIVATE -Wall -Wextra -Wpedantic) +target_include_directories(webdir PRIVATE ${INCLUDES}) +target_link_libraries(webdir PRIVATE ${LIBS}) + +if(WEBDIR_BUILD_TESTS) + set(TEST_FILES + src/data_mock.hpp + src/data_test.cpp + src/app_test.cpp + ) + + # ctest --test-dir build + add_executable(webdir_test ${SOURCE_FILES} ${TEST_FILES}) + set_property(TARGET webdir_test PROPERTY CXX_STANDARD 23) + set_property(TARGET webdir_test PROPERTY COMPILE_WARNING_AS_ERROR TRUE) + target_compile_options(webdir_test PRIVATE -Wall -Wextra -Wpedantic) + target_include_directories(webdir_test PRIVATE + ${INCLUDES} + ${googletest_SOURCE_DIR}/googletest/include + ${googletest_SOURCE_DIR}/googlemock/include + ) + + target_link_libraries(webdir_test PRIVATE + ${LIBS} + GTest::gtest_main + GTest::gmock_main + ) + + enable_testing() + include(GoogleTest) + gtest_discover_tests(webdir_test + # Need this so that the unit tests can find the templates. + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) +endif() diff --git a/COPYING.txt b/COPYING.txt new file mode 100644 index 0000000..10f9a1d --- /dev/null +++ b/COPYING.txt @@ -0,0 +1,20 @@ +Copyright (c) 2025 MetroWind + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..00ac6ac --- /dev/null +++ b/README.adoc @@ -0,0 +1,103 @@ += Shrt + +A naively simple URL shortener + +image::screenshot-0.webp[Screenshot] + +image::screenshot-1.webp[Screenshot] + +== Installation + +Only *NIX systems are supported. + +Runtime dependencies: + +- cURL +- OpenSSL +- SQLite + +Build dependencies: + +- CMake +- A C++23 compiler + +=== Building from source + +1. Clone the repo +2. Go into the repo directory, and run `cmake -B build`. +3. Run `cmake --build build -j`. +4. Pick a data directory where the database will be created. +5. Copy the `templates` and `statics` directories into the data +directory. +6. Copy `build/shrt` into any directory that is in your `$PATH`. + +=== Using pre-build binary + +1. Download the binary from one of the releases. +2. Extract the downloaded tarball. +3. Continue to step 4 in “Building from source”. + +=== Arch Linux package + +A PKGBUILD is provided in `packages/arch` in this repository. + +== Configuartion + +By default, shrt looks for a configuration file at `/etc/shrt.yaml`. +This path can be changed with the `-c` command line option. An example +configuration file is printed below with comments. + +[source,yaml] +---- +# Specify the data directory you have picked. +data-dir: "/var/lib/shrt" +# The listening address. Example: localhost, 0.0.0.0, 127.0.0.1. +listen-address: localhost +# The port to listen to. If this is 0, the value of “listen-address” +# is treated as a path of a UNIX domain socket file. +listen-port: 8080 +# The client ID of your shrt service. This is given by the OpenID +# Connect provider. +client-id: shrt +# The client secrete of your shrt service. This is given by the OpenID +# Connect provider. +client-secret: "abced12345" +# The initial URL of the OpenID Connect service. +openid-url-prefix: "https://auth.example.com/" +# The base URL of your shrt service. This is usually just “https://” +# followed by your domain name. +base-url: https://go.mws.rocks +---- + +=== Authentication + +Shrt relies on an external OpenID Connect service provider for +authentication. Usually you register your instance of shrt to the +OpenID Connect provider. The provider will give you an client ID and a +client secret (in this case your shrt server is the “client” of the +OpenID Connect service). An OpenID Connect service has a number of +endpoints, such as user info, tokens, etc. The URLs of those endpoints +are discover by visiting a URL that is constructed from the URL in the +configuration option `openid-url-prefix`, followed by +`.well-known/openid-configuration`. For example, if you set +`openid-url-prefix` to be `https://auth.example.com/`, the resulting +URL would be + +---- +https://auth.example.com/.well-known/openid-configuration +---- + +This method works on KeyCloak, but I have never tested this on other +OpenID Connect providers. + +=== Base URL and shortcuts + +For the configuration option `base-url`, usually this is just +`https://` followed by a domain that you own. Supposed you create a +shortcut called “search” which points to `https://google.com`, you +would be able to visit `https://your.domain/search` and be redirected +to `https://google.com`. However you do not have to use a root URL +under your domain. If you set `base-url` to +`https://your.domain/some/path`, your shortcut would be at +`https://your.domain/some/path/search`. I do not know why anybody +would want this, but the capability is there. diff --git a/packages/arch/PKGBUILD b/packages/arch/PKGBUILD new file mode 100644 index 0000000..550b8c7 --- /dev/null +++ b/packages/arch/PKGBUILD @@ -0,0 +1,50 @@ +pkgname=shrt-git +pkgver=0.1 +pkgrel=1 +pkgdesc="A naively simple link shortener" +arch=('x86_64') +url="https://github.com/MetroWind/shrt" +license=('MIT') +groups=() +depends=('sqlite' 'curl' 'openssl') +makedepends=('git' 'cmake' 'gcc') +provides=("${pkgname%-git}") +conflicts=("${pkgname%-git}") +replaces=() +backup=("etc/shrt.yaml") +# Stripping doesn’t work with ryml. +options=(!debug !strip) +install=shrt.install +source=('git+https://github.com/MetroWind/shrt.git' "sysusers-${pkgname%-git}.conf" "${pkgname%-git}.service" "${pkgname%-git}.yaml") +noextract=() +sha256sums=('SKIP' "1ea5c7d99be0954fb9aa6e22e7f11d485fd66d3232df3cbe3051c81e542b4bfc" + "1e65ce88985b19471af84a95ebf8f2d6726e6af434cd4dafd7203ad783510a0f" + "c91a4e0a43373e08343aba704cbd064936521decf23546a74d2d8b3f08a8e963") + +pkgver() +{ + cd "$srcdir/${pkgname%-git}" + printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)" +} + +build() +{ + cd "$srcdir/${pkgname%-git}" + # Usually CMAKE_BUILD_TYPE is set to be “None” in a PKGBUILD. But + # it doesn’t work well with ryml. + cmake -B build \ + -DCMAKE_BUILD_TYPE='Release' \ + -DCMAKE_INSTALL_PREFIX='/usr' \ + -Wno-dev . + cmake --build build +} + +package() +{ + install -Dm755 -t "$pkgdir/usr/bin" "${srcdir}/${pkgname%-git}/build/${pkgname%-git}" + mkdir -pv "$pkgdir/var/lib/${pkgname%-git}/attachments" + cp -r "${srcdir}/${pkgname%-git}/"{statics,templates} "${pkgdir}/var/lib/${pkgname%-git}" + install -Dm644 -t "$pkgdir/etc" "${srcdir}/${pkgname%-git}.yaml" + install -Dm644 "${srcdir}/sysusers-${pkgname%-git}.conf" "${pkgdir}/usr/lib/sysusers.d/${pkgname%-git}.conf" + install -Dm644 "${srcdir}/${pkgname%-git}.service" "${pkgdir}/usr/lib/systemd/system/${pkgname%-git}.service" +} diff --git a/packages/arch/shrt.install b/packages/arch/shrt.install new file mode 100644 index 0000000..b3c69b3 --- /dev/null +++ b/packages/arch/shrt.install @@ -0,0 +1,9 @@ +post_install() +{ + chown -R shrt:shrt var/lib/shrt +} + +post_upgrade() +{ + chown -R shrt:shrt var/lib/shrt +} diff --git a/packages/arch/shrt.service b/packages/arch/shrt.service new file mode 100644 index 0000000..95e739f --- /dev/null +++ b/packages/arch/shrt.service @@ -0,0 +1,12 @@ +[Unit] +Description=Shrt service +After=network.target + +[Service] +User=shrt +Group=shrt +ExecStart=/usr/bin/shrt +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/packages/arch/shrt.yaml b/packages/arch/shrt.yaml new file mode 100644 index 0000000..9439ccd --- /dev/null +++ b/packages/arch/shrt.yaml @@ -0,0 +1,7 @@ +data-dir: "/var/lib/shrt" +listen-address: localhost +listen-port: 8080 +# client-id: +# client-secret: +# openid-url-prefix: +base-url: http://localhost:8080 diff --git a/packages/arch/sysusers-shrt.conf b/packages/arch/sysusers-shrt.conf new file mode 100644 index 0000000..b0900bd --- /dev/null +++ b/packages/arch/sysusers-shrt.conf @@ -0,0 +1 @@ +u shrt - "Shrt service user" - - 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; +} diff --git a/statics/styles.css b/statics/styles.css new file mode 100644 index 0000000..3bf5adc --- /dev/null +++ b/statics/styles.css @@ -0,0 +1,290 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: unset; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} + +/* ========== Actual styles ======================================> */ + +:root +{ + --color-bg: #c0c0c0; + --color-fg: black; + --color-border-dark-1: #808080; + --color-border-dark-2: black; + --color-border-light-1: #dfdfdf; + --color-border-light-2: white; + --color-titlebar-1: navy; + --color-titlebar-2: #1084d0; +} + +* +{ + box-sizing: border-box; +} + +html +{ + background: #008080; + color: var(--color-fg); + font-family: sans-serif; + font-size: 12px; + padding: 12px; +} + +.Window +{ + box-shadow: inset -1px -1px var(--color-border-dark-2), + inset 1px 1px var(--color-border-light-1), + inset -2px -2px var(--color-border-dark-1), + inset 2px 2px var(--color-border-light-2); + background: var(--color-bg); + padding: 10px; + + .Titlebar + { + background: linear-gradient(90deg, var(--color-titlebar-1), var(--color-titlebar-2)); + margin: -6px -6px 10px -6px; + color: white; + padding: 3px; + } +} + +.Dialog +{ + max-width: 800px; + margin: 0px auto; +} + +input[type="text"], input[type="url"], input[type="email"], textarea +{ + appearance: none; + border: none; + outline: none; + box-shadow: inset -1px -1px var(--color-border-light-2), + inset 1px 1px var(--color-border-dark-1), + inset -2px -2px var(--color-border-light-1), + inset 2px 2px var(--color-border-dark-2); + width: 100%; + padding: 4px 6px; + font-family: monospace; + font-size: 0.9rem; +} + +a.Button, a.FloatButton +{ + color: black; + text-decoration: none; +} + +button, input[type="submit"] +{ + appearance: none; + border: none; + outline: none; + box-shadow: inset -1px -1px var(--color-border-dark-2), + inset 1px 1px var(--color-border-light-1), + inset -2px -2px var(--color-border-dark-1), + inset 2px 2px var(--color-border-light-2); + background: var(--color-bg); + min-height: 23px; + min-width: 75px; + padding: 0 12px; + text-align: center; +} + +.IconButton +{ + padding: 4px; + min-width: unset; + min-height: unset; +} + +.FloatButton +{ + padding: 4px; + min-width: unset; + min-height: unset; + box-shadow: none; +} + +.FloatButton:hover +{ + padding: 4px; + min-width: unset; + min-height: unset; + box-shadow: inset 1px 1px var(--color-border-light-1), + inset -1px -1px var(--color-border-dark-1); +} + +button:active, .FloatButton:active +{ + box-shadow: inset -1px -1px var(--color-border-light-2), + inset 1px 1px var(--color-border-dark-1), + inset -2px -2px var(--color-border-light-1), + inset 2px 2px var(--color-border-dark-2); +} + +hr +{ + border: none; + border-top: solid var(--color-border-dark-1) 1px; + border-bottom: solid var(--color-border-light-1) 1px; +} + +.ButtonRow +{ + display: flex; + justify-content: right; + gap: 10px; + margin: 10px 0; +} + +.ButtonRowLeft +{ + text-align: Left; + margin-top: 10px; +} + +.Toolbar +{ + display: flex; + border-bottom: solid var(--color-border-dark-1) 1px; + box-shadow: 0px 1px var(--color-border-light-1); + position: relative; + left: -10px; + top: -10px; + width: calc(100% + 2ex); + padding: 2px 8px; +} + +table.InputFields +{ + border-collapse: collapse; + width: 100%; + table-layout: auto; + + td + { + padding: 2px 2px; + } + + td:nth-child(2) + { + width: 100%; + } + +} + +.StatusBar +{ + margin: 10px -6px -7px -6px; + display: flex; +} + +.StatusCell +{ + box-shadow: inset -2px -2px var(--color-border-light-2), + inset 2px 2px var(--color-border-dark-1); + padding: 4px; + flex-grow: 1; +} + +nav +{ + display: flex; + justify-content: space-between; +} + +#Links +{ + box-shadow: inset -1px -1px var(--color-border-light-2), + inset 1px 1px var(--color-border-dark-1), + inset -2px -2px var(--color-border-light-1), + inset 2px 2px var(--color-border-dark-2); + padding: 2px; +} + +table.TableView +{ + background-color: white; + border-collapse: collapse; + width: 100%; + + thead th + { + box-shadow: inset -1px -1px var(--color-border-dark-2), + inset 1px 1px var(--color-border-light-1), + inset -2px -2px var(--color-border-dark-1), + inset 2px 2px var(--color-border-light-2); + background-color: var(--color-bg); + padding: 2px 4px 4px 4px; + text-align: left; + } + + td + { + padding: 2px; + } +} + +#Links table +{ + th:nth-child(2) + { + width: 100%; + } + + /* th:nth-child(3), th:nth-child(4) */ + /* { */ + /* width: 64px; */ + /* } */ + + td:nth-child(3), td:nth-child(4) + { + text-align: center; + } +} diff --git a/templates/delete-link.html b/templates/delete-link.html new file mode 100644 index 0000000..809a57e --- /dev/null +++ b/templates/delete-link.html @@ -0,0 +1,32 @@ + + + + {% include "head.html" %} + shrt – Delet a Link + + +
+ {% include "nav.html" %} +
+ + + + + + + + + + + + + +
+
+ +
+
+ {% include "footer.html" %} +
+ + diff --git a/templates/footer.html b/templates/footer.html new file mode 100644 index 0000000..66d46e2 --- /dev/null +++ b/templates/footer.html @@ -0,0 +1,5 @@ +
+ + Powered by shrt + +
diff --git a/templates/head.html b/templates/head.html new file mode 100644 index 0000000..46b21e7 --- /dev/null +++ b/templates/head.html @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/links.html b/templates/links.html new file mode 100644 index 0000000..f3b3e3f --- /dev/null +++ b/templates/links.html @@ -0,0 +1,39 @@ + + + + {% include "head.html" %} + + + + shrt + + +
+ {% include "nav.html" %} +
+ +
+ + {% include "footer.html" %} +
+ + diff --git a/templates/nav.html b/templates/nav.html new file mode 100644 index 0000000..5a89f7a --- /dev/null +++ b/templates/nav.html @@ -0,0 +1,17 @@ + diff --git a/templates/new-link.html b/templates/new-link.html new file mode 100644 index 0000000..3ce3fda --- /dev/null +++ b/templates/new-link.html @@ -0,0 +1,36 @@ + + + + {% include "head.html" %} + shrt – Create a New Link + + +
+ {% include "nav.html" %} +
+ + + + + + + + + + + + + +
+ + +
+
+ +
+
+ {% include "footer.html" %} +
+ + -- cgit v1.2.3-70-g09d2