diff options
author | MetroWind <chris.corsair@gmail.com> | 2025-09-07 09:42:33 -0700 |
---|---|---|
committer | MetroWind <chris.corsair@gmail.com> | 2025-09-07 09:42:33 -0700 |
commit | ea0d3220db995018335c48eb06b9794235ff436b (patch) | |
tree | 892564cdd4946c6ee9c1051bc31ff5c7bba6ddf1 |
Initial commit, mostly just copied from shrt.
-rw-r--r-- | CMakeLists.txt | 136 | ||||
-rw-r--r-- | COPYING.txt | 20 | ||||
-rw-r--r-- | README.adoc | 103 | ||||
-rw-r--r-- | packages/arch/PKGBUILD | 50 | ||||
-rw-r--r-- | packages/arch/shrt.install | 9 | ||||
-rw-r--r-- | packages/arch/shrt.service | 12 | ||||
-rw-r--r-- | packages/arch/shrt.yaml | 7 | ||||
-rw-r--r-- | packages/arch/sysusers-shrt.conf | 1 | ||||
-rw-r--r-- | src/app.cpp | 342 | ||||
-rw-r--r-- | src/app.hpp | 84 | ||||
-rw-r--r-- | src/app_test.cpp | 205 | ||||
-rw-r--r-- | src/config.cpp | 85 | ||||
-rw-r--r-- | src/config.hpp | 23 | ||||
-rw-r--r-- | src/data.cpp | 54 | ||||
-rw-r--r-- | src/data.hpp | 73 | ||||
-rw-r--r-- | src/data_mock.hpp | 24 | ||||
-rw-r--r-- | src/data_test.cpp | 17 | ||||
-rw-r--r-- | src/main.cpp | 29 | ||||
-rw-r--r-- | statics/styles.css | 290 | ||||
-rw-r--r-- | templates/delete-link.html | 32 | ||||
-rw-r--r-- | templates/footer.html | 5 | ||||
-rw-r--r-- | templates/head.html | 5 | ||||
-rw-r--r-- | templates/links.html | 39 | ||||
-rw-r--r-- | templates/nav.html | 17 | ||||
-rw-r--r-- | templates/new-link.html | 36 |
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 | ||
2 | cmake_minimum_required(VERSION 3.24) | ||
3 | |||
4 | set(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. | ||
13 | if(APPLE) | ||
14 | set(CMAKE_CXX_FLAGS "-I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1") | ||
15 | endif() | ||
16 | |||
17 | project(Webdir) | ||
18 | option(WEBDIR_BUILD_TESTS "Build unit tests" OFF) | ||
19 | |||
20 | include(FetchContent) | ||
21 | FetchContent_Declare( | ||
22 | libmw | ||
23 | GIT_REPOSITORY https://github.com/MetroWind/libmw.git | ||
24 | ) | ||
25 | |||
26 | FetchContent_Declare( | ||
27 | cxxopts | ||
28 | GIT_REPOSITORY https://github.com/jarro2783/cxxopts.git | ||
29 | GIT_TAG v3.1.1 | ||
30 | ) | ||
31 | |||
32 | FetchContent_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 | |||
39 | FetchContent_Declare( | ||
40 | spdlog | ||
41 | GIT_REPOSITORY https://github.com/gabime/spdlog.git | ||
42 | GIT_TAG v1.12.0 | ||
43 | ) | ||
44 | |||
45 | FetchContent_Declare( | ||
46 | json | ||
47 | GIT_REPOSITORY https://github.com/nlohmann/json.git | ||
48 | GIT_TAG v3.11.3 | ||
49 | ) | ||
50 | |||
51 | FetchContent_Declare( | ||
52 | inja | ||
53 | GIT_REPOSITORY https://github.com/pantor/inja.git | ||
54 | GIT_TAG main | ||
55 | ) | ||
56 | |||
57 | set(SPDLOG_USE_STD_FORMAT ON) | ||
58 | set(LIBMW_BUILD_URL ON) | ||
59 | set(LIBMW_BUILD_SQLITE ON) | ||
60 | set(LIBMW_BUILD_HTTP_SERVER ON) | ||
61 | set(INJA_USE_EMBEDDED_JSON FALSE) | ||
62 | set(INJA_BUILD_TESTS FALSE) | ||
63 | FetchContent_MakeAvailable(libmw ryml spdlog cxxopts json inja) | ||
64 | |||
65 | if(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) | ||
71 | endif() | ||
72 | |||
73 | set(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 | |||
82 | set(LIBS | ||
83 | cxxopts | ||
84 | mw::mw | ||
85 | mw::url | ||
86 | mw::sqlite | ||
87 | mw::http-server | ||
88 | ryml::ryml | ||
89 | spdlog::spdlog | ||
90 | ) | ||
91 | |||
92 | set(INCLUDES | ||
93 | ${libmw_SOURCE_DIR}/includes | ||
94 | ${json_SOURCE_DIR}/single_include | ||
95 | ${inja_SOURCE_DIR}/single_include/inja | ||
96 | ) | ||
97 | |||
98 | add_executable(webdir ${SOURCE_FILES} src/main.cpp) | ||
99 | set_property(TARGET webdir PROPERTY CXX_STANDARD 23) | ||
100 | set_property(TARGET webdir PROPERTY CXX_EXTENSIONS FALSE) | ||
101 | |||
102 | set_property(TARGET webdir PROPERTY COMPILE_WARNING_AS_ERROR TRUE) | ||
103 | target_compile_options(webdir PRIVATE -Wall -Wextra -Wpedantic) | ||
104 | target_include_directories(webdir PRIVATE ${INCLUDES}) | ||
105 | target_link_libraries(webdir PRIVATE ${LIBS}) | ||
106 | |||
107 | if(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}) | ||
136 | 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 @@ | |||
1 | Copyright (c) 2025 MetroWind <chris.corsair@gmail.com> | ||
2 | |||
3 | Permission is hereby granted, free of charge, to any person obtaining | ||
4 | a copy of this software and associated documentation files (the | ||
5 | "Software"), to deal in the Software without restriction, including | ||
6 | without limitation the rights to use, copy, modify, merge, publish, | ||
7 | distribute, sublicense, and/or sell copies of the Software, and to | ||
8 | permit persons to whom the Software is furnished to do so, subject to | ||
9 | the following conditions: | ||
10 | |||
11 | The above copyright notice and this permission notice shall be | ||
12 | included in all copies or substantial portions of the Software. | ||
13 | |||
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | ||
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. | ||
17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY | ||
18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, | ||
19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE | ||
20 | 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 @@ | |||
1 | = Shrt | ||
2 | |||
3 | A naively simple URL shortener | ||
4 | |||
5 | image::screenshot-0.webp[Screenshot] | ||
6 | |||
7 | image::screenshot-1.webp[Screenshot] | ||
8 | |||
9 | == Installation | ||
10 | |||
11 | Only *NIX systems are supported. | ||
12 | |||
13 | Runtime dependencies: | ||
14 | |||
15 | - cURL | ||
16 | - OpenSSL | ||
17 | - SQLite | ||
18 | |||
19 | Build dependencies: | ||
20 | |||
21 | - CMake | ||
22 | - A C++23 compiler | ||
23 | |||
24 | === Building from source | ||
25 | |||
26 | 1. Clone the repo | ||
27 | 2. Go into the repo directory, and run `cmake -B build`. | ||
28 | 3. Run `cmake --build build -j`. | ||
29 | 4. Pick a data directory where the database will be created. | ||
30 | 5. Copy the `templates` and `statics` directories into the data | ||
31 | directory. | ||
32 | 6. Copy `build/shrt` into any directory that is in your `$PATH`. | ||
33 | |||
34 | === Using pre-build binary | ||
35 | |||
36 | 1. Download the binary from one of the releases. | ||
37 | 2. Extract the downloaded tarball. | ||
38 | 3. Continue to step 4 in “Building from source”. | ||
39 | |||
40 | === Arch Linux package | ||
41 | |||
42 | A PKGBUILD is provided in `packages/arch` in this repository. | ||
43 | |||
44 | == Configuartion | ||
45 | |||
46 | By default, shrt looks for a configuration file at `/etc/shrt.yaml`. | ||
47 | This path can be changed with the `-c` command line option. An example | ||
48 | configuration file is printed below with comments. | ||
49 | |||
50 | [source,yaml] | ||
51 | ---- | ||
52 | # Specify the data directory you have picked. | ||
53 | data-dir: "/var/lib/shrt" | ||
54 | # The listening address. Example: localhost, 0.0.0.0, 127.0.0.1. | ||
55 | listen-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. | ||
58 | listen-port: 8080 | ||
59 | # The client ID of your shrt service. This is given by the OpenID | ||
60 | # Connect provider. | ||
61 | client-id: shrt | ||
62 | # The client secrete of your shrt service. This is given by the OpenID | ||
63 | # Connect provider. | ||
64 | client-secret: "abced12345" | ||
65 | # The initial URL of the OpenID Connect service. | ||
66 | openid-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. | ||
69 | base-url: https://go.mws.rocks | ||
70 | ---- | ||
71 | |||
72 | === Authentication | ||
73 | |||
74 | Shrt relies on an external OpenID Connect service provider for | ||
75 | authentication. Usually you register your instance of shrt to the | ||
76 | OpenID Connect provider. The provider will give you an client ID and a | ||
77 | client secret (in this case your shrt server is the “client” of the | ||
78 | OpenID Connect service). An OpenID Connect service has a number of | ||
79 | endpoints, such as user info, tokens, etc. The URLs of those endpoints | ||
80 | are discover by visiting a URL that is constructed from the URL in the | ||
81 | configuration 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 | ||
84 | URL would be | ||
85 | |||
86 | ---- | ||
87 | https://auth.example.com/.well-known/openid-configuration | ||
88 | ---- | ||
89 | |||
90 | This method works on KeyCloak, but I have never tested this on other | ||
91 | OpenID Connect providers. | ||
92 | |||
93 | === Base URL and shortcuts | ||
94 | |||
95 | For the configuration option `base-url`, usually this is just | ||
96 | `https://` followed by a domain that you own. Supposed you create a | ||
97 | shortcut called “search” which points to `https://google.com`, you | ||
98 | would be able to visit `https://your.domain/search` and be redirected | ||
99 | to `https://google.com`. However you do not have to use a root URL | ||
100 | under 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 | ||
103 | 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 @@ | |||
1 | pkgname=shrt-git | ||
2 | pkgver=0.1 | ||
3 | pkgrel=1 | ||
4 | pkgdesc="A naively simple link shortener" | ||
5 | arch=('x86_64') | ||
6 | url="https://github.com/MetroWind/shrt" | ||
7 | license=('MIT') | ||
8 | groups=() | ||
9 | depends=('sqlite' 'curl' 'openssl') | ||
10 | makedepends=('git' 'cmake' 'gcc') | ||
11 | provides=("${pkgname%-git}") | ||
12 | conflicts=("${pkgname%-git}") | ||
13 | replaces=() | ||
14 | backup=("etc/shrt.yaml") | ||
15 | # Stripping doesn’t work with ryml. | ||
16 | options=(!debug !strip) | ||
17 | install=shrt.install | ||
18 | source=('git+https://github.com/MetroWind/shrt.git' "sysusers-${pkgname%-git}.conf" "${pkgname%-git}.service" "${pkgname%-git}.yaml") | ||
19 | noextract=() | ||
20 | sha256sums=('SKIP' "1ea5c7d99be0954fb9aa6e22e7f11d485fd66d3232df3cbe3051c81e542b4bfc" | ||
21 | "1e65ce88985b19471af84a95ebf8f2d6726e6af434cd4dafd7203ad783510a0f" | ||
22 | "c91a4e0a43373e08343aba704cbd064936521decf23546a74d2d8b3f08a8e963") | ||
23 | |||
24 | pkgver() | ||
25 | { | ||
26 | cd "$srcdir/${pkgname%-git}" | ||
27 | printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)" | ||
28 | } | ||
29 | |||
30 | build() | ||
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 | |||
42 | package() | ||
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 @@ | |||
1 | post_install() | ||
2 | { | ||
3 | chown -R shrt:shrt var/lib/shrt | ||
4 | } | ||
5 | |||
6 | post_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] | ||
2 | Description=Shrt service | ||
3 | After=network.target | ||
4 | |||
5 | [Service] | ||
6 | User=shrt | ||
7 | Group=shrt | ||
8 | ExecStart=/usr/bin/shrt | ||
9 | Restart=on-failure | ||
10 | |||
11 | [Install] | ||
12 | 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 @@ | |||
1 | data-dir: "/var/lib/shrt" | ||
2 | listen-address: localhost | ||
3 | listen-port: 8080 | ||
4 | # client-id: | ||
5 | # client-secret: | ||
6 | # openid-url-prefix: | ||
7 | 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 @@ | |||
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 | |||
27 | namespace | ||
28 | { | ||
29 | |||
30 | std::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 | |||
68 | void 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 | |||
97 | mw::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 | |||
116 | App::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 | |||
148 | std::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 | |||
195 | void App::handleIndex(Response& res) const | ||
196 | { | ||
197 | res.set_redirect(urlFor("links"), 301); | ||
198 | } | ||
199 | |||
200 | void App::handleLogin(Response& res) const | ||
201 | { | ||
202 | res.set_redirect(auth->initialURL(), 301); | ||
203 | } | ||
204 | |||
205 | void 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 | |||
236 | std::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 | |||
243 | void 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 | |||
270 | mw::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 | |||
304 | std::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 | |||
16 | class App : public mw::HTTPServer | ||
17 | { | ||
18 | public: | ||
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 | |||
30 | private: | ||
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 | |||
16 | using ::testing::_; | ||
17 | using ::testing::Return; | ||
18 | using ::testing::HasSubstr; | ||
19 | using ::testing::FieldsAre; | ||
20 | using ::testing::ContainsRegex; | ||
21 | |||
22 | void 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 | |||
34 | class UserAppTest : public testing::Test | ||
35 | { | ||
36 | protected: | ||
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 | |||
65 | TEST_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 | |||
78 | TEST_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 | |||
115 | TEST_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 | |||
128 | TEST_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 | |||
143 | TEST_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 | |||
156 | TEST_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 | "®exp=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 | "®exp=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 | |||
13 | namespace { | ||
14 | |||
15 | mw::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 | |||
33 | mw::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 | |||
8 | struct 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 | |||
11 | namespace | ||
12 | { | ||
13 | |||
14 | } // namespace | ||
15 | |||
16 | mw::E<std::unique_ptr<DataSourceSQLite>> | ||
17 | DataSourceSQLite::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 | |||
41 | mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::newFromMemory() | ||
42 | { | ||
43 | return fromFile(":memory:"); | ||
44 | } | ||
45 | |||
46 | mw::E<int64_t> DataSourceSQLite::getSchemaVersion() const | ||
47 | { | ||
48 | return db->evalToValue<int64_t>("PRAGMA user_version;"); | ||
49 | } | ||
50 | |||
51 | mw::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 | |||
12 | struct 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 | |||
28 | struct User | ||
29 | { | ||
30 | int64_t id; | ||
31 | std::string openid_uid; | ||
32 | std::string name; | ||
33 | }; | ||
34 | |||
35 | class DataSourceInterface | ||
36 | { | ||
37 | public: | ||
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 | |||
48 | protected: | ||
49 | virtual mw::E<void> setSchemaVersion(int64_t v) const = 0; | ||
50 | }; | ||
51 | |||
52 | class DataSourceSQLite : public DataSourceInterface | ||
53 | { | ||
54 | public: | ||
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 | |||
68 | protected: | ||
69 | mw::E<void> setSchemaVersion(int64_t v) const override; | ||
70 | |||
71 | private: | ||
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 | |||
12 | class DataSourceMock : public DataSourceInterface | ||
13 | { | ||
14 | public: | ||
15 | ~DataSourceMock() override = default; | ||
16 | |||
17 | MOCK_METHOD(mw::E<int64_t>, getSchemaVersion, (), (const override)); | ||
18 | |||
19 | protected: | ||
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 | |||
11 | using ::testing::IsEmpty; | ||
12 | |||
13 | TEST(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 | |||
12 | int 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 | |||
6 | html, body, div, span, applet, object, iframe, | ||
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, | ||
8 | a, abbr, acronym, address, big, cite, code, | ||
9 | del, dfn, em, img, ins, kbd, q, s, samp, | ||
10 | small, strike, strong, sub, sup, tt, var, | ||
11 | b, u, i, center, | ||
12 | dl, dt, dd, ol, ul, li, | ||
13 | fieldset, form, label, legend, | ||
14 | table, caption, tbody, tfoot, thead, tr, th, td, | ||
15 | article, aside, canvas, details, embed, | ||
16 | figure, figcaption, footer, header, hgroup, | ||
17 | menu, nav, output, ruby, section, summary, | ||
18 | time, 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 */ | ||
27 | article, aside, details, figcaption, figure, | ||
28 | footer, header, hgroup, menu, nav, section { | ||
29 | display: block; | ||
30 | } | ||
31 | body { | ||
32 | line-height: 1; | ||
33 | } | ||
34 | ol, ul { | ||
35 | list-style: none; | ||
36 | } | ||
37 | blockquote, q { | ||
38 | quotes: none; | ||
39 | } | ||
40 | blockquote:before, blockquote:after, | ||
41 | q:before, q:after { | ||
42 | content: ''; | ||
43 | content: none; | ||
44 | } | ||
45 | table { | ||
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 | |||
69 | html | ||
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 | |||
102 | input[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 | |||
117 | a.Button, a.FloatButton | ||
118 | { | ||
119 | color: black; | ||
120 | text-decoration: none; | ||
121 | } | ||
122 | |||
123 | button, 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 | |||
163 | button: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 | |||
171 | hr | ||
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 | |||
204 | table.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 | |||
236 | nav | ||
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 | |||
251 | table.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> | ||