aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt136
-rw-r--r--COPYING.txt20
-rw-r--r--README.adoc103
-rw-r--r--packages/arch/PKGBUILD50
-rw-r--r--packages/arch/shrt.install9
-rw-r--r--packages/arch/shrt.service12
-rw-r--r--packages/arch/shrt.yaml7
-rw-r--r--packages/arch/sysusers-shrt.conf1
-rw-r--r--src/app.cpp342
-rw-r--r--src/app.hpp84
-rw-r--r--src/app_test.cpp205
-rw-r--r--src/config.cpp85
-rw-r--r--src/config.hpp23
-rw-r--r--src/data.cpp54
-rw-r--r--src/data.hpp73
-rw-r--r--src/data_mock.hpp24
-rw-r--r--src/data_test.cpp17
-rw-r--r--src/main.cpp29
-rw-r--r--statics/styles.css290
-rw-r--r--templates/delete-link.html32
-rw-r--r--templates/footer.html5
-rw-r--r--templates/head.html5
-rw-r--r--templates/links.html39
-rw-r--r--templates/nav.html17
-rw-r--r--templates/new-link.html36
25 files changed, 1698 insertions, 0 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..1378763
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,136 @@
1# cmake -DCMAKE_CXX_INCLUDE_WHAT_YOU_USE=include-what-you-use -B build . && cmake --build build -j
2cmake_minimum_required(VERSION 3.24)
3
4set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE)
5
6# For whatever reason, the C++ stdlib is broken on my Mac.
7#
8# % echo "#include <vector>" | c++ -x c++ -
9# <stdin>:1:10: fatal error: 'vector' file not found
10# 1 | #include <vector>
11# | ^~~~~~~~
12# 1 error generated.
13if(APPLE)
14 set(CMAKE_CXX_FLAGS "-I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1")
15endif()
16
17project(Webdir)
18option(WEBDIR_BUILD_TESTS "Build unit tests" OFF)
19
20include(FetchContent)
21FetchContent_Declare(
22 libmw
23 GIT_REPOSITORY https://github.com/MetroWind/libmw.git
24)
25
26FetchContent_Declare(
27 cxxopts
28 GIT_REPOSITORY https://github.com/jarro2783/cxxopts.git
29 GIT_TAG v3.1.1
30)
31
32FetchContent_Declare(
33 ryml
34 GIT_REPOSITORY https://github.com/biojppm/rapidyaml.git
35 GIT_TAG
36 GIT_SHALLOW FALSE # ensure submodules are checked out
37)
38
39FetchContent_Declare(
40 spdlog
41 GIT_REPOSITORY https://github.com/gabime/spdlog.git
42 GIT_TAG v1.12.0
43)
44
45FetchContent_Declare(
46 json
47 GIT_REPOSITORY https://github.com/nlohmann/json.git
48 GIT_TAG v3.11.3
49)
50
51FetchContent_Declare(
52 inja
53 GIT_REPOSITORY https://github.com/pantor/inja.git
54 GIT_TAG main
55)
56
57set(SPDLOG_USE_STD_FORMAT ON)
58set(LIBMW_BUILD_URL ON)
59set(LIBMW_BUILD_SQLITE ON)
60set(LIBMW_BUILD_HTTP_SERVER ON)
61set(INJA_USE_EMBEDDED_JSON FALSE)
62set(INJA_BUILD_TESTS FALSE)
63FetchContent_MakeAvailable(libmw ryml spdlog cxxopts json inja)
64
65if(WEBDIR_BUILD_TESTS)
66 FetchContent_Declare(
67 googletest
68 URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz
69 )
70 FetchContent_MakeAvailable(googletest)
71endif()
72
73set(SOURCE_FILES
74 src/app.cpp
75 src/app.hpp
76 src/config.cpp
77 src/config.hpp
78 src/data.cpp
79 src/data.hpp
80)
81
82set(LIBS
83 cxxopts
84 mw::mw
85 mw::url
86 mw::sqlite
87 mw::http-server
88 ryml::ryml
89 spdlog::spdlog
90)
91
92set(INCLUDES
93 ${libmw_SOURCE_DIR}/includes
94 ${json_SOURCE_DIR}/single_include
95 ${inja_SOURCE_DIR}/single_include/inja
96)
97
98add_executable(webdir ${SOURCE_FILES} src/main.cpp)
99set_property(TARGET webdir PROPERTY CXX_STANDARD 23)
100set_property(TARGET webdir PROPERTY CXX_EXTENSIONS FALSE)
101
102set_property(TARGET webdir PROPERTY COMPILE_WARNING_AS_ERROR TRUE)
103target_compile_options(webdir PRIVATE -Wall -Wextra -Wpedantic)
104target_include_directories(webdir PRIVATE ${INCLUDES})
105target_link_libraries(webdir PRIVATE ${LIBS})
106
107if(WEBDIR_BUILD_TESTS)
108 set(TEST_FILES
109 src/data_mock.hpp
110 src/data_test.cpp
111 src/app_test.cpp
112 )
113
114 # ctest --test-dir build
115 add_executable(webdir_test ${SOURCE_FILES} ${TEST_FILES})
116 set_property(TARGET webdir_test PROPERTY CXX_STANDARD 23)
117 set_property(TARGET webdir_test PROPERTY COMPILE_WARNING_AS_ERROR TRUE)
118 target_compile_options(webdir_test PRIVATE -Wall -Wextra -Wpedantic)
119 target_include_directories(webdir_test PRIVATE
120 ${INCLUDES}
121 ${googletest_SOURCE_DIR}/googletest/include
122 ${googletest_SOURCE_DIR}/googlemock/include
123 )
124
125 target_link_libraries(webdir_test PRIVATE
126 ${LIBS}
127 GTest::gtest_main
128 GTest::gmock_main
129 )
130
131 enable_testing()
132 include(GoogleTest)
133 gtest_discover_tests(webdir_test
134 # Need this so that the unit tests can find the templates.
135 WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
136endif()
diff --git a/COPYING.txt b/COPYING.txt
new file mode 100644
index 0000000..10f9a1d
--- /dev/null
+++ b/COPYING.txt
@@ -0,0 +1,20 @@
1Copyright (c) 2025 MetroWind <chris.corsair@gmail.com>
2
3Permission is hereby granted, free of charge, to any person obtaining
4a copy of this software and associated documentation files (the
5"Software"), to deal in the Software without restriction, including
6without limitation the rights to use, copy, modify, merge, publish,
7distribute, sublicense, and/or sell copies of the Software, and to
8permit persons to whom the Software is furnished to do so, subject to
9the following conditions:
10
11The above copyright notice and this permission notice shall be
12included in all copies or substantial portions of the Software.
13
14THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20SOFTWARE 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 @@
1= Shrt
2
3A naively simple URL shortener
4
5image::screenshot-0.webp[Screenshot]
6
7image::screenshot-1.webp[Screenshot]
8
9== Installation
10
11Only *NIX systems are supported.
12
13Runtime dependencies:
14
15- cURL
16- OpenSSL
17- SQLite
18
19Build dependencies:
20
21- CMake
22- A C++23 compiler
23
24=== Building from source
25
261. Clone the repo
272. Go into the repo directory, and run `cmake -B build`.
283. Run `cmake --build build -j`.
294. Pick a data directory where the database will be created.
305. Copy the `templates` and `statics` directories into the data
31directory.
326. Copy `build/shrt` into any directory that is in your `$PATH`.
33
34=== Using pre-build binary
35
361. Download the binary from one of the releases.
372. Extract the downloaded tarball.
383. Continue to step 4 in “Building from source”.
39
40=== Arch Linux package
41
42A PKGBUILD is provided in `packages/arch` in this repository.
43
44== Configuartion
45
46By default, shrt looks for a configuration file at `/etc/shrt.yaml`.
47This path can be changed with the `-c` command line option. An example
48configuration file is printed below with comments.
49
50[source,yaml]
51----
52# Specify the data directory you have picked.
53data-dir: "/var/lib/shrt"
54# The listening address. Example: localhost, 0.0.0.0, 127.0.0.1.
55listen-address: localhost
56# The port to listen to. If this is 0, the value of “listen-address”
57# is treated as a path of a UNIX domain socket file.
58listen-port: 8080
59# The client ID of your shrt service. This is given by the OpenID
60# Connect provider.
61client-id: shrt
62# The client secrete of your shrt service. This is given by the OpenID
63# Connect provider.
64client-secret: "abced12345"
65# The initial URL of the OpenID Connect service.
66openid-url-prefix: "https://auth.example.com/"
67# The base URL of your shrt service. This is usually just “https://”
68# followed by your domain name.
69base-url: https://go.mws.rocks
70----
71
72=== Authentication
73
74Shrt relies on an external OpenID Connect service provider for
75authentication. Usually you register your instance of shrt to the
76OpenID Connect provider. The provider will give you an client ID and a
77client secret (in this case your shrt server is the “client” of the
78OpenID Connect service). An OpenID Connect service has a number of
79endpoints, such as user info, tokens, etc. The URLs of those endpoints
80are discover by visiting a URL that is constructed from the URL in the
81configuration option `openid-url-prefix`, followed by
82`.well-known/openid-configuration`. For example, if you set
83`openid-url-prefix` to be `https://auth.example.com/`, the resulting
84URL would be
85
86----
87https://auth.example.com/.well-known/openid-configuration
88----
89
90This method works on KeyCloak, but I have never tested this on other
91OpenID Connect providers.
92
93=== Base URL and shortcuts
94
95For the configuration option `base-url`, usually this is just
96`https://` followed by a domain that you own. Supposed you create a
97shortcut called “search” which points to `https://google.com`, you
98would be able to visit `https://your.domain/search` and be redirected
99to `https://google.com`. However you do not have to use a root URL
100under your domain. If you set `base-url` to
101`https://your.domain/some/path`, your shortcut would be at
102`https://your.domain/some/path/search`. I do not know why anybody
103would 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 @@
1pkgname=shrt-git
2pkgver=0.1
3pkgrel=1
4pkgdesc="A naively simple link shortener"
5arch=('x86_64')
6url="https://github.com/MetroWind/shrt"
7license=('MIT')
8groups=()
9depends=('sqlite' 'curl' 'openssl')
10makedepends=('git' 'cmake' 'gcc')
11provides=("${pkgname%-git}")
12conflicts=("${pkgname%-git}")
13replaces=()
14backup=("etc/shrt.yaml")
15# Stripping doesn’t work with ryml.
16options=(!debug !strip)
17install=shrt.install
18source=('git+https://github.com/MetroWind/shrt.git' "sysusers-${pkgname%-git}.conf" "${pkgname%-git}.service" "${pkgname%-git}.yaml")
19noextract=()
20sha256sums=('SKIP' "1ea5c7d99be0954fb9aa6e22e7f11d485fd66d3232df3cbe3051c81e542b4bfc"
21 "1e65ce88985b19471af84a95ebf8f2d6726e6af434cd4dafd7203ad783510a0f"
22 "c91a4e0a43373e08343aba704cbd064936521decf23546a74d2d8b3f08a8e963")
23
24pkgver()
25{
26 cd "$srcdir/${pkgname%-git}"
27 printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)"
28}
29
30build()
31{
32 cd "$srcdir/${pkgname%-git}"
33 # Usually CMAKE_BUILD_TYPE is set to be “None” in a PKGBUILD. But
34 # it doesn’t work well with ryml.
35 cmake -B build \
36 -DCMAKE_BUILD_TYPE='Release' \
37 -DCMAKE_INSTALL_PREFIX='/usr' \
38 -Wno-dev .
39 cmake --build build
40}
41
42package()
43{
44 install -Dm755 -t "$pkgdir/usr/bin" "${srcdir}/${pkgname%-git}/build/${pkgname%-git}"
45 mkdir -pv "$pkgdir/var/lib/${pkgname%-git}/attachments"
46 cp -r "${srcdir}/${pkgname%-git}/"{statics,templates} "${pkgdir}/var/lib/${pkgname%-git}"
47 install -Dm644 -t "$pkgdir/etc" "${srcdir}/${pkgname%-git}.yaml"
48 install -Dm644 "${srcdir}/sysusers-${pkgname%-git}.conf" "${pkgdir}/usr/lib/sysusers.d/${pkgname%-git}.conf"
49 install -Dm644 "${srcdir}/${pkgname%-git}.service" "${pkgdir}/usr/lib/systemd/system/${pkgname%-git}.service"
50}
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 @@
1post_install()
2{
3 chown -R shrt:shrt var/lib/shrt
4}
5
6post_upgrade()
7{
8 chown -R shrt:shrt var/lib/shrt
9}
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 @@
1[Unit]
2Description=Shrt service
3After=network.target
4
5[Service]
6User=shrt
7Group=shrt
8ExecStart=/usr/bin/shrt
9Restart=on-failure
10
11[Install]
12WantedBy=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 @@
1data-dir: "/var/lib/shrt"
2listen-address: localhost
3listen-port: 8080
4# client-id:
5# client-secret:
6# openid-url-prefix:
7base-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 @@
1#include <format>
2#include <stddef.h>
3#include <stdint.h>
4#include <chrono>
5#include <expected>
6#include <filesystem>
7#include <iterator>
8#include <string>
9#include <string_view>
10#include <unordered_map>
11#include <utility>
12
13#include <nlohmann/json.hpp>
14#include <spdlog/spdlog.h>
15#include <inja.hpp>
16#include <mw/http_server.hpp>
17#include <mw/url.hpp>
18#include <mw/utils.hpp>
19#include <mw/error.hpp>
20#include <mw/auth.hpp>
21
22#include "app.hpp"
23#include "config.hpp"
24#include "data.hpp"
25#include "mw/error.hpp"
26
27namespace
28{
29
30std::unordered_map<std::string, std::string> parseCookies(std::string_view value)
31{
32 std::unordered_map<std::string, std::string> cookies;
33 size_t begin = 0;
34 while(true)
35 {
36 if(begin >= value.size())
37 {
38 break;
39 }
40
41 size_t semicolon = value.find(';', begin);
42 if(semicolon == std::string::npos)
43 {
44 semicolon = value.size();
45 }
46
47 std::string_view section = value.substr(begin, semicolon - begin);
48
49 begin = semicolon + 1;
50 // Skip spaces
51 while(begin < value.size() && value[begin] == ' ')
52 {
53 begin++;
54 }
55
56 size_t equal = section.find('=');
57 if(equal == std::string::npos) continue;
58 cookies.emplace(section.substr(0, equal),
59 section.substr(equal+1, semicolon - equal - 1));
60 if(semicolon >= value.size())
61 {
62 continue;
63 }
64 }
65 return cookies;
66}
67
68void setTokenCookies(const mw::Tokens& tokens, App::Response& res)
69{
70 int64_t expire_sec = 300;
71 if(tokens.expiration.has_value())
72 {
73 auto expire = std::chrono::duration_cast<std::chrono::seconds>(
74 *tokens.expiration - mw::Clock::now());
75 expire_sec = expire.count();
76 }
77 res.set_header("Set-Cookie", std::format(
78 "shrt-access-token={}; Max-Age={}",
79 mw::urlEncode(tokens.access_token), expire_sec));
80 // Add refresh token to cookie, with one month expiration.
81 if(tokens.refresh_token.has_value())
82 {
83 expire_sec = 1800;
84 if(tokens.refresh_expiration.has_value())
85 {
86 auto expire = std::chrono::duration_cast<std::chrono::seconds>(
87 *tokens.refresh_expiration - mw::Clock::now());
88 expire_sec = expire.count();
89 }
90
91 res.set_header("Set-Cookie", std::format(
92 "shrt-refresh-token={}; Max-Age={}",
93 mw::urlEncode(*tokens.refresh_token), expire_sec));
94 }
95}
96
97mw::HTTPServer::ListenAddress listenAddrFromConfig(const Configuration& config)
98{
99 if(config.listen_port == 0)
100 {
101 mw::SocketFileInfo sock(config.listen_address);
102 sock.user = config.socket_user;
103 sock.group = config.socket_group;
104 sock.permission = config.socket_permission;
105 return sock;
106 }
107
108 mw::IPSocketInfo sock;
109 sock.address = config.listen_address;
110 sock.port = config.listen_port;
111 return sock;
112}
113
114} // namespace
115
116App::App(const Configuration& conf,
117 std::unique_ptr<DataSourceInterface> data_source,
118 std::unique_ptr<mw::AuthInterface> openid_auth)
119 : mw::HTTPServer(listenAddrFromConfig(conf)),
120 config(conf),
121 templates((std::filesystem::path(config.data_dir) / "templates" / "")
122 .string()),
123 data(std::move(data_source)),
124 auth(std::move(openid_auth))
125{
126 auto u = mw::URL::fromStr(conf.base_url);
127 if(u.has_value())
128 {
129 base_url = *std::move(u);
130 }
131
132 templates.add_callback("url_for", [&](const inja::Arguments& args) ->
133 std::string
134 {
135 switch(args.size())
136 {
137 case 1:
138 return urlFor(args.at(0)->get_ref<const std::string&>());
139 case 2:
140 return urlFor(args.at(0)->get_ref<const std::string&>(),
141 args.at(1)->get_ref<const std::string&>());
142 default:
143 return "Invalid number of url_for() arguments";
144 }
145 });
146}
147
148std::string App::urlFor(const std::string& name, const std::string& arg) const
149{
150 if(name == "statics")
151 {
152 return mw::URL(base_url).appendPath("_/statics").appendPath(arg).str();
153 }
154 if(name == "index")
155 {
156 return base_url.str();
157 }
158 if(name == "shortcut")
159 {
160 return mw::URL(base_url).appendPath(arg).str();
161 }
162 if(name == "links")
163 {
164 return mw::URL(base_url).appendPath("_/links").str();
165 }
166 if(name == "login")
167 {
168 return mw::URL(base_url).appendPath("_/login").str();
169 }
170 if(name == "openid-redirect")
171 {
172 return mw::URL(base_url).appendPath("_/openid-redirect").str();
173 }
174 if(name == "new-link")
175 {
176 return mw::URL(base_url).appendPath("_/new-link").str();
177 }
178 if(name == "create-link")
179 {
180 return mw::URL(base_url).appendPath("_/create-link").str();
181 }
182 if(name == "delete-link-dialog")
183 {
184 return mw::URL(base_url).appendPath("_/delete-link").appendPath(arg)
185 .str();
186 }
187 if(name == "delete-link")
188 {
189 return mw::URL(base_url).appendPath("_/delete-link").str();
190 }
191
192 return "";
193}
194
195void App::handleIndex(Response& res) const
196{
197 res.set_redirect(urlFor("links"), 301);
198}
199
200void App::handleLogin(Response& res) const
201{
202 res.set_redirect(auth->initialURL(), 301);
203}
204
205void App::handleOpenIDRedirect(const Request& req, Response& res) const
206{
207 if(req.has_param("error"))
208 {
209 res.status = 500;
210 if(req.has_param("error_description"))
211 {
212 res.set_content(
213 std::format("{}: {}.", req.get_param_value("error"),
214 req.get_param_value("error_description")),
215 "text/plain");
216 }
217 return;
218 }
219 else if(!req.has_param("code"))
220 {
221 res.status = 500;
222 res.set_content("No error or code in auth response", "text/plain");
223 return;
224 }
225
226 std::string code = req.get_param_value("code");
227 spdlog::debug("OpenID server visited {} with code {}.", req.path, code);
228 ASSIGN_OR_RESPOND_ERROR(mw::Tokens tokens, auth->authenticate(code), res);
229 ASSIGN_OR_RESPOND_ERROR(mw::UserInfo user, auth->getUser(tokens), res);
230
231 setTokenCookies(tokens, res);
232 res.set_redirect(urlFor("index"), 301);
233}
234
235
236std::string App::getPath(const std::string& name,
237 const std::string& arg_name) const
238{
239 return mw::URL::fromStr(urlFor(name, std::string(":") + arg_name)).value()
240 .path();
241}
242
243void App::setup()
244{
245 {
246 std::string statics_dir = (std::filesystem::path(config.data_dir) /
247 "statics").string();
248 spdlog::info("Mounting static dir at {}...", statics_dir);
249 if (!server.set_mount_point("/_/statics", statics_dir))
250 {
251 spdlog::error("Failed to mount statics");
252 return;
253 }
254 }
255
256 server.Get(getPath("index"), [&]([[maybe_unused]] const Request& req, Response& res)
257 {
258 handleIndex(res);
259 });
260 server.Get(getPath("login"), [&]([[maybe_unused]] const Request& req, Response& res)
261 {
262 handleLogin(res);
263 });
264 server.Get(getPath("openid-redirect"), [&](const Request& req, Response& res)
265 {
266 handleOpenIDRedirect(req, res);
267 });
268}
269
270mw::E<App::SessionValidation> App::validateSession(const Request& req) const
271{
272 if(!req.has_header("Cookie"))
273 {
274 spdlog::debug("Request has no cookie.");
275 return SessionValidation::invalid();
276 }
277
278 auto cookies = parseCookies(req.get_header_value("Cookie"));
279 if(auto it = cookies.find("shrt-access-token");
280 it != std::end(cookies))
281 {
282 spdlog::debug("Cookie has access token.");
283 mw::Tokens tokens;
284 tokens.access_token = it->second;
285 mw::E<mw::UserInfo> user = auth->getUser(tokens);
286 if(user.has_value())
287 {
288 return SessionValidation::valid(*std::move(user));
289 }
290 }
291 // No access token or access token expired
292 if(auto it = cookies.find("shrt-refresh-token");
293 it != std::end(cookies))
294 {
295 spdlog::debug("Cookie has refresh token.");
296 // Try to refresh the tokens.
297 ASSIGN_OR_RETURN(mw::Tokens tokens, auth->refreshTokens(it->second));
298 ASSIGN_OR_RETURN(mw::UserInfo user, auth->getUser(tokens));
299 return SessionValidation::refreshed(std::move(user), std::move(tokens));
300 }
301 return SessionValidation::invalid();
302}
303
304std::optional<App::SessionValidation> App::prepareSession(
305 const Request& req, Response& res, bool allow_error_and_invalid) const
306{
307 mw::E<SessionValidation> session = validateSession(req);
308 if(!session.has_value())
309 {
310 if(allow_error_and_invalid)
311 {
312 return SessionValidation::invalid();
313 }
314 else
315 {
316 res.status = 500;
317 res.set_content("Failed to validate session.", "text/plain");
318 return std::nullopt;
319 }
320 }
321
322 switch(session->status)
323 {
324 case SessionValidation::INVALID:
325 if(allow_error_and_invalid)
326 {
327 return *session;
328 }
329 else
330 {
331 res.status = 401;
332 res.set_content("Invalid session.", "text/plain");
333 return std::nullopt;
334 }
335 case SessionValidation::VALID:
336 break;
337 case SessionValidation::REFRESHED:
338 setTokenCookies(session->new_tokens, res);
339 break;
340 }
341 return *session;
342}
diff --git a/src/app.hpp b/src/app.hpp
new file mode 100644
index 0000000..092196c
--- /dev/null
+++ b/src/app.hpp
@@ -0,0 +1,84 @@
1#pragma once
2
3#include <memory>
4#include <optional>
5#include <string>
6
7#include <inja.hpp>
8#include <mw/url.hpp>
9#include <mw/http_server.hpp>
10#include <mw/error.hpp>
11#include <mw/auth.hpp>
12
13#include "data.hpp"
14#include "config.hpp"
15
16class App : public mw::HTTPServer
17{
18public:
19 using Request = mw::HTTPServer::Request;
20 using Response = mw::HTTPServer::Response;
21
22 App() = delete;
23 App(const Configuration& conf,
24 std::unique_ptr<DataSourceInterface> data_source,
25 std::unique_ptr<mw::AuthInterface> openid_auth);
26
27 std::string urlFor(const std::string& name, const std::string& arg="")
28 const;
29
30private:
31 void setup() override;
32
33 struct SessionValidation
34 {
35 enum { VALID, REFRESHED, INVALID } status;
36 mw::UserInfo user;
37 mw::Tokens new_tokens;
38
39 static SessionValidation valid(mw::UserInfo&& user_info)
40 {
41 return {VALID, user_info, {}};
42 }
43
44 static SessionValidation refreshed(mw::UserInfo&& user_info, mw::Tokens&& tokens)
45 {
46 return {REFRESHED, user_info, tokens};
47 }
48
49 static SessionValidation invalid()
50 {
51 return {INVALID, {}, {}};
52 }
53 };
54 mw::E<SessionValidation> validateSession(const Request& req) const;
55
56 // Query the auth module for the status of the session. If there
57 // is no session or it fails to query the auth module, set the
58 // status and body in “res” accordingly, and return nullopt. In
59 // this case if this function does return a value, it would never
60 // be an invalid session.
61 //
62 // If “allow_error_and_invalid” is true, failure to query and
63 // invalid session are considered ok, and no status and body would
64 // be set in “res”. In this case this function just returns an
65 // invalid session.
66 std::optional<SessionValidation> prepareSession(
67 const Request& req, Response& res,
68 bool allow_error_and_invalid=false) const;
69
70 // This gives a path, optionally with the name of an argument,
71 // that is suitable to bind to a URL handler. For example,
72 // supposed the URL of the blog post with ID 1 is
73 // “http://some.domain/blog/p/1”. Calling “getPath("post", "id")”
74 // would give “/blog/p/:id”. This uses urlFor(), and therefore
75 // requires that the URL is mapped correctly in that function.
76 std::string getPath(const std::string& name, const std::string& arg_name="")
77 const;
78
79 Configuration config;
80 mw::URL base_url;
81 inja::Environment templates;
82 std::unique_ptr<DataSourceInterface> data;
83 std::unique_ptr<mw::AuthInterface> auth;
84};
diff --git a/src/app_test.cpp b/src/app_test.cpp
new file mode 100644
index 0000000..a022611
--- /dev/null
+++ b/src/app_test.cpp
@@ -0,0 +1,205 @@
1#include <httplib.h>
2#include <memory>
3#include <iostream>
4
5#include <gmock/gmock.h>
6#include <gtest/gtest.h>
7#include <mw/test_utils.hpp>
8#include <mw/http_client.hpp>
9#include <mw/auth_mock.hpp>
10
11#include "app.hpp"
12#include "config.hpp"
13#include "data.hpp"
14#include "data_mock.hpp"
15
16using ::testing::_;
17using ::testing::Return;
18using ::testing::HasSubstr;
19using ::testing::FieldsAre;
20using ::testing::ContainsRegex;
21
22void PrintTo(const ShortLink& link, std::ostream* os)
23{
24 *os << "ShortLink(id: " << link.id
25 << ", shortcut: " << link.shortcut
26 << ", original_url: " << link.original_url
27 << ", type: " << link.type
28 << ", user_id: " << link.user_id
29 << ", user_name: " << link.user_name
30 << ", visits: " << link.visits
31 << ", time_creation: " << link.time_creation << ")";
32}
33
34class UserAppTest : public testing::Test
35{
36protected:
37 UserAppTest()
38 {
39 config.base_url = "http://localhost:8080/";
40 config.listen_address = "localhost";
41 config.listen_port = 8080;
42 config.data_dir = ".";
43
44 auto auth = std::make_unique<mw::AuthMock>();
45
46 mw::UserInfo expected_user;
47 expected_user.name = "mw";
48 expected_user.id = "mw";
49 mw::Tokens token;
50 token.access_token = "aaa";
51 EXPECT_CALL(*auth, getUser(std::move(token)))
52 .Times(::testing::AtLeast(0))
53 .WillRepeatedly(Return(expected_user));
54 auto data = std::make_unique<DataSourceMock>();
55 data_source = data.get();
56
57 app = std::make_unique<App>(config, std::move(data), std::move(auth));
58 }
59
60 Configuration config;
61 std::unique_ptr<App> app;
62 const DataSourceMock* data_source;
63};
64
65TEST_F(UserAppTest, CanDenyAccessToLinkList)
66{
67 EXPECT_TRUE(mw::isExpected(app->start()));
68 {
69 mw::HTTPSession client;
70 ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get(
71 mw::HTTPRequest("http://localhost:8080/_/links")));
72 EXPECT_EQ(res->status, 401);
73 }
74 app->stop();
75 app->wait();
76}
77
78TEST_F(UserAppTest, CanHandleLinkList)
79{
80 std::vector<ShortLink> links;
81 ShortLink link0;
82 link0.shortcut = "link0";
83 link0.original_url = "a";
84 link0.id = 1;
85 link0.user_id = "mw";
86 link0.user_name = "mw";
87 link0.type = ShortLink::NORMAL;
88 ShortLink link1;
89 link1.shortcut = "link1";
90 link1.original_url = "b";
91 link1.id = 2;
92 link1.user_id = "mw";
93 link1.user_name = "mw";
94 link1.type = ShortLink::REGEXP;
95 links.push_back(std::move(link0));
96 links.push_back(std::move(link1));
97
98 EXPECT_CALL(*data_source, getAllLinks("mw"))
99 .WillOnce(Return(std::move(links)));
100
101 EXPECT_TRUE(mw::isExpected(app->start()));
102 {
103 mw::HTTPSession client;
104 ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get(
105 mw::HTTPRequest("http://localhost:8080/_/links")
106 .addHeader("Cookie", "shrt-access-token=aaa")));
107 EXPECT_EQ(res->status, 200) << "Response body: " << res->payloadAsStr();
108 EXPECT_THAT(res->payloadAsStr(), ContainsRegex("<td>a</td>[[:space:]]*<td>-</td>"));
109 EXPECT_THAT(res->payloadAsStr(), ContainsRegex("<td>b</td>[[:space:]]*<td>✅</td>"));
110 }
111 app->stop();
112 app->wait();
113}
114
115TEST_F(UserAppTest, CanDenyAccessToNewLink)
116{
117 EXPECT_TRUE(mw::isExpected(app->start()));
118 {
119 mw::HTTPSession client;
120 ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get(
121 mw::HTTPRequest("http://localhost:8080/_/new-link")));
122 EXPECT_EQ(res->status, 401);
123 }
124 app->stop();
125 app->wait();
126}
127
128TEST_F(UserAppTest, CanHandleNewLink)
129{
130 EXPECT_TRUE(mw::isExpected(app->start()));
131 {
132 mw::HTTPSession client;
133 ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.get(
134 mw::HTTPRequest("http://localhost:8080/_/new-link")
135 .addHeader("Cookie", "shrt-access-token=aaa")));
136 EXPECT_EQ(res->status, 200);
137 EXPECT_THAT(res->payloadAsStr(), HasSubstr("Create a New Link"));
138 }
139 app->stop();
140 app->wait();
141}
142
143TEST_F(UserAppTest, CanDenyAccessToCreateLink)
144{
145 EXPECT_TRUE(mw::isExpected(app->start()));
146 {
147 mw::HTTPSession client;
148 ASSIGN_OR_FAIL(const mw::HTTPResponse* res, client.post(
149 mw::HTTPRequest("http://localhost:8080/_/create-link")));
150 EXPECT_EQ(res->status, 401);
151 }
152 app->stop();
153 app->wait();
154}
155
156TEST_F(UserAppTest, CanDenyHandleCreateLink)
157{
158 EXPECT_CALL(*data_source, addLink(
159 FieldsAre(
160 _, // id
161 "abc", // shortcut
162 "http://darksair.org", // original_url
163 ShortLink::NORMAL, // type
164 "mw", // user_id
165 "", // user_name
166 _, // visits
167 _))) // time_creation
168 .WillOnce(Return(mw::E<void>()));
169
170 EXPECT_CALL(*data_source, addLink(
171 FieldsAre(
172 _, // id
173 "xyz", // shortcut
174 "http://mws.rocks", // original_url
175 ShortLink::REGEXP, // type
176 "mw", // user_id
177 "", // user_name
178 _, // visits
179 _))) // time_creation
180 .WillOnce(Return(mw::E<void>()));
181
182 EXPECT_TRUE(mw::isExpected(app->start()));
183 {
184 mw::HTTPSession client;
185 ASSIGN_OR_FAIL(const mw::HTTPResponse* res1, client.post(
186 mw::HTTPRequest("http://localhost:8080/_/create-link")
187 .setPayload("shortcut=abc&original_url=http%3A%2F%2Fdarksair%2Eorg"
188 "&regexp=off")
189 .addHeader("Cookie", "shrt-access-token=aaa")
190 .setContentType("application/x-www-form-urlencoded")));
191 EXPECT_EQ(res1->status, 302);
192 EXPECT_EQ(res1->header.at("Location"), "http://localhost:8080/");
193
194 ASSIGN_OR_FAIL(const mw::HTTPResponse* res2, client.post(
195 mw::HTTPRequest("http://localhost:8080/_/create-link")
196 .setPayload("shortcut=xyz&original_url=http%3A%2F%2Fmws%2Erocks"
197 "&regexp=on")
198 .addHeader("Cookie", "shrt-access-token=aaa")
199 .setContentType("application/x-www-form-urlencoded")));
200 EXPECT_EQ(res2->status, 302);
201 EXPECT_EQ(res2->header.at("Location"), "http://localhost:8080/");
202 }
203 app->stop();
204 app->wait();
205}
diff --git a/src/config.cpp b/src/config.cpp
new file mode 100644
index 0000000..aec29a4
--- /dev/null
+++ b/src/config.cpp
@@ -0,0 +1,85 @@
1#include <filesystem>
2#include <vector>
3#include <fstream>
4#include <format>
5#include <utility>
6
7#include <ryml.hpp>
8#include <ryml_std.hpp>
9#include <mw/error.hpp>
10
11#include "config.hpp"
12
13namespace {
14
15mw::E<std::vector<char>> readFile(const std::filesystem::path& path)
16{
17 std::ifstream f(path, std::ios::binary);
18 std::vector<char> content;
19 content.reserve(102400);
20 content.assign(std::istreambuf_iterator<char>(f),
21 std::istreambuf_iterator<char>());
22 if(f.bad() || f.fail())
23 {
24 return std::unexpected(mw::runtimeError(
25 std::format("Failed to read file {}", path.string())));
26 }
27
28 return content;
29}
30
31} // namespace
32
33mw::E<Configuration> Configuration::fromYaml(const std::filesystem::path& path)
34{
35 auto buffer = readFile(path);
36 if(!buffer.has_value())
37 {
38 return std::unexpected(buffer.error());
39 }
40
41 ryml::Tree tree = ryml::parse_in_place(ryml::to_substr(*buffer));
42 Configuration config;
43 if(tree["listen-address"].readable())
44 {
45 tree["listen-address"] >> config.listen_address;
46 }
47 if(tree["listen-port"].readable())
48 {
49 tree["listen-port"] >> config.listen_port;
50 }
51 if(tree["socket-user"].readable())
52 {
53 tree["socket-user"] >> config.socket_user;
54 }
55 if(tree["socket-group"].readable())
56 {
57 tree["socket-group"] >> config.socket_group;
58 }
59 if(tree["socket-permission"].readable())
60 {
61 tree["socket-permission"] >> config.socket_permission;
62 }
63 if(tree["base-url"].readable())
64 {
65 tree["base-url"] >> config.base_url;
66 }
67 if(tree["data-dir"].readable())
68 {
69 tree["data-dir"] >> config.data_dir;
70 }
71 if(tree["openid-url-prefix"].readable())
72 {
73 tree["openid-url-prefix"] >> config.openid_url_prefix;
74 }
75 if(tree["client-id"].readable())
76 {
77 tree["client-id"] >> config.client_id;
78 }
79 if(tree["client-secret"].readable())
80 {
81 tree["client-secret"] >> config.client_secret;
82 }
83
84 return mw::E<Configuration>{std::in_place, std::move(config)};
85}
diff --git a/src/config.hpp b/src/config.hpp
new file mode 100644
index 0000000..1f4e01f
--- /dev/null
+++ b/src/config.hpp
@@ -0,0 +1,23 @@
1#pragma once
2
3#include <filesystem>
4#include <string>
5
6#include <mw/error.hpp>
7
8struct Configuration
9{
10 std::string listen_address = "localhost";
11 // Set this to 0 to listen to socket file.
12 int listen_port = 8123;
13 std::string socket_user = "";
14 std::string socket_group = "";
15 int socket_permission = 0;
16 std::string base_url = "http://localhost:8123/";
17 std::string data_dir = ".";
18 std::string openid_url_prefix;
19 std::string client_id;
20 std::string client_secret;
21
22 static mw::E<Configuration> fromYaml(const std::filesystem::path& path);
23};
diff --git a/src/data.cpp b/src/data.cpp
new file mode 100644
index 0000000..78cb500
--- /dev/null
+++ b/src/data.cpp
@@ -0,0 +1,54 @@
1#include <memory>
2#include <string>
3#include <expected>
4
5#include <mw/database.hpp>
6#include <mw/error.hpp>
7#include <mw/utils.hpp>
8
9#include "data.hpp"
10
11namespace
12{
13
14} // namespace
15
16mw::E<std::unique_ptr<DataSourceSQLite>>
17DataSourceSQLite::fromFile(const std::string& db_file)
18{
19 auto data_source = std::make_unique<DataSourceSQLite>();
20 ASSIGN_OR_RETURN(data_source->db, mw::SQLite::connectFile(db_file));
21
22 // Perform schema upgrade here.
23 //
24 // data_source->upgradeSchema1To2();
25
26 // Update this line when schema updates.
27 DO_OR_RETURN(data_source->setSchemaVersion(1));
28 DO_OR_RETURN(data_source->db->execute(
29 "CREATE TABLE IF NOT EXISTS Users "
30 "(id INTEGER PRIMARY KEY, openid_uid TEXT, name TEXT);"));
31 DO_OR_RETURN(data_source->db->execute(
32 "CREATE TABLE IF NOT EXISTS LinkItems "
33 "(id INTEGER PRIMARY KEY,"
34 " FOREIGN KEY(owner_id) REFERENCES Users(id) NOT NULL,"
35 " FOREIGN KEY(parent_id) REFERENCES LinkItems(id),"
36 " name TEXT NOT NULL, url TEXT, description TEXT,"
37 " visibility INTEGER NOT NULL, time INTEGER NOT NULL);"));
38 return data_source;
39}
40
41mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::newFromMemory()
42{
43 return fromFile(":memory:");
44}
45
46mw::E<int64_t> DataSourceSQLite::getSchemaVersion() const
47{
48 return db->evalToValue<int64_t>("PRAGMA user_version;");
49}
50
51mw::E<void> DataSourceSQLite::setSchemaVersion(int64_t v) const
52{
53 return db->execute(std::format("PRAGMA user_version = {};", v));
54}
diff --git a/src/data.hpp b/src/data.hpp
new file mode 100644
index 0000000..dc64e6f
--- /dev/null
+++ b/src/data.hpp
@@ -0,0 +1,73 @@
1#pragma once
2
3#include <memory>
4#include <string>
5#include <optional>
6#include <vector>
7
8#include <mw/database.hpp>
9#include <mw/error.hpp>
10#include <mw/utils.hpp>
11
12struct LinkItem
13{
14 enum Visibility {PUBLIC, PRIVATE};
15
16 int64_t id;
17 int64_t owner_id;
18 // Top-level items don’t have parents.
19 std::optional<int64_t> parent_id;
20 std::string name;
21 // If this is empty, it’s a parent.
22 std::string url;
23 std::string description;
24 Visibility visibility;
25 mw::Time time;
26};
27
28struct User
29{
30 int64_t id;
31 std::string openid_uid;
32 std::string name;
33};
34
35class DataSourceInterface
36{
37public:
38 virtual ~DataSourceInterface() = default;
39
40 // The schema version is the version of the database. It starts
41 // from 1. Every time the schema change, someone should increase
42 // this number by 1, manually, by hand. The intended use is to
43 // help with database migration.
44 virtual mw::E<int64_t> getSchemaVersion() const = 0;
45
46 virtual std::vector<LinkItem> items(std::optional<int64_t> parent) = 0;
47
48protected:
49 virtual mw::E<void> setSchemaVersion(int64_t v) const = 0;
50};
51
52class DataSourceSQLite : public DataSourceInterface
53{
54public:
55 explicit DataSourceSQLite(std::unique_ptr<mw::SQLite> conn)
56 : db(std::move(conn)) {}
57 ~DataSourceSQLite() override = default;
58
59 static mw::E<std::unique_ptr<DataSourceSQLite>>
60 fromFile(const std::string& db_file);
61 static mw::E<std::unique_ptr<DataSourceSQLite>> newFromMemory();
62
63 mw::E<int64_t> getSchemaVersion() const override;
64
65 // Do not use.
66 DataSourceSQLite() = default;
67
68protected:
69 mw::E<void> setSchemaVersion(int64_t v) const override;
70
71private:
72 std::unique_ptr<mw::SQLite> db;
73};
diff --git a/src/data_mock.hpp b/src/data_mock.hpp
new file mode 100644
index 0000000..363eb9e
--- /dev/null
+++ b/src/data_mock.hpp
@@ -0,0 +1,24 @@
1#pragma once
2
3#include <vector>
4#include <string>
5#include <optional>
6
7#include <gmock/gmock.h>
8#include <mw/error.hpp>
9
10#include "data.hpp"
11
12class DataSourceMock : public DataSourceInterface
13{
14public:
15 ~DataSourceMock() override = default;
16
17 MOCK_METHOD(mw::E<int64_t>, getSchemaVersion, (), (const override));
18
19protected:
20 mw::E<void> setSchemaVersion([[maybe_unused]] int64_t v) const override
21 {
22 return {};
23 }
24};
diff --git a/src/data_test.cpp b/src/data_test.cpp
new file mode 100644
index 0000000..f1a1a1c
--- /dev/null
+++ b/src/data_test.cpp
@@ -0,0 +1,17 @@
1#include <vector>
2
3#include <gtest/gtest.h>
4#include <gmock/gmock.h>
5#include <mw/error.hpp>
6#include <mw/utils.hpp>
7#include <mw/test_utils.hpp>
8
9#include "data.hpp"
10
11using ::testing::IsEmpty;
12
13TEST(DataSource, CanAddAndDeleteLink)
14{
15 ASSIGN_OR_FAIL(std::unique_ptr<DataSourceSQLite> data,
16 DataSourceSQLite::newFromMemory());
17}
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..1aceef1
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,29 @@
1#include <memory>
2
3#include <cxxopts.hpp>
4#include <spdlog/spdlog.h>
5#include <mw/url.hpp>
6#include <mw/error.hpp>
7
8#include "config.hpp"
9#include "data.hpp"
10#include "app.hpp"
11
12int main(int argc, char** argv)
13{
14 cxxopts::Options cmd_options(
15 "shrt", "A naively simple URL shortener");
16 cmd_options.add_options()
17 ("c,config", "Config file",
18 cxxopts::value<std::string>()->default_value("/etc/shrt.yaml"))
19 ("h,help", "Print this message.");
20 auto opts = cmd_options.parse(argc, argv);
21
22 if(opts.count("help"))
23 {
24 std::cout << cmd_options.help() << std::endl;
25 return 0;
26 }
27
28 return 0;
29}
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 @@
1/* http://meyerweb.com/eric/tools/css/reset/
2 v2.0 | 20110126
3 License: none (public domain)
4*/
5
6html, body, div, span, applet, object, iframe,
7h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8a, abbr, acronym, address, big, cite, code,
9del, dfn, em, img, ins, kbd, q, s, samp,
10small, strike, strong, sub, sup, tt, var,
11b, u, i, center,
12dl, dt, dd, ol, ul, li,
13fieldset, form, label, legend,
14table, caption, tbody, tfoot, thead, tr, th, td,
15article, aside, canvas, details, embed,
16figure, figcaption, footer, header, hgroup,
17menu, nav, output, ruby, section, summary,
18time, mark, audio, video {
19 margin: 0;
20 padding: 0;
21 border: 0;
22 font-size: unset;
23 font: inherit;
24 vertical-align: baseline;
25}
26/* HTML5 display-role reset for older browsers */
27article, aside, details, figcaption, figure,
28footer, header, hgroup, menu, nav, section {
29 display: block;
30}
31body {
32 line-height: 1;
33}
34ol, ul {
35 list-style: none;
36}
37blockquote, q {
38 quotes: none;
39}
40blockquote:before, blockquote:after,
41q:before, q:after {
42 content: '';
43 content: none;
44}
45table {
46 border-collapse: collapse;
47 border-spacing: 0;
48}
49
50/* ========== Actual styles ======================================> */
51
52:root
53{
54 --color-bg: #c0c0c0;
55 --color-fg: black;
56 --color-border-dark-1: #808080;
57 --color-border-dark-2: black;
58 --color-border-light-1: #dfdfdf;
59 --color-border-light-2: white;
60 --color-titlebar-1: navy;
61 --color-titlebar-2: #1084d0;
62}
63
64*
65{
66 box-sizing: border-box;
67}
68
69html
70{
71 background: #008080;
72 color: var(--color-fg);
73 font-family: sans-serif;
74 font-size: 12px;
75 padding: 12px;
76}
77
78.Window
79{
80 box-shadow: inset -1px -1px var(--color-border-dark-2),
81 inset 1px 1px var(--color-border-light-1),
82 inset -2px -2px var(--color-border-dark-1),
83 inset 2px 2px var(--color-border-light-2);
84 background: var(--color-bg);
85 padding: 10px;
86
87 .Titlebar
88 {
89 background: linear-gradient(90deg, var(--color-titlebar-1), var(--color-titlebar-2));
90 margin: -6px -6px 10px -6px;
91 color: white;
92 padding: 3px;
93 }
94}
95
96.Dialog
97{
98 max-width: 800px;
99 margin: 0px auto;
100}
101
102input[type="text"], input[type="url"], input[type="email"], textarea
103{
104 appearance: none;
105 border: none;
106 outline: none;
107 box-shadow: inset -1px -1px var(--color-border-light-2),
108 inset 1px 1px var(--color-border-dark-1),
109 inset -2px -2px var(--color-border-light-1),
110 inset 2px 2px var(--color-border-dark-2);
111 width: 100%;
112 padding: 4px 6px;
113 font-family: monospace;
114 font-size: 0.9rem;
115}
116
117a.Button, a.FloatButton
118{
119 color: black;
120 text-decoration: none;
121}
122
123button, input[type="submit"]
124{
125 appearance: none;
126 border: none;
127 outline: none;
128 box-shadow: inset -1px -1px var(--color-border-dark-2),
129 inset 1px 1px var(--color-border-light-1),
130 inset -2px -2px var(--color-border-dark-1),
131 inset 2px 2px var(--color-border-light-2);
132 background: var(--color-bg);
133 min-height: 23px;
134 min-width: 75px;
135 padding: 0 12px;
136 text-align: center;
137}
138
139.IconButton
140{
141 padding: 4px;
142 min-width: unset;
143 min-height: unset;
144}
145
146.FloatButton
147{
148 padding: 4px;
149 min-width: unset;
150 min-height: unset;
151 box-shadow: none;
152}
153
154.FloatButton:hover
155{
156 padding: 4px;
157 min-width: unset;
158 min-height: unset;
159 box-shadow: inset 1px 1px var(--color-border-light-1),
160 inset -1px -1px var(--color-border-dark-1);
161}
162
163button:active, .FloatButton:active
164{
165 box-shadow: inset -1px -1px var(--color-border-light-2),
166 inset 1px 1px var(--color-border-dark-1),
167 inset -2px -2px var(--color-border-light-1),
168 inset 2px 2px var(--color-border-dark-2);
169}
170
171hr
172{
173 border: none;
174 border-top: solid var(--color-border-dark-1) 1px;
175 border-bottom: solid var(--color-border-light-1) 1px;
176}
177
178.ButtonRow
179{
180 display: flex;
181 justify-content: right;
182 gap: 10px;
183 margin: 10px 0;
184}
185
186.ButtonRowLeft
187{
188 text-align: Left;
189 margin-top: 10px;
190}
191
192.Toolbar
193{
194 display: flex;
195 border-bottom: solid var(--color-border-dark-1) 1px;
196 box-shadow: 0px 1px var(--color-border-light-1);
197 position: relative;
198 left: -10px;
199 top: -10px;
200 width: calc(100% + 2ex);
201 padding: 2px 8px;
202}
203
204table.InputFields
205{
206 border-collapse: collapse;
207 width: 100%;
208 table-layout: auto;
209
210 td
211 {
212 padding: 2px 2px;
213 }
214
215 td:nth-child(2)
216 {
217 width: 100%;
218 }
219
220}
221
222.StatusBar
223{
224 margin: 10px -6px -7px -6px;
225 display: flex;
226}
227
228.StatusCell
229{
230 box-shadow: inset -2px -2px var(--color-border-light-2),
231 inset 2px 2px var(--color-border-dark-1);
232 padding: 4px;
233 flex-grow: 1;
234}
235
236nav
237{
238 display: flex;
239 justify-content: space-between;
240}
241
242#Links
243{
244 box-shadow: inset -1px -1px var(--color-border-light-2),
245 inset 1px 1px var(--color-border-dark-1),
246 inset -2px -2px var(--color-border-light-1),
247 inset 2px 2px var(--color-border-dark-2);
248 padding: 2px;
249}
250
251table.TableView
252{
253 background-color: white;
254 border-collapse: collapse;
255 width: 100%;
256
257 thead th
258 {
259 box-shadow: inset -1px -1px var(--color-border-dark-2),
260 inset 1px 1px var(--color-border-light-1),
261 inset -2px -2px var(--color-border-dark-1),
262 inset 2px 2px var(--color-border-light-2);
263 background-color: var(--color-bg);
264 padding: 2px 4px 4px 4px;
265 text-align: left;
266 }
267
268 td
269 {
270 padding: 2px;
271 }
272}
273
274#Links table
275{
276 th:nth-child(2)
277 {
278 width: 100%;
279 }
280
281 /* th:nth-child(3), th:nth-child(4) */
282 /* { */
283 /* width: 64px; */
284 /* } */
285
286 td:nth-child(3), td:nth-child(4)
287 {
288 text-align: center;
289 }
290}
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 @@
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 {% include "head.html" %}
5 <title>shrt – Delet a Link</title>
6 </head>
7 <body>
8 <div id="Body" class="Window Dialog">
9 {% include "nav.html" %}
10 <form action="{{ url_for("delete-link") }}" method="post" id="DeleteLinkForm">
11 <table class="InputFields">
12 <tr>
13 <td><label for="id">Shortcut</label></td>
14 <td><input type="text" name="id" id="id" readonly value="{{ link.id_str }}" /></td>
15 </tr>
16 <tr>
17 <td><label for="shortcut">Shortcut</label></td>
18 <td><input type="text" id="shortcut" readonly value="{{ link.shortcut }}" /></td>
19 </tr>
20 <tr>
21 <td><label for="original_url">URL</label></td>
22 <td><input type="url" id="original_url" readonly value="{{ link.original_url }}" /></td>
23 </tr>
24 </table>
25 <div class="ButtonRow">
26 <input type="submit" value="Delete link!" />
27 </div>
28 </form>
29 {% include "footer.html" %}
30 </div>
31 </body>
32</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 @@
1<footer class="StatusBar">
2 <span class="StatusCell">
3 Powered by <a href="https://github.com/MetroWind/shrt">shrt</a>
4 </span>
5</footer>
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 @@
1<meta charset="utf-8">
2<meta name="viewport" content="width=device-width,initial-scale=1">
3<link rel="icon" href="{{ url_for("statics", "icon.svg") }}" type="image/svg+xml">
4<link rel="apple-touch-icon" href="{{ url_for("statics", "icon-180.png") }}">
5<link rel="stylesheet" href="{{ url_for("statics", "styles.css") }}">
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 @@
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>
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 @@
1<nav class="Titlebar">
2 <span id="NavLeft">
3 {{ title }}
4 </span>
5
6 <span id="NavCenter"></span>
7
8 <span id="NavRight">
9 <span>
10 {% if length(session_user) > 0 %}
11 {{ session_user }}
12 {% else %}
13 <a href="{{ url_for("login") }}">Login</a>
14 {% endif %}
15 </span>
16 </span>
17</nav>
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 @@
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 {% include "head.html" %}
5 <title>shrt – Create a New Link</title>
6 </head>
7 <body>
8 <div id="Body" class="Window Dialog">
9 {% include "nav.html" %}
10 <form action="{{ url_for("create-link") }}" method="post" id="LinkForm">
11 <table class="InputFields">
12 <tr>
13 <td><label for="shortcut">Shortcut</label></td>
14 <td><input type="text" name="shortcut" id="shortcut"
15 minlength="2" pattern="(\p{L}|\p{N}|_|-|\.)+"></td>
16 </tr>
17 <tr>
18 <td><label for="original_url">URL</label></td>
19 <td><input type="url" name="original_url" id="original_url" required></td>
20 </tr>
21 <tr>
22 <td></td>
23 <td>
24 <input type="checkbox" id="regexp" name="regexp">
25 <label for="regexp">Regexp</label>
26 </td>
27 </tr>
28 </table>
29 <div class="ButtonRow">
30 <input type="submit" value="Add link!">
31 </div>
32 </form>
33 {% include "footer.html" %}
34 </div>
35 </body>
36</html>