aboutsummaryrefslogtreecommitdiff
path: root/src/app.cpp
diff options
context:
space:
mode:
authorMetroWind <chris.corsair@gmail.com>2025-09-07 09:42:33 -0700
committerMetroWind <chris.corsair@gmail.com>2025-09-07 09:42:33 -0700
commitea0d3220db995018335c48eb06b9794235ff436b (patch)
tree892564cdd4946c6ee9c1051bc31ff5c7bba6ddf1 /src/app.cpp
Initial commit, mostly just copied from shrt.
Diffstat (limited to 'src/app.cpp')
-rw-r--r--src/app.cpp342
1 files changed, 342 insertions, 0 deletions
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}