diff --git a/src/application.cpp b/src/application.cpp index f46d22d..e6a7c3b 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -10,6 +10,9 @@ namespace { +// GCC 12 (PR101367) can destroy lambda captures incorrectly when the lambda is +// created inside a co_await expression. Construct worker tasks before awaiting. + auto execute(std::function operation) -> boost::asio::awaitable { co_return operation(); } @@ -99,7 +102,8 @@ auto fpol::application::now() const -> std::int64_t { auto fpol::application::work(std::function operation) -> boost::asio::awaitable { auto path = config_.database; - co_return co_await boost::asio::co_spawn(database_worker_, execute([path, operation = std::move(operation)]() {auto database=fpol::database{path};database.begin();auto value=operation(database);database.commit();return value; }), boost::asio::use_awaitable); + auto transaction = execute([path, operation = std::move(operation)]() {auto database=fpol::database{path};database.begin();auto value=operation(database);database.commit();return value; }); + co_return co_await boost::asio::co_spawn(database_worker_, std::move(transaction), boost::asio::use_awaitable); } auto fpol::application::hash_work(std::function operation) -> boost::asio::awaitable { @@ -119,7 +123,8 @@ auto fpol::application::hash_work(std::function operation) auto fpol::application::tick(bool restart) -> boost::asio::awaitable { auto time = now(); - co_await work([time, restart](fpol::database &database) {fpol::advance_drafts(database,time,restart);return boost::json::value{}; }); + auto advance = work([time, restart](fpol::database &database) {fpol::advance_drafts(database,time,restart);return boost::json::value{}; }); + co_await std::move(advance); } auto fpol::application::handle(std::string action, boost::json::value input, std::string token, std::string csrf, std::string peer) -> boost::asio::awaitable { @@ -130,7 +135,7 @@ auto fpol::application::handle(std::string action, boost::json::value input, std auto name = username(input); auto password = fpol::text(input, "password"); ensure(password.size() <= 256, "Invalid credentials", 401); - auto user = co_await work([name, peer, time](fpol::database &database) { + auto lookup = work([name, peer, time](fpol::database &database) { for (auto const &key : {"user:" + name, "peer:" + peer}) { auto limit = database.one("SELECT * FROM login_limits WHERE key=?", {key}); auto maximum = key.starts_with("user:") ? 10 : 100; @@ -141,35 +146,47 @@ auto fpol::application::handle(std::string action, boost::json::value input, std } return boost::json::value{database.one("SELECT * FROM users WHERE username=?", {name})}; }); + auto user = co_await std::move(lookup); auto hash = user.as_object().empty() ? "scrypt$131072$8$1$" + std::string(32, '0') + "$" + std::string(128, '0') : fpol::text(user, "password_hash"); - auto valid = co_await hash_work([password, hash]() { return boost::json::value{fpol::password_matches(password, hash)}; }); + auto verification = hash_work([password, hash]() { return boost::json::value{fpol::password_matches(password, hash)}; }); + auto valid = co_await std::move(verification); ensure(valid.as_bool() && !user.as_object().empty(), "Invalid username or password", 401); - co_return co_await work([user, hash, name, time](fpol::database &database) {auto current=database.one("SELECT password_hash FROM users WHERE id=?",{fpol::text(user,"id")});ensure(!current.empty()&&fpol::text(current,"password_hash")==hash,"Password changed; sign in again",401);database.query("DELETE FROM login_limits WHERE key=?",{"user:"+name});return session(database,fpol::text(user,"id"),time); }); + auto login = work([user, hash, name, time](fpol::database &database) {auto current=database.one("SELECT password_hash FROM users WHERE id=?",{fpol::text(user,"id")});ensure(!current.empty()&&fpol::text(current,"password_hash")==hash,"Password changed; sign in again",401);database.query("DELETE FROM login_limits WHERE key=?",{"user:"+name});return session(database,fpol::text(user,"id"),time); }); + co_return co_await std::move(login); } if (action == "auth/register") { auto name = username(input); auto invite = fpol::text(input, "invite"); auto password = fpol::text(input, "password"); auto team_name = fpol::text(input, "team_name"); - co_await work([name, invite, time](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username is unavailable",409);ensure(!database.one("SELECT token_hash FROM invites WHERE token_hash=? AND expires_at>?",{fpol::digest(invite),time}).empty(),"Invitation is invalid or expired",404);return boost::json::value{}; }); - auto hash = co_await hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; }); - co_return co_await work([=, this](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username is unavailable",409);auto id=fpol::random_token(12);database.query("INSERT INTO users(id,username,password_hash,created_at) VALUES(?,?,?,?)",{id,name,std::string{hash.as_string()},time});auto user=database.one("SELECT id,username,admin FROM users WHERE id=?",{id});game_action(database,"league/join",boost::json::object{{"invite",invite},{"team_name",team_name}},user,time,config_.allow_demo);return session(database,id,time); }); + auto validation = work([name, invite, time](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username is unavailable",409);ensure(!database.one("SELECT token_hash FROM invites WHERE token_hash=? AND expires_at>?",{fpol::digest(invite),time}).empty(),"Invitation is invalid or expired",404);return boost::json::value{}; }); + co_await std::move(validation); + auto hashing = hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; }); + auto hash = co_await std::move(hashing); + auto registration = work([=, this](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username is unavailable",409);auto id=fpol::random_token(12);database.query("INSERT INTO users(id,username,password_hash,created_at) VALUES(?,?,?,?)",{id,name,std::string{hash.as_string()},time});auto user=database.one("SELECT id,username,admin FROM users WHERE id=?",{id});game_action(database,"league/join",boost::json::object{{"invite",invite},{"team_name",team_name}},user,time,config_.allow_demo);return session(database,id,time); }); + co_return co_await std::move(registration); } if (action == "auth/reset") { auto reset = fpol::text(input, "reset_token"); auto password = fpol::text(input, "password"); - auto previous = co_await work([reset, time](fpol::database &database) {auto value=database.one("SELECT user_id FROM password_resets WHERE token_hash=? AND expires_at>?",{fpol::digest(reset),time});ensure(!value.empty(),"Reset link is invalid or expired",400);return boost::json::value{value}; }); - auto hash = co_await hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; }); - co_return co_await work([reset, hash, time, previous](fpol::database &database) {auto value=database.one("SELECT user_id FROM password_resets WHERE token_hash=? AND expires_at>?",{fpol::digest(reset),time});ensure(!value.empty(),"Reset link is invalid or expired",400);auto id=fpol::text(previous,"user_id");database.query("UPDATE users SET password_hash=? WHERE id=?",{std::string{hash.as_string()},id});database.query("DELETE FROM sessions WHERE user_id=?",{id});database.query("DELETE FROM password_resets WHERE user_id=?",{id});return success(boost::json::object{{"message","Password reset. Sign in with your new password."}}); }); + auto lookup = work([reset, time](fpol::database &database) {auto value=database.one("SELECT user_id FROM password_resets WHERE token_hash=? AND expires_at>?",{fpol::digest(reset),time});ensure(!value.empty(),"Reset link is invalid or expired",400);return boost::json::value{value}; }); + auto previous = co_await std::move(lookup); + auto hashing = hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; }); + auto hash = co_await std::move(hashing); + auto reset_password = work([reset, hash, time, previous](fpol::database &database) {auto value=database.one("SELECT user_id FROM password_resets WHERE token_hash=? AND expires_at>?",{fpol::digest(reset),time});ensure(!value.empty(),"Reset link is invalid or expired",400);auto id=fpol::text(previous,"user_id");database.query("UPDATE users SET password_hash=? WHERE id=?",{std::string{hash.as_string()},id});database.query("DELETE FROM sessions WHERE user_id=?",{id});database.query("DELETE FROM password_resets WHERE user_id=?",{id});return success(boost::json::object{{"message","Password reset. Sign in with your new password."}}); }); + co_return co_await std::move(reset_password); } if (action == "auth/password") { - auto previous = co_await work([=](fpol::database &database) {auto user=authenticated(database,token,csrf,time,true);return boost::json::value{database.one("SELECT id,password_hash FROM users WHERE id=?",{fpol::text(user,"id")})}; }); + auto lookup = work([=](fpol::database &database) {auto user=authenticated(database,token,csrf,time,true);return boost::json::value{database.one("SELECT id,password_hash FROM users WHERE id=?",{fpol::text(user,"id")})}; }); + auto previous = co_await std::move(lookup); auto old_password = fpol::text(input, "current_password"); auto password = fpol::text(input, "password"); - auto hash = co_await hash_work([previous, old_password, password]() {ensure(fpol::password_matches(old_password,fpol::text(previous,"password_hash")),"Current password is incorrect",401);return boost::json::value{fpol::password_hash(password)}; }); - co_return co_await work([=](fpol::database &database) {auto user=authenticated(database,token,csrf,time,true);auto current=database.one("SELECT password_hash FROM users WHERE id=?",{fpol::text(user,"id")});ensure(fpol::text(current,"password_hash")==fpol::text(previous,"password_hash"),"Password changed; sign in again",409);database.query("UPDATE users SET password_hash=? WHERE id=?",{std::string{hash.as_string()},fpol::text(user,"id")});database.query("DELETE FROM sessions WHERE user_id=?",{fpol::text(user,"id")});return session(database,fpol::text(user,"id"),time); }); + auto hashing = hash_work([previous, old_password, password]() {ensure(fpol::password_matches(old_password,fpol::text(previous,"password_hash")),"Current password is incorrect",401);return boost::json::value{fpol::password_hash(password)}; }); + auto hash = co_await std::move(hashing); + auto change_password = work([=](fpol::database &database) {auto user=authenticated(database,token,csrf,time,true);auto current=database.one("SELECT password_hash FROM users WHERE id=?",{fpol::text(user,"id")});ensure(fpol::text(current,"password_hash")==fpol::text(previous,"password_hash"),"Password changed; sign in again",409);database.query("UPDATE users SET password_hash=? WHERE id=?",{std::string{hash.as_string()},fpol::text(user,"id")});database.query("DELETE FROM sessions WHERE user_id=?",{fpol::text(user,"id")});return session(database,fpol::text(user,"id"),time); }); + co_return co_await std::move(change_password); } - co_return co_await work([=, this](fpol::database &database) { + auto dispatch = work([=, this](fpol::database &database) { auto user = authenticated(database, token, csrf, time, mutation(action)); if (action == "auth/me") { return success(user); @@ -272,6 +289,7 @@ auto fpol::application::handle(std::string action, boost::json::value input, std } return success(fpol::game_action(database, action, input, user, time, config_.allow_demo)); }); + co_return co_await std::move(dispatch); } catch (fpol::api_error const &error) { co_return boost::json::object{{"ok", false}, {"status", error.status}, {"message", error.what()}, {"code", std::to_string(error.status)}}; } catch (std::invalid_argument const &error) { @@ -293,15 +311,19 @@ auto fpol::application::admin(std::string action, boost::json::value input) -> b if (action == "create-user") { auto name = username(input); auto password = fpol::text(input, "password"); - auto hash = co_await hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; }); - co_return co_await work([name, hash, time](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username already exists",409);auto id=fpol::random_token(12);database.query("INSERT INTO users(id,username,password_hash,admin,created_at) VALUES(?,?,?,1,?)",{id,name,std::string{hash.as_string()},time});return boost::json::value{boost::json::object{{"id",id},{"username",name}}}; }); + auto hashing = hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; }); + auto hash = co_await std::move(hashing); + auto creation = work([name, hash, time](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username already exists",409);auto id=fpol::random_token(12);database.query("INSERT INTO users(id,username,password_hash,admin,created_at) VALUES(?,?,?,1,?)",{id,name,std::string{hash.as_string()},time});return boost::json::value{boost::json::object{{"id",id},{"username",name}}}; }); + co_return co_await std::move(creation); } if (action == "reset-link") { auto name = username(input); - co_return co_await work([name, time](fpol::database &database) {auto user=database.one("SELECT id FROM users WHERE username=?",{name});ensure(!user.empty(),"Unknown account",404);auto token=fpol::random_token();auto id=fpol::text(user,"id");database.query("DELETE FROM password_resets WHERE user_id=?",{id});database.query("INSERT INTO password_resets VALUES(?,?,?)",{fpol::digest(token),id,time+1800});return boost::json::value{boost::json::object{{"reset_token",token},{"expires_at",time+1800}}}; }); + auto reset = work([name, time](fpol::database &database) {auto user=database.one("SELECT id FROM users WHERE username=?",{name});ensure(!user.empty(),"Unknown account",404);auto token=fpol::random_token();auto id=fpol::text(user,"id");database.query("DELETE FROM password_resets WHERE user_id=?",{id});database.query("INSERT INTO password_resets VALUES(?,?,?)",{fpol::digest(token),id,time+1800});return boost::json::value{boost::json::object{{"reset_token",token},{"expires_at",time+1800}}}; }); + co_return co_await std::move(reset); } if (action == "sources") { - co_return co_await work([](fpol::database &database) { return boost::json::value{database.query("SELECT * FROM sources ORDER BY id")}; }); + auto listing = work([](fpol::database &database) { return boost::json::value{database.query("SELECT * FROM sources ORDER BY id")}; }); + co_return co_await std::move(listing); } throw fpol::api_error{400, "Unknown administrative action"}; } diff --git a/tests/test_application.py b/tests/test_application.py index 5fe6d34..f902953 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -24,7 +24,7 @@ class application_tests(unittest.TestCase): def command(*args, stdin=None): run = subprocess.run([str(ADMIN), "--database", str(cls.base_database), "--allow-demo", *args], input=stdin, text=True, capture_output=True, timeout=30) if run.returncode: - raise RuntimeError(run.stderr) + raise RuntimeError(f"{args[0]} exited with code {run.returncode}:\n{run.stderr}") return json.loads(run.stdout.splitlines()[-1]) command("import", str(ROOT / "data/demo/catalog.json"), str(ROOT / "data/demo/ratings.json"), str(ROOT / "data/real/us-2026-10-04-partial.json")) for username in ("alice", "bob", "carol"):