GCC Code Coverage Report


Directory: ./
File: fpphttp_compat.cpp
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 92 113 81.4%
Functions: 10 11 90.9%
Branches: 84 176 47.7%

Line Branch Exec Source
1 /*
2 * This file is part of the Falcon Player (FPP) and is Copyright (C)
3 * 2013-2022 by the Falcon Player Developers.
4 *
5 * The Falcon Player (FPP) is free software, and is covered under
6 * multiple Open Source licenses. Please see the included 'LICENSES'
7 * file for descriptions of what files are covered by each license.
8 *
9 * This source file is covered under the LGPL v2.1 as described in the
10 * included LICENSE.LGPL file.
11 */
12
13 // The plugin HTTP route registry behind FPPPlugins::registerPluginApi(). Kept in
14 // its own translation unit so that fpphttp.h avoids pulling in the heavy
15 // <drogon/HttpAppFramework.h> header everywhere.
16 //
17 // Plugin routes are not drogon routes
18 // -----------------------------------
19 // Drogon's route table is frozen once app().run() starts: registering a handler
20 // after that asserts (and, in a build without asserts, mutates the router's maps
21 // unlocked under the I/O threads and never compiles regex routes). Plugins are
22 // loaded at runtime -- an install or reinstall does unload + load without
23 // restarting fppd -- so a plugin path that was not registered before run() can
24 // never become a drogon route.
25 //
26 // So no plugin path is ever handed to drogon. installPluginApiRouter() installs
27 // one drogon default handler before run(); drogon calls it for any request that
28 // none of FPP's own routes matched, and it looks the path up in the registry
29 // below on every request. Registering, re-arming and disarming a plugin path are
30 // then just registry edits, safe at any time.
31 //
32 // Safe plugin unregistration
33 // --------------------------
34 // A registered path owns a slot. While a plugin is live the slot names its
35 // handler and the call is dispatched. Once disarmed the path answers 410 Gone
36 // instead of crashing, and arming the slot again -- from a reinstalled or newly
37 // built copy of the plugin -- puts the same path back to work. Nothing about
38 // that depends on the plugin's .so still being the one that registered it.
39 //
40 // The handler is the plugin's own std::function: its code and captured state
41 // live in the plugin's .so, so disarming must *destroy* it, not merely forget
42 // it. See disarmSlots() for the ordering that makes that safe.
43 //
44 // The registry maps path string -> slot, so register and unregister calls, from
45 // any generation of a plugin, find the same slot. Slots are never erased, so a
46 // path that was ever registered keeps answering 410 rather than 404.
47
48 // HttpAppFramework.h must come before fpphttp.h: fpphttp.h undefines LOG_DEBUG
49 // (to avoid a conflict with FPP's log.h), but drogon's orm/Field.h uses LOG_DEBUG
50 // during its own compilation. Including the full framework header first lets all
51 // drogon headers compile with LOG_DEBUG intact; fpphttp.h then cleans it up.
52 #include <drogon/HttpAppFramework.h>
53 #include "fpphttp.h"
54 #include "log.h"
55 #include <atomic>
56 #include <map>
57 #include <memory>
58 #include <mutex>
59 #include <shared_mutex>
60 #include <string>
61 #include <utility>
62 #include <vector>
63
64 // ---------------------------------------------------------------------------
65 // Route registry
66 // ---------------------------------------------------------------------------
67
68 namespace {
69
70 // What one path dispatches to.
71 struct RouteSlot {
72 // Guarded by s_registryMutex; held by shared_ptr so a dispatch can keep it
73 // alive for the length of the call without blocking the registry.
74 std::shared_ptr<FPPPlugins::PluginApiHandler> handler;
75 std::vector<drogon::HttpMethod> methods;
76 std::string path; // as registered, for listing; lookups use the normalized key
77 };
78 using SlotPtr = std::shared_ptr<RouteSlot>;
79
80 std::mutex s_registryMutex;
81 // Keyed by normalizedPath(). s_exact holds every registered path; s_family holds
82 // the family=true ones again, matching any subpath "<path>/...".
83 std::map<std::string, SlotPtr> s_exact;
84 std::map<std::string, SlotPtr> s_family;
85
86 // Held shared for the duration of a dispatch into plugin code, and exclusively
87 // by the disarm paths once the slot has been cleared. Clearing the slot alone
88 // is not enough: a handler that already read it is about to call into the
89 // plugin, and an unload dlclose()s the .so right after unregistration returns --
90 // unmapping the code and vtables out from under the in-flight request. Taking
91 // this exclusively makes disarming wait until no request is inside the plugin
92 // before it returns, so a subsequent dlclose() is safe. (A handler that
93 // disarmed its own path from inside itself would self-deadlock, but nothing
94 // does that.)
95 std::shared_mutex s_dispatchMutex;
96
97 // Drogon matched routes case-insensitively and ignoring a trailing '/', and
98 // plugin paths were drogon routes before, so lookups keep those rules.
99 56 std::string normalizedPath(const std::string& path) {
100 56 std::string p = path;
101
2/2
✓ Branch 0 taken 555 times.
✓ Branch 1 taken 56 times.
611 for (auto& c : p) {
102 555 c = (char)tolower((unsigned char)c);
103 }
104
3/6
✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 56 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 56 times.
56 while (p.size() > 1 && p.back() == '/') {
105 ✗ p.pop_back();
106 }
107 56 return p;
108 }
109
110 16 void armSlot(std::map<std::string, SlotPtr>& table, const std::string& key, const std::string& path,
111 const std::shared_ptr<FPPPlugins::PluginApiHandler>& fn,
112 const std::vector<drogon::HttpMethod>& methods) {
113 16 auto& slot = table[key];
114
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 if (!slot) {
115
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 slot = std::make_shared<RouteSlot>();
116 }
117 16 slot->handler = fn;
118 16 slot->methods = methods;
119 16 slot->path = path;
120 16 }
121
122 // The slot for a request path: an exact registration first, else the longest
123 // family registration it lies under. Caller holds s_registryMutex.
124 26 SlotPtr findSlot(const std::string& reqPath) {
125
1/2
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
26 std::string p = normalizedPath(reqPath);
126
1/2
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
26 auto it = s_exact.find(p);
127
2/2
✓ Branch 0 taken 24 times.
✓ Branch 1 taken 2 times.
26 if (it != s_exact.end()) {
128 24 return it->second;
129 }
130 for (;;) {
131 4 auto slash = p.rfind('/');
132
2/4
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 4 times.
4 if (slash == std::string::npos || slash == 0) {
133 2 return nullptr;
134 }
135
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 p.resize(slash);
136
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 auto f = s_family.find(p);
137
2/2
✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
4 if (f != s_family.end()) {
138 2 return f->second;
139 }
140 2 }
141 26 }
142
143 26 bool methodAllowed(const std::vector<drogon::HttpMethod>& methods, drogon::HttpMethod m) {
144
2/2
✓ Branch 0 taken 26 times.
✓ Branch 1 taken 1 times.
27 for (auto a : methods) {
145 // Drogon answered HEAD from a route's GET handler; keep doing that.
146
3/6
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 25 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
26 if (a == m || (m == drogon::Head && a == drogon::Get)) {
147 25 return true;
148 }
149 }
150 1 return false;
151 }
152
153 // The drogon default handler: every request no FPP route matched lands here.
154 26 void dispatchPluginApi(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback) {
155 // Declared first so it is released LAST: the handler copy below must be
156 // gone before a concurrent disarm is allowed to proceed, otherwise the
157 // last reference to the plugin's callable could be dropped -- running
158 // its destructor -- after the .so was unmapped.
159
1/2
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
26 std::shared_lock<std::shared_mutex> dispatchLock(s_dispatchMutex);
160
161 26 std::shared_ptr<FPPPlugins::PluginApiHandler> fn;
162 26 bool allowed = false;
163 {
164
1/2
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
26 std::lock_guard<std::mutex> lock(s_registryMutex);
165
2/4
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
26 SlotPtr slot = findSlot(req->path());
166
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 26 times.
26 if (!slot) {
167 ✗ dispatchLock.unlock();
168 ✗ callback(drogon::HttpResponse::newNotFoundResponse());
169 ✗ return;
170 }
171 26 fn = slot->handler;
172
1/2
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
26 allowed = methodAllowed(slot->methods, req->method());
173
2/4
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
26 }
174
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 23 times.
26 if (!fn) {
175
4/8
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
12 callback(makeStringResponse("Plugin not loaded", 410, "text/plain"));
176
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 22 times.
23 } else if (!allowed) {
177
4/8
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
4 callback(makeStringResponse("Method Not Allowed", 405, "text/plain"));
178 } else {
179 // drogon wraps its own routes' handlers in a try/catch, but not the
180 // default handler: a plugin handler that throws would otherwise reach
181 // the event loop and terminate fppd. Answer 500 instead, unless the
182 // handler already responded before it threw.
183
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 auto responded = std::make_shared<std::atomic<bool>>(false);
184
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 auto cb = std::make_shared<std::function<void(const HttpResponsePtr&)>>(std::move(callback));
185 try {
186
4/6
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 16 times.
✓ Branch 3 taken 6 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 6 times.
40 (*fn)(req, [responded, cb](const HttpResponsePtr& resp) {
187 16 responded->store(true);
188 16 (*cb)(resp);
189 16 });
190
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 3 times.
6 } catch (const std::exception& ex) {
191
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 LogErr(VB_PLUGIN, "Plugin API handler for %s threw: %s\n", req->path().c_str(), ex.what());
192
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 if (!responded->exchange(true)) {
193
4/8
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
15 (*cb)(makeStringResponse("Plugin handler error", 500, "text/plain"));
194 }
195 6 } catch (...) {
196
2/4
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
3 LogErr(VB_PLUGIN, "Plugin API handler for %s threw a non-standard exception\n", req->path().c_str());
197
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 if (!responded->exchange(true)) {
198
4/8
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
15 (*cb)(makeStringResponse("Plugin handler error", 500, "text/plain"));
199 }
200
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 }
201 22 }
202
2/4
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 26 times.
✗ Branch 3 not taken.
26 }
203
204 // Clears both kinds of handler for the listed paths, then blocks until no
205 // request is inside plugin code, then destroys the plugin-owned callables.
206 // Destroy-then-return is the whole contract: the caller is entitled to dlclose()
207 // the moment this returns.
208 15 void disarmSlots(const std::string& path) {
209 15 std::vector<std::shared_ptr<FPPPlugins::PluginApiHandler>> extracted;
210 {
211
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::lock_guard<std::mutex> lock(s_registryMutex);
212
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::string key = normalizedPath(path);
213
2/2
✓ Branch 0 taken 30 times.
✓ Branch 1 taken 15 times.
45 for (auto* table : { &s_exact, &s_family }) {
214
1/2
✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
30 auto it = table->find(key);
215
2/2
✓ Branch 0 taken 14 times.
✓ Branch 1 taken 16 times.
30 if (it == table->end()) {
216 14 continue;
217 }
218 // Clear first, so requests arriving now bail at the empty slot
219 // rather than queueing behind the drain below.
220
1/2
✓ Branch 0 taken 16 times.
✗ Branch 1 not taken.
16 extracted.push_back(std::move(it->second->handler));
221 16 it->second->handler.reset();
222 }
223 15 }
224
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::unique_lock<std::shared_mutex> drain(s_dispatchMutex);
225 // Nothing can be dispatching now, and these are the last references.
226 15 extracted.clear();
227 15 }
228
229 } // namespace
230
231 namespace FPPPlugins {
232
233 1885 void installPluginApiRouter() {
234
1/2
✓ Branch 0 taken 1885 times.
✗ Branch 1 not taken.
1885 drogon::app().setDefaultHandler(dispatchPluginApi);
235 1885 }
236
237 ✗ std::vector<std::pair<std::string, drogon::HttpMethod>> listPluginApiRoutes() {
238 ✗ std::vector<std::pair<std::string, drogon::HttpMethod>> routes;
239 ✗ std::lock_guard<std::mutex> lock(s_registryMutex);
240 ✗ for (auto* table : { &s_exact, &s_family }) {
241 ✗ for (auto& [key, slot] : *table) {
242 ✗ if (!slot->handler) {
243 ✗ continue;
244 }
245 ✗ std::string path = slot->path;
246 ✗ if (table == &s_family) {
247 ✗ while (path.size() > 1 && path.back() == '/') {
248 ✗ path.pop_back();
249 }
250 ✗ path += "/.*";
251 }
252 ✗ for (auto m : slot->methods) {
253 ✗ routes.emplace_back(path, m);
254 }
255 ✗ }
256 }
257 ✗ return routes;
258 ✗ }
259
260 15 void registerPluginApi(const std::string& path, PluginApiHandler handler,
261 const std::vector<drogon::HttpMethod>& methods, bool family) {
262
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 auto fn = std::make_shared<PluginApiHandler>(std::move(handler));
263
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::string key = normalizedPath(path);
264
265
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 std::lock_guard<std::mutex> lock(s_registryMutex);
266
1/2
✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
15 armSlot(s_exact, key, path, fn, methods);
267
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 14 times.
15 if (family) {
268
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 armSlot(s_family, key, path, fn, methods);
269 }
270 15 }
271
272 15 void unregisterPluginApi(const std::string& path) {
273 // Always disarm the family slot too, whether or not it was registered:
274 // forgetting it there would leave a second live reference to the plugin's
275 // handler, which is exactly the dangling pointer this API exists to prevent.
276 15 disarmSlots(path);
277 15 }
278
279 } // namespace FPPPlugins
280