Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 136444b..203f44a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -33,6 +33,7 @@ FetchContent_Declare(
set(SPDLOG_USE_STD_FORMAT ON)
set(LIBMW_BUILD_URL ON)
set(LIBMW_BUILD_HTTP_SERVER ON)
+set(LIBMW_BUILD_SQLITE ON)
FetchContent_MakeAvailable(libmw spdlog json cxxopts)
add_executable(telegrammer src/main.cpp)
@@ -41,6 +42,7 @@ target_link_libraries(telegrammer PRIVATE
mw::mw
mw::http-server
mw::url
+ mw::sqlite
nlohmann_json::nlohmann_json
spdlog::spdlog
cxxopts
diff --git a/README.md b/README.md
index f842b11..316f420 100644
--- a/README.md
+++ b/README.md
@@ -46,10 +46,27 @@ Run the executable from the build directory. You must provide your Telegram Bot
| `-t, --token` | **Required.** Your Telegram Bot Token. | |
| `-p, --port` | Port to listen on. | `8080` |
| `-h, --host` | Interface to bind to. | `0.0.0.0` |
+| `--db` | Path to the SQLite database file. | `telegrammer.db` |
+| `--add-key <name>` | Generate and add a new API key for `<name>`. | |
+| `--delete-key <name>` | Delete the API key for `<name>`. | |
+| `--list-keys` | List all registered API keys. | |
| `--help` | Show help message. | |
+## API Key Management
+
+Before using the API, you must generate an API key:
+
+```bash
+./telegrammer --add-key my_service
+# Output: Added key for 'my_service': YOUR_GENERATED_KEY
+```
+
## API Reference
+**Authentication:**
+All API requests must include the `Authorization` header with a valid API key:
+`Authorization: Bearer YOUR_GENERATED_KEY`
+
### 1. Send Message (`POST /send`)
Send a text message to a chat. You can target a user by `chat_id` OR `username`.
diff --git a/prd.md b/prd.md
index 204fff1..2474014 100644
--- a/prd.md
+++ b/prd.md
@@ -7,5 +7,10 @@ A Telegram bot written in C++ that act as an API.
Whenever the bot see a message in the chat, it POSTs the info of the
message to the programs HTTP callback endpoint.
* Use Cmake for build. Use [libmw](https://github.com/MetroWind/libmw)
- for HTTP server and HTTP client, and various tools. You can find an
- example project using libmw at @shrt.
+ for HTTP server and HTTP client, sqlite access, and various tools.
+ You can find an example project using libmw at @shrt.
+* The API will need an api key as authentication. The API keys (and
+ name) are stored in a sqlite database.
+* There should be a set of command line parameters to add, delete, and
+ list API keys. When a user wants to add an API key, they also need
+ to supply a name for the key.
diff --git a/src/main.cpp b/src/main.cpp
index a69389d..0530e94 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -7,6 +7,8 @@
#include <chrono>
#include <expected>
#include <optional>
+#include <random>
+#include <filesystem>
#include <cxxopts.hpp>
#include <nlohmann/json.hpp>
@@ -16,9 +18,112 @@
#include <mw/url.hpp>
#include <mw/error.hpp>
#include <mw/utils.hpp>
+#include <mw/database.hpp>
using json = nlohmann::json;
+class ApiKeyManager
+{
+public:
+ explicit ApiKeyManager(const std::string& db_path)
+ {
+ auto db_res = mw::SQLite::connectFile(db_path);
+ if(!db_res.has_value())
+ {
+ throw std::runtime_error("Failed to connect to DB: " +
+ mw::errorMsg(db_res.error()));
+ }
+ db_ = std::move(*db_res);
+
+ auto create_res = db_->execute(
+ "CREATE TABLE IF NOT EXISTS api_keys ("
+ "name TEXT PRIMARY KEY, "
+ "key TEXT NOT NULL, "
+ "created_at INTEGER DEFAULT (unixepoch())"
+ ");");
+ if(!create_res.has_value())
+ {
+ throw std::runtime_error("Failed to create table: " +
+ mw::errorMsg(create_res.error()));
+ }
+ }
+
+ mw::E<std::string> addKey(const std::string& name)
+ {
+ std::string key = generateKey();
+ auto stmt = db_->statementFromStr(
+ "INSERT INTO api_keys (name, key) VALUES (?, ?);");
+ if(!stmt.has_value()) return std::unexpected(stmt.error());
+
+ auto bind_res = stmt->bind(name, key);
+ if(!bind_res.has_value()) return std::unexpected(bind_res.error());
+
+ auto exec_res = db_->execute(std::move(*stmt));
+ if(!exec_res.has_value()) return std::unexpected(exec_res.error());
+
+ return key;
+ }
+
+ mw::E<void> deleteKey(const std::string& name)
+ {
+ auto stmt = db_->statementFromStr(
+ "DELETE FROM api_keys WHERE name = ?;");
+ if(!stmt.has_value()) return std::unexpected(stmt.error());
+
+ auto bind_res = stmt->bind(name);
+ if(!bind_res.has_value()) return std::unexpected(bind_res.error());
+
+ return db_->execute(std::move(*stmt));
+ }
+
+ mw::E<std::vector<std::pair<std::string, std::string>>> listKeys()
+ {
+ return db_->eval<std::string, std::string>(
+ "SELECT name, key FROM api_keys;")
+ .transform([](auto rows)
+ {
+ std::vector<std::pair<std::string, std::string>> keys;
+ for(const auto& row : rows)
+ {
+ keys.emplace_back(std::get<0>(row), std::get<1>(row));
+ }
+ return keys;
+ });
+ }
+
+ bool isValid(const std::string& key)
+ {
+ auto stmt = db_->statementFromStr(
+ "SELECT COUNT(*) FROM api_keys WHERE key = ?;");
+ if(!stmt.has_value()) return false;
+ stmt->bind(key);
+ auto res = db_->evalToValue<int>(std::move(*stmt));
+ return res.has_value() && *res > 0;
+ }
+
+private:
+ std::unique_ptr<mw::SQLite> db_;
+
+ std::string generateKey()
+ {
+ static const char alphanum[] =
+ "0123456789"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "abcdefghijklmnopqrstuvwxyz";
+ std::random_device rd;
+ std::mt19937 gen(rd());
+ std::uniform_int_distribution<> dis(0, sizeof(alphanum) - 2);
+
+ std::string key;
+ key.reserve(32);
+ for(int i = 0; i < 32; ++i)
+ {
+ key += alphanum[dis(gen)];
+ }
+ return key;
+ }
+};
+
class TelegramClient
{
public:
@@ -113,15 +218,24 @@ private:
class App : public mw::HTTPServer
{
public:
- App(mw::IPSocketInfo listen_info, std::string token)
+ App(mw::IPSocketInfo listen_info, std::string token,
+ std::shared_ptr<ApiKeyManager> key_manager)
: mw::HTTPServer(listen_info),
- tg_client_(std::move(token))
+ tg_client_(std::move(token)),
+ key_manager_(std::move(key_manager))
{}
void setup()
{
server.Post("/send", [this](const Request& req, Response& res)
{
+ if(!checkAuth(req))
+ {
+ res.status = 401;
+ res.set_content("Unauthorized", "text/plain");
+ return;
+ }
+
try
{
auto body = json::parse(req.body);
@@ -173,6 +287,13 @@ public:
server.Post("/subscribe", [this](const Request& req, Response& res)
{
+ if(!checkAuth(req))
+ {
+ res.status = 401;
+ res.set_content("Unauthorized", "text/plain");
+ return;
+ }
+
try
{
auto body = json::parse(req.body);
@@ -231,6 +352,18 @@ public:
}
private:
+ bool checkAuth(const Request& req)
+ {
+ if(!req.has_header("Authorization")) return false;
+ std::string auth = req.get_header_value("Authorization");
+ if(auth.starts_with("Bearer "))
+ {
+ std::string key = auth.substr(7);
+ return key_manager_->isValid(key);
+ }
+ return false;
+ }
+
void dispatchMessage(const json& message)
{
int64_t chat_id = message["chat"]["id"];
@@ -264,6 +397,7 @@ private:
TelegramClient tg_client_;
SubscriptionManager sub_manager_;
UsernameResolver username_resolver_;
+ std::shared_ptr<ApiKeyManager> key_manager_;
bool running_ = true;
};
@@ -276,6 +410,13 @@ int main(int argc, char** argv)
cxxopts::value<int>()->default_value("8080"))
("h,host", "Interface to bind to",
cxxopts::value<std::string>()->default_value("0.0.0.0"))
+ ("db", "Path to database file",
+ cxxopts::value<std::string>()->default_value("telegrammer.db"))
+ ("add-key", "Add a new API key with name",
+ cxxopts::value<std::string>())
+ ("delete-key", "Delete an API key by name",
+ cxxopts::value<std::string>())
+ ("list-keys", "List all API keys")
("help", "Print help");
try
@@ -288,9 +429,68 @@ int main(int argc, char** argv)
return 0;
}
+ std::string db_path = result["db"].as<std::string>();
+ auto key_manager = std::make_shared<ApiKeyManager>(db_path);
+
+ if(result.count("add-key"))
+ {
+ std::string name = result["add-key"].as<std::string>();
+ auto key = key_manager->addKey(name);
+ if(key.has_value())
+ {
+ std::cout << "Added key for '" << name << "': " << *key
+ << std::endl;
+ return 0;
+ }
+ else
+ {
+ spdlog::error("Failed to add key: {}",
+ mw::errorMsg(key.error()));
+ return 1;
+ }
+ }
+
+ if(result.count("delete-key"))
+ {
+ std::string name = result["delete-key"].as<std::string>();
+ auto res = key_manager->deleteKey(name);
+ if(res.has_value())
+ {
+ std::cout << "Deleted key for '" << name << "'" << std::endl;
+ return 0;
+ }
+ else
+ {
+ spdlog::error("Failed to delete key: {}",
+ mw::errorMsg(res.error()));
+ return 1;
+ }
+ }
+
+ if(result.count("list-keys"))
+ {
+ auto keys = key_manager->listKeys();
+ if(keys.has_value())
+ {
+ std::cout << "API Keys:" << std::endl;
+ for(const auto& [name, key] : *keys)
+ {
+ std::cout << "- " << name << ": " << key << std::endl;
+ }
+ return 0;
+ }
+ else
+ {
+ spdlog::error("Failed to list keys: {}",
+ mw::errorMsg(keys.error()));
+ return 1;
+ }
+ }
+
if(!result.count("token"))
{
- spdlog::error("Token is required. Use --token <TOKEN>");
+ spdlog::error("Token is required to start server. "
+ "Use --token <TOKEN>");
std::cout << cmd_options.help() << std::endl;
return 1;
}
@@ -303,7 +503,7 @@ int main(int argc, char** argv)
listen_info.address = host;
listen_info.port = port;
- App app(listen_info, token);
+ App app(listen_info, token, key_manager);
app.setup();
auto start_res = app.start();
@@ -328,11 +528,11 @@ int main(int argc, char** argv)
polling_thread.join();
}
}
- catch(const cxxopts::exceptions::exception& e)
+ catch(const std::exception& e)
{
- spdlog::error("Error parsing options: {}", e.what());
+ spdlog::error("Error: {}", e.what());
return 1;
}
return 0;
-}
+}
\ No newline at end of file
diff --git a/test_api.sh b/test_api.sh
index b79bd00..ef325b2 100644
--- a/test_api.sh
+++ b/test_api.sh
@@ -2,17 +2,37 @@
# Simple test script for Telegrammer API
# NOTE: This requires Telegrammer to be running.
+# Usage: ./test_api.sh <API_KEY>
+# If no key provided, it assumes you've added one manually or disabled auth (not possible anymore).
API_URL="http://localhost:8080"
+API_KEY="$1"
+
+if [ -z "$API_KEY" ]; then
+ echo "Error: API Key required."
+ echo "Usage: $0 <API_KEY>"
+ exit 1
+fi
+
+echo "Using API Key: $API_KEY"
echo "Testing /subscribe..."
curl -X POST "$API_URL/subscribe" \
-H "Content-Type: application/json" \
+ -H "Authorization: Bearer $API_KEY" \
-d '{"chat_id": 12345, "callback_url": "http://localhost:9090/webhook"}'
echo -e "\n"
+echo "Testing /send (should fail if token is invalid, but test API structure)..."
+curl -X POST "$API_URL/send" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $API_KEY" \
+ -d '{"chat_id": 12345, "text": "Test message"}'
+echo -e "\n"
+
echo "Testing /send with username (requires prior message from user)..."
curl -X POST "$API_URL/send" \
-H "Content-Type: application/json" \
+ -H "Authorization: Bearer $API_KEY" \
-d '{"username": "some_user", "text": "Test message to username"}'
-echo -e "\n"
+echo -e "\n"
\ No newline at end of file