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 /src/app.cpp |
Initial commit, mostly just copied from shrt.
Diffstat (limited to 'src/app.cpp')
-rw-r--r-- | src/app.cpp | 342 |
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 | |||
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 | } | ||