├── .dockerignore ├── .github └── workflows │ ├── build.yaml │ └── docker.yaml ├── .gitignore ├── .gitmodules ├── CMakeLists.txt ├── Dockerfile ├── README.md ├── cagliostr.hxx ├── cagliostr.png ├── compile_flags.txt ├── deps └── .gitkeep ├── main.cxx ├── records.cxx ├── sign.cxx ├── test.cxx └── version.h /.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | deps 3 | !deps/.gitkeep 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | # This starter workflow is for a CMake project running on a single platform. There is a different starter workflow if you need cross-platform coverage. 2 | # See: https://github.com/actions/starter-workflows/blob/main/ci/cmake-multi-platform.yml 3 | name: Build 4 | 5 | on: 6 | push: 7 | branches: [ "main" ] 8 | pull_request: 9 | branches: [ "main" ] 10 | 11 | env: 12 | BUILD_TYPE: Release 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | submodules: true 22 | 23 | - name: Configure CMake 24 | run: cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} 25 | 26 | - name: Build 27 | run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}} 28 | 29 | - name: Test 30 | working-directory: ${{github.workspace}}/build 31 | run: ctest -C ${{env.BUILD_TYPE}} 32 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | push_to_registry: 10 | name: Docker 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Update VERSION 17 | run: | 18 | sed -i.bak -e "s/devel/$(git describe --tags --abbrev=0 | sed 's!^v!!')/" version.h 19 | 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v1 22 | 23 | - name: Set up Docker Buildx 24 | id: buildx 25 | uses: docker/setup-buildx-action@v1 26 | 27 | - name: Log in to GitHub Container Registry 28 | uses: docker/login-action@v2 29 | with: 30 | registry: ghcr.io 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | - name: Extract metadata (tags, labels) for Docker 35 | id: meta 36 | uses: docker/metadata-action@v3 37 | with: 38 | images: ghcr.io/${{ github.repository }} 39 | 40 | - name: Build and push Docker image 41 | uses: docker/build-push-action@v2 42 | with: 43 | context: . 44 | platforms: linux/amd64 45 | builder: ${{ steps.buildx.outputs.name }} 46 | push: ${{ github.event_name != 'pull_request' }} 47 | tags: | 48 | ghcr.io/${{ github.repository }}:latest 49 | ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} 51 | cache-from: type=gha 52 | cache-to: type=gha,mode=max # mode=maxを有効にすると、中間ステージまで含めてキャッシュできる 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/c++ 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=c++ 3 | 4 | ### C++ ### 5 | # Prerequisites 6 | *.d 7 | 8 | # Compiled Object files 9 | *.slo 10 | *.lo 11 | *.o 12 | *.obj 13 | 14 | # Precompiled Headers 15 | *.gch 16 | *.pch 17 | 18 | # Compiled Dynamic libraries 19 | *.so 20 | *.dylib 21 | *.dll 22 | 23 | # Fortran module files 24 | *.mod 25 | *.smod 26 | 27 | # Compiled Static libraries 28 | *.lai 29 | *.la 30 | *.a 31 | *.lib 32 | 33 | # Executables 34 | *.exe 35 | *.out 36 | *.app 37 | 38 | # End of https://www.toptal.com/developers/gitignore/api/c++ 39 | 40 | # binary 41 | cagliostr 42 | 43 | .idea 44 | build/ 45 | cmake-build-debug/ 46 | kustomize/ 47 | litestream.yaml 48 | *.sh 49 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deps/dcdpr-libbech32"] 2 | path = deps/dcdpr-libbech32 3 | url = https://github.com/dcdpr/libbech32 4 | [submodule "deps/nlohmann-json"] 5 | path = deps/nlohmann-json 6 | url = https://github.com/nlohmann/json 7 | [submodule "deps/matheus28-ws28"] 8 | path = deps/matheus28-ws28 9 | url = https://github.com/Matheus28/ws28 10 | [submodule "deps/libuv-libuv"] 11 | path = deps/libuv-libuv 12 | url = https://github.com/libuv/libuv 13 | [submodule "deps/bitcoin-core-libsecp256k1"] 14 | path = deps/bitcoin-core-libsecp256k1 15 | url = https://github.com/bitcoin-core/secp256k1 16 | [submodule "deps/fnc12-sqlite_orm"] 17 | path = deps/fnc12-sqlite_orm 18 | url = https://github.com/fnc12/sqlite_orm 19 | [submodule "deps/gabime-spdlog"] 20 | path = deps/gabime-spdlog 21 | url = https://github.com/gabime/spdlog 22 | [submodule "deps/taywee-args"] 23 | path = deps/taywee-args 24 | url = https://github.com/taywee/args 25 | [submodule "deps/p-ranav-argparse"] 26 | path = deps/p-ranav-argparse 27 | url = https://github.com/p-ranav/argparse 28 | [submodule "deps/h2o-picotest"] 29 | path = deps/h2o-picotest 30 | url = https://github.com/h2o/picotest 31 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.10) 2 | project(cagliostr) 3 | include(ExternalProject) 4 | include(CheckLibraryExists) 5 | 6 | message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") 7 | 8 | set(CMAKE_CXX_FLAGS_RELEASE "-O3 -std=c++20 -Wall -Wextra ${CMAKE_C_FLAGS}") 9 | set(CMAKE_CXX_FLAGS_DEBUG "-g -std=c++20 -Wall -Wextra ${CMAKE_C_FLAGS}") 10 | 11 | #-------------------------------------------------- 12 | # libuv 13 | set(LIBUV_LIBRARIES ${CMAKE_CURRENT_SOURCE_DIR}/deps/libuv-libuv/build/libuv.a) 14 | add_custom_target(libuv DEPENDS ${LIBUV_LIBRARIES}) 15 | add_custom_command( 16 | OUTPUT ${LIBUV_LIBRARIES} 17 | WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deps/libuv-libuv 18 | COMMAND ${CMAKE_COMMAND} -B build -D CMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} 19 | COMMAND ${CMAKE_COMMAND} --build build -t uv_a 20 | ) 21 | link_directories(${CMAKE_CURRENT_SOURCE_DIR}/deps/libuv-libuv/build) 22 | 23 | #-------------------------------------------------- 24 | # libsecp256k1 25 | set(LIBSECP256K1_LIBRARIES ${CMAKE_CURRENT_SOURCE_DIR}/deps/bitcoin-core-libsecp256k1/build/src/libsecp256k1.a) 26 | add_custom_target(libsecp256k1 DEPENDS ${LIBSECP256K1_LIBRARIES}) 27 | #target_compile_definitions(${LIBSECP256K1_LIBRARIES} PRIVATE SECP256K1_STATIC) 28 | add_compile_definitions(SECP256K1_STATIC) 29 | add_custom_command( 30 | OUTPUT ${LIBSECP256K1_LIBRARIES} 31 | WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deps/bitcoin-core-libsecp256k1 32 | COMMAND ${CMAKE_COMMAND} -B build -D CMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -D BUILD_SHARED_LIBS=off 33 | COMMAND ${CMAKE_COMMAND} --build build -t secp256k1 34 | ) 35 | link_directories(${CMAKE_CURRENT_SOURCE_DIR}/deps/bitcoin-core-libsecp256k1/build/src) 36 | 37 | #-------------------------------------------------- 38 | # libspdlog 39 | if ("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") 40 | set(LIBSPDLOG_LIBRARIES ${CMAKE_CURRENT_SOURCE_DIR}/deps/gabime-spdlog/build/libspdlogd.a) 41 | else() 42 | set(LIBSPDLOG_LIBRARIES ${CMAKE_CURRENT_SOURCE_DIR}/deps/gabime-spdlog/build/libspdlog.a) 43 | endif() 44 | add_custom_target(libspdlog DEPENDS ${LIBSPDLOG_LIBRARIES}) 45 | add_custom_command( 46 | OUTPUT ${LIBSPDLOG_LIBRARIES} 47 | WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deps/gabime-spdlog 48 | COMMAND ${CMAKE_COMMAND} -B build -D CMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} -D BUILD_SHARED_LIBS=off 49 | COMMAND ${CMAKE_COMMAND} --build build -t spdlog 50 | ) 51 | link_directories(${CMAKE_CURRENT_SOURCE_DIR}/deps/gabime-spdlog/build) 52 | 53 | #-------------------------------------------------- 54 | # cagliostr-core 55 | set (t_ cagliostr-core) 56 | 57 | include_directories( 58 | ${t_} 59 | ${PROJECT_SOURCE_DIR}/deps/dcdpr-libbech32/include 60 | ${PROJECT_SOURCE_DIR}/deps/matheus28-ws28/src 61 | ${PROJECT_SOURCE_DIR}/deps/nlohmann-json/include 62 | ${PROJECT_SOURCE_DIR}/deps/libuv-libuv/include 63 | ${PROJECT_SOURCE_DIR}/deps/bitcoin-core-libsecp256k1/include 64 | ${PROJECT_SOURCE_DIR}/deps/gabime-spdlog/include 65 | ${PROJECT_SOURCE_DIR}/deps/p-ranav-argparse/include 66 | ) 67 | add_library (${t_} INTERFACE) 68 | add_dependencies(${t_} libuv) 69 | add_dependencies(${t_} libsecp256k1) 70 | add_dependencies(${t_} libspdlog) 71 | target_link_libraries(${t_} INTERFACE crypto) 72 | target_link_libraries(${t_} INTERFACE ssl) 73 | target_link_libraries(${t_} INTERFACE sqlite3) 74 | target_link_libraries(${t_} INTERFACE ${LIBUV_LIBRARIES}) 75 | target_link_libraries(${t_} INTERFACE ${LIBSECP256K1_LIBRARIES}) 76 | target_link_libraries(${t_} INTERFACE ${LIBSPDLOG_LIBRARIES}) 77 | if (WIN32) 78 | target_link_libraries(${t_} INTERFACE ws2_32 dbghelp userenv iphlpapi) 79 | endif() 80 | 81 | #-------------------------------------------------- 82 | # cagliostr 83 | set (t_ cagliostr) 84 | add_executable(${t_} main.cxx records.cxx sign.cxx deps/matheus28-ws28/src/Server.cpp deps/matheus28-ws28/src/Client.cpp deps/matheus28-ws28/src/base64.cpp) 85 | target_link_libraries(${t_} PRIVATE cagliostr-core) 86 | 87 | #-------------------------------------------------- 88 | # test 89 | set (t_ cagliostr-test) 90 | include_directories( 91 | ${t_} 92 | ${PROJECT_SOURCE_DIR}/deps/h2o-picotest 93 | ) 94 | add_executable(${t_} test.cxx ${PROJECT_SOURCE_DIR}/deps/h2o-picotest/picotest.c records.cxx sign.cxx) 95 | target_link_libraries(${t_} PRIVATE cagliostr-core) 96 | 97 | enable_testing() 98 | add_test(test cagliostr-test) 99 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | 3 | FROM debian:bookworm AS build-dev 4 | WORKDIR /usr/src/app 5 | RUN apt update && apt install -y g++ libsqlite3-dev libssl-dev cmake make git 6 | COPY . /usr/src/app 7 | RUN git submodule update --init 8 | RUN mkdir build && cd build && cmake -DCMAKE_BUILD_TYPE=Release .. && make cagliostr 9 | FROM debian:bookworm AS build-run 10 | RUN apt update && apt install -y libsqlite3-0 libssl3 libtcmalloc-minimal4 && apt clean 11 | COPY --link --from=build-dev /usr/src/app/build/cagliostr /usr/bin/cagliostr 12 | COPY --from=build-dev /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 13 | RUN mkdir /data 14 | ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 15 | ENTRYPOINT ["/usr/bin/cagliostr"] 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cagliostr 2 | 3 | High performance Nostr Relay written in C++ 4 | 5 | ![cagliostr](cagliostr.png) 6 | 7 | ## Usage 8 | 9 | ``` 10 | $ ./cagliostr --help 11 | Usage: cagliostr [--help] [--version] [-database DATABASE] [-loglevel LEVEL] 12 | 13 | Optional arguments: 14 | -h, --help shows help message and exits 15 | -v, --version prints version information and exits 16 | -database DATABASE connection string [default: "./cagliostr.sqlite"] 17 | -loglevel LEVEL log level [default: "info"] 18 | ``` 19 | 20 | ## Requirements 21 | 22 | * OpenSSL 23 | * libsqlite3 24 | 25 | ## Installation 26 | 27 | ``` 28 | $ git submodule update --init --recursive 29 | $ cmake -B build && cmake --build build 30 | ``` 31 | 32 | ## License 33 | 34 | MIT 35 | 36 | ## Author 37 | 38 | Yasuhiro Matsumoto (a.k.a. mattn) 39 | -------------------------------------------------------------------------------- /cagliostr.hxx: -------------------------------------------------------------------------------- 1 | #ifndef _CAGLIOSTR_H_ 2 | #define _CAGLIOSTR_H_ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #if defined(__GLIBC__) 10 | # include 11 | #endif 12 | 13 | #include 14 | 15 | typedef struct event_t { 16 | std::string id; 17 | std::string pubkey; 18 | std::time_t created_at; 19 | int kind; 20 | std::vector> tags; 21 | std::string content; 22 | std::string sig; 23 | } event_t; 24 | 25 | typedef struct filter_t { 26 | std::vector ids{}; 27 | std::vector authors{}; 28 | std::vector kinds{}; 29 | std::vector> tags{}; 30 | std::time_t since{}; 31 | std::time_t until{}; 32 | int limit{500}; 33 | std::string search; 34 | } filter_t; 35 | 36 | void storage_init(const std::string &); 37 | void storage_deinit(); 38 | bool insert_record(const event_t &); 39 | 40 | int delete_record_by_id(const std::string &); 41 | int delete_record_by_kind_and_pubkey(int, const std::string &, std::time_t); 42 | int delete_record_by_kind_and_pubkey_and_dtag(int, const std::string &, 43 | const std::vector &, 44 | std::time_t); 45 | 46 | bool send_records(std::function, 47 | const std::string &, const std::vector &, bool); 48 | 49 | bool check_event(const event_t &); 50 | 51 | #endif 52 | -------------------------------------------------------------------------------- /cagliostr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattn/cagliostr/028e4cbe0ee56b48b298c8281011ac7e3965367b/cagliostr.png -------------------------------------------------------------------------------- /compile_flags.txt: -------------------------------------------------------------------------------- 1 | -std=c++20 2 | -I 3 | deps/matheus28-ws28/src 4 | -I 5 | deps/dcdpr-libbech32/include 6 | -I 7 | deps/nlohmann-json/include 8 | -I 9 | deps/bitcoin-core-libsecp256k1/include 10 | -I 11 | deps/libuv-libuv/include 12 | -I 13 | deps/gabime-spdlog/include 14 | -I 15 | deps/p-ranav-argparse/include 16 | -I 17 | deps/h2o-picotest 18 | -------------------------------------------------------------------------------- /deps/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattn/cagliostr/028e4cbe0ee56b48b298c8281011ac7e3965367b/deps/.gitkeep -------------------------------------------------------------------------------- /main.cxx: -------------------------------------------------------------------------------- 1 | #include "Headers.h" 2 | #include "cagliostr.hxx" 3 | #include "version.h" 4 | #include 5 | 6 | #include 7 | #include 8 | 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | typedef struct subscriber_t { 15 | std::string sub; 16 | ws28::Client *client{}; 17 | std::vector filters; 18 | } subscriber_t; 19 | 20 | typedef struct client_t { 21 | std::string ip; 22 | std::string challenge; 23 | std::string pubkey; 24 | } client_t; 25 | 26 | // global variables 27 | std::vector subscribers; 28 | 29 | std::string service_url; 30 | 31 | static const std::string realIP(ws28::Client *client) { 32 | client_t *ci = (client_t *)client->GetUserData(); 33 | if (ci != nullptr) 34 | return ci->ip; 35 | return client->GetIP(); 36 | } 37 | 38 | static const std::string realIP(ws28::HTTPRequest &req) { 39 | std::string ip{req.ip}; 40 | auto value = req.headers.Get("x-forwarded-for"); 41 | if (value.has_value()) { 42 | ip = value.value(); // possible to be multiple comma separated 43 | } else { 44 | value = req.headers.Get("x-real-ip"); 45 | if (value.has_value()) { 46 | ip = value.value(); 47 | } 48 | } 49 | return ip; 50 | } 51 | 52 | static const std::string challenge(ws28::Client *client) { 53 | client_t *ci = (client_t *)client->GetUserData(); 54 | if (ci != nullptr) 55 | return ci->challenge; 56 | return ""; 57 | } 58 | 59 | static const void set_auth_pubkey(ws28::Client *client, std::string pubkey) { 60 | client_t *ci = (client_t *)client->GetUserData(); 61 | if (ci != nullptr) 62 | ci->pubkey = pubkey; 63 | } 64 | 65 | static const bool check_auth_pubkey(ws28::Client *client, std::string pubkey) { 66 | client_t *ci = (client_t *)client->GetUserData(); 67 | if (ci != nullptr) 68 | return ci->pubkey == pubkey; 69 | return false; 70 | } 71 | 72 | static void relay_send(ws28::Client *client, const nlohmann::json &data) { 73 | assert(client); 74 | const auto &s = data.dump(); 75 | spdlog::debug("{} << {}", realIP(client), s); 76 | client->Send(s.data(), s.size(), 1); 77 | } 78 | 79 | static inline void relay_notice(ws28::Client *client, const std::string &msg) { 80 | assert(client); 81 | nlohmann::json data = {"NOTICE", msg}; 82 | relay_send(client, data); 83 | } 84 | 85 | static inline void relay_notice(ws28::Client *client, const std::string &id, 86 | const std::string &msg) { 87 | assert(client); 88 | nlohmann::json data = {"NOTICE", id, msg}; 89 | relay_send(client, data); 90 | } 91 | 92 | static inline void relay_closed(ws28::Client *client, const std::string &msg) { 93 | assert(client); 94 | nlohmann::json data = {"CLOSED", msg}; 95 | relay_send(client, data); 96 | client->Close(0); 97 | } 98 | 99 | static inline void relay_closed(ws28::Client *client, const std::string &id, 100 | const std::string &msg) { 101 | assert(client); 102 | nlohmann::json data = {"CLOSED", id, msg}; 103 | relay_send(client, data); 104 | } 105 | 106 | static bool is_hex(const std::string &s, size_t len) { 107 | const static std::string hex = "0123456789abcdef"; 108 | if (s.size() != len) { 109 | return false; 110 | } 111 | for (const auto &c : s) { 112 | if (hex.find(c) == std::string::npos) { 113 | return false; 114 | } 115 | } 116 | return true; 117 | } 118 | 119 | static bool make_filter(filter_t &filter, const nlohmann::json &data) { 120 | if (data.count("ids") > 0) { 121 | for (const auto &id : data["ids"]) { 122 | if (!is_hex(id, 64)) 123 | return false; 124 | filter.ids.push_back(id); 125 | } 126 | } 127 | if (data.count("authors") > 0) { 128 | for (const auto &author : data["authors"]) { 129 | if (!is_hex(author, 64)) 130 | return false; 131 | filter.authors.push_back(author); 132 | } 133 | } 134 | if (data.count("kinds") > 0) { 135 | for (const auto &kind : data["kinds"]) { 136 | filter.kinds.push_back(kind); 137 | } 138 | } 139 | for (nlohmann::json::const_iterator it = data.begin(); it != data.end(); 140 | ++it) { 141 | if (it.key().at(0) == '#' && it.value().is_array()) { 142 | std::vector tag = {it.key().c_str() + 1}; 143 | for (const auto &v : it.value()) { 144 | tag.push_back(v); 145 | } 146 | filter.tags.push_back(tag); 147 | } 148 | } 149 | if (data.count("since") > 0) { 150 | filter.since = data["since"]; 151 | } 152 | if (data.count("until") > 0) { 153 | filter.until = data["until"]; 154 | } 155 | if (data.count("limit") > 0) { 156 | filter.limit = data["limit"]; 157 | } 158 | if (data.count("search") > 0) { 159 | filter.search = data["search"]; 160 | } 161 | return true; 162 | } 163 | 164 | static void do_relay_req(ws28::Client *client, const nlohmann::json &data) { 165 | assert(client); 166 | std::string sub = data[1]; 167 | std::vector filters; 168 | for (size_t i = 2; i < data.size(); i++) { 169 | if (!data[i].is_object()) { 170 | continue; 171 | } 172 | try { 173 | filter_t filter; 174 | if (!make_filter(filter, data[i])) { 175 | continue; 176 | } 177 | filters.push_back(filter); 178 | } catch (std::exception &e) { 179 | spdlog::warn("!! {}", e.what()); 180 | } 181 | } 182 | if (filters.empty()) { 183 | const auto reply = 184 | nlohmann::json::array({"NOTICE", sub, "error: invalid filter"}); 185 | relay_send(client, reply); 186 | return; 187 | } 188 | subscribers.push_back({.sub = sub, .client = client, .filters = filters}); 189 | 190 | send_records([&](const nlohmann::json &data) { relay_send(client, data); }, 191 | sub, filters, false); 192 | const auto reply = nlohmann::json::array({"EOSE", sub}); 193 | relay_send(client, reply); 194 | } 195 | 196 | static void do_relay_count(ws28::Client *client, const nlohmann::json &data) { 197 | assert(client); 198 | std::string sub = data[1]; 199 | std::vector filters; 200 | for (size_t i = 2; i < data.size(); i++) { 201 | if (!data[i].is_object()) { 202 | filters.clear(); 203 | break; 204 | } 205 | try { 206 | filter_t filter; 207 | if (!make_filter(filter, data[i])) { 208 | filters.clear(); 209 | break; 210 | } 211 | filters.push_back(filter); 212 | } catch (std::exception &e) { 213 | spdlog::warn("!! {}", e.what()); 214 | } 215 | } 216 | if (filters.empty()) { 217 | const auto reply = 218 | nlohmann::json::array({"NOTICE", sub, "error: invalid filter"}); 219 | relay_send(client, reply); 220 | return; 221 | } 222 | 223 | send_records([&](const nlohmann::json &data) { relay_send(client, data); }, 224 | sub, filters, true); 225 | } 226 | 227 | static void do_relay_close(ws28::Client *client, const nlohmann::json &data) { 228 | assert(client); 229 | std::string sub = data[1]; 230 | auto it = subscribers.begin(); 231 | while (it != subscribers.end()) { 232 | if (it->sub == sub && it->client == client) { 233 | it = subscribers.erase(it); 234 | } else { 235 | it++; 236 | } 237 | } 238 | } 239 | 240 | static bool matched_filters(const std::vector &filters, 241 | const event_t &ev) { 242 | auto found = false; 243 | for (const auto &filter : filters) { 244 | if (!filter.ids.empty()) { 245 | const auto result = 246 | std::find(filter.ids.begin(), filter.ids.end(), ev.id); 247 | if (result == filter.ids.end()) { 248 | continue; 249 | } 250 | } 251 | if (!filter.authors.empty()) { 252 | const auto result = 253 | std::find(filter.authors.begin(), filter.authors.end(), ev.pubkey); 254 | if (result == filter.authors.end()) { 255 | continue; 256 | } 257 | } 258 | if (!filter.kinds.empty()) { 259 | const auto result = 260 | std::find(filter.kinds.begin(), filter.kinds.end(), ev.kind); 261 | if (result == filter.kinds.end()) { 262 | continue; 263 | } 264 | } 265 | if (filter.since > 0) { 266 | if (filter.since > ev.created_at) { 267 | continue; 268 | } 269 | } 270 | if (filter.until > 0) { 271 | if (ev.created_at > filter.until) { 272 | continue; 273 | } 274 | } 275 | if (!filter.tags.empty()) { 276 | auto matched = false; 277 | for (const auto &tag : ev.tags) { 278 | if (tag.size() < 2) 279 | continue; 280 | for (const auto &filter_tag : filter.tags) { 281 | if (filter_tag.size() < 2) 282 | continue; 283 | if (tag == filter_tag) { 284 | matched = true; 285 | break; 286 | } 287 | } 288 | if (matched) { 289 | break; 290 | } 291 | } 292 | if (!matched) { 293 | continue; 294 | } 295 | } 296 | found = true; 297 | } 298 | return found; 299 | } 300 | 301 | static void to_json(nlohmann::json &j, const event_t &e) { 302 | j = nlohmann::json{ 303 | {"id", e.id}, {"pubkey", e.pubkey}, 304 | {"content", e.content}, {"created_at", e.created_at}, 305 | {"kind", e.kind}, {"tags", e.tags}, 306 | {"sig", e.sig}, 307 | }; 308 | } 309 | 310 | static void from_json(const nlohmann::json &j, event_t &e) { 311 | j.at("id").get_to(e.id); 312 | j.at("pubkey").get_to(e.pubkey); 313 | j.at("content").get_to(e.content); 314 | j.at("created_at").get_to(e.created_at); 315 | j.at("kind").get_to(e.kind); 316 | j.at("tags").get_to(e.tags); 317 | j.at("sig").get_to(e.sig); 318 | } 319 | 320 | static void do_relay_event(ws28::Client *client, const nlohmann::json &data) { 321 | try { 322 | const event_t ev = data[1]; 323 | if (!check_event(ev)) { 324 | relay_notice(client, "error: invalid id or signature"); 325 | return; 326 | } 327 | 328 | for (const auto &tag : ev.tags) { 329 | if (tag.size() == 1 && tag[0] == "-") { 330 | if (!check_auth_pubkey(client, ev.pubkey)) { 331 | const auto reply = nlohmann::json::array( 332 | {"OK", ev.id, false, "auth-required: authentication required"}); 333 | relay_send(client, reply); 334 | return; 335 | } 336 | } 337 | } 338 | 339 | if (ev.kind == 5) { 340 | for (const auto &tag : ev.tags) { 341 | if (tag.size() >= 2 && tag[0] == "e") { 342 | for (size_t i = 1; i < tag.size(); i++) { 343 | if (delete_record_by_id(tag[i]) < 0) { 344 | return; 345 | } 346 | } 347 | } 348 | } 349 | } else { 350 | if (20000 <= ev.kind && ev.kind < 30000) { 351 | return; 352 | } else if (ev.kind == 0 || ev.kind == 3 || 353 | (10000 <= ev.kind && ev.kind < 20000)) { 354 | if (delete_record_by_kind_and_pubkey(ev.kind, ev.pubkey, 355 | ev.created_at) < 0) { 356 | return; 357 | } 358 | } else if (30000 <= ev.kind && ev.kind < 40000) { 359 | std::string d; 360 | for (const auto &tag : ev.tags) { 361 | if (tag.size() >= 2 && tag[0] == "d") { 362 | if (delete_record_by_kind_and_pubkey_and_dtag( 363 | ev.kind, ev.pubkey, tag, ev.created_at) < 0) { 364 | return; 365 | } 366 | } 367 | } 368 | } 369 | 370 | if (insert_record(ev) != 1) { 371 | relay_notice(client, "error: duplicate event"); 372 | return; 373 | } 374 | } 375 | 376 | for (const auto &s : subscribers) { 377 | if (matched_filters(s.filters, ev)) { 378 | nlohmann::json reply = {"EVENT", s.sub, ev}; 379 | relay_send(s.client, reply); 380 | } 381 | } 382 | nlohmann::json reply = {"OK", ev.id, true, ""}; 383 | relay_send(client, reply); 384 | } catch (std::exception &e) { 385 | spdlog::warn("!! {}", e.what()); 386 | } 387 | } 388 | 389 | static void do_relay_auth(ws28::Client *client, const nlohmann::json &data) { 390 | try { 391 | const event_t ev = data[1]; 392 | if (!check_event(ev)) { 393 | relay_notice(client, "error: invalid id or signature"); 394 | return; 395 | } 396 | 397 | auto ok = 0; 398 | for (const auto &tag : ev.tags) { 399 | if (tag.size() < 2) 400 | continue; 401 | if (tag[0] == "challenge") { 402 | if (tag[1] == challenge(client)) 403 | ok++; 404 | } 405 | if (tag[0] == "relay") { 406 | if (tag[1] == service_url) 407 | ok++; 408 | } 409 | } 410 | 411 | if (ok == 2) { 412 | set_auth_pubkey(client, ev.pubkey); 413 | nlohmann::json reply = {"OK", ev.id, true, ""}; 414 | relay_send(client, reply); 415 | return; 416 | } 417 | 418 | nlohmann::json reply = {"OK", ev.id, false, 419 | "error: failed to authenticate"}; 420 | relay_send(client, reply); 421 | } catch (std::exception &e) { 422 | spdlog::warn("!! {}", e.what()); 423 | } 424 | } 425 | 426 | static auto html = R"( 427 | 428 | 429 | 430 | 431 | Cagliostr 432 | 441 | 442 | 443 |
444 |

Cagliostr the Nostr relay server

445 |

446 |
447 | 448 | 449 | )"; 450 | 451 | static auto nip11 = R"({ 452 | "name": "cagliostr", 453 | "description": "nostr relay written in C++", 454 | "pubkey": "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc", 455 | "contact": "mattn.jp@gmail.com", 456 | "supported_nips": [1, 2, 4, 9, 11, 12, 15, 16, 20, 22, 28, 33, 40, 42, 45, 50, 70], 457 | "software": "https://github.com/mattn/cagliostr", 458 | "version": "develop", 459 | "limitation": { 460 | "max_message_length": 524288, 461 | "max_subscriptions": 20, 462 | "max_filters": 10, 463 | "max_limit": 500, 464 | "max_subid_length": 100, 465 | "max_event_tags": 100, 466 | "max_content_length": 16384, 467 | "min_pow_difficulty": 30, 468 | "auth_required": false, 469 | "payment_required": false, 470 | "restricted_writes": false 471 | }, 472 | "fees": {}, 473 | "icon": "https://raw.githubusercontent.com/mattn/cagliostr/main/cagliostr.png" 474 | })"_json; 475 | 476 | static void http_request_callback(ws28::HTTPRequest &req, 477 | ws28::HTTPResponse &resp) { 478 | spdlog::debug("{} >> {} {}", realIP(req), req.method, req.path); 479 | resp.header("Access-Control-Allow-Origin", "*"); 480 | if (req.method == "GET") { 481 | const auto accept = req.headers.Get("accept"); 482 | if (accept.has_value() && accept.value() == "application/nostr+json") { 483 | resp.status(200); 484 | resp.header("content-type", "application/json; charset=UTF-8"); 485 | resp.send(nip11.dump()); 486 | } else if (req.path == "/") { 487 | resp.status(200); 488 | resp.header("content-type", "text/html; charset=UTF-8"); 489 | resp.send(html); 490 | } else { 491 | resp.status(404); 492 | resp.header("content-type", "text/html; charset=UTF-8"); 493 | resp.send("Not Found\n"); 494 | } 495 | } 496 | } 497 | 498 | static std::string generate_random_hex_16() { 499 | std::random_device rd; 500 | std::mt19937_64 gen(rd()); 501 | std::uniform_int_distribution dist(0, UINT64_MAX); 502 | uint64_t random_value = dist(gen); 503 | std::ostringstream oss; 504 | oss << std::hex << std::setw(16) << std::setfill('0') << random_value; 505 | return oss.str(); 506 | } 507 | 508 | static void connect_callback(ws28::Client *client, ws28::HTTPRequest &req) { 509 | auto challenge = generate_random_hex_16(); 510 | nlohmann::json auth = {"AUTH", challenge}; 511 | relay_send(client, auth); 512 | client_t *ci = new client_t{ 513 | .ip = realIP(req), 514 | .challenge = challenge, 515 | }; 516 | client->SetUserData(ci); 517 | spdlog::debug("CONNECTED {}", ci->ip); 518 | } 519 | 520 | static bool tcpcheck_callback(std::string_view /*ip*/, bool /*secure*/) { 521 | // spdlog::debug("TCPCHECK {} {}", ip, secure); 522 | return true; 523 | } 524 | 525 | static bool check_callback(ws28::Client * /*client*/, ws28::HTTPRequest &req) { 526 | spdlog::debug("CHECK {}", realIP(req)); 527 | return true; 528 | } 529 | 530 | static void disconnect_callback(ws28::Client *client) { 531 | assert(client); 532 | 533 | spdlog::debug("DISCONNECT {}", realIP(client)); 534 | client_t *ci = (client_t *)client->GetUserData(); 535 | if (ci != nullptr) 536 | delete ci; 537 | auto it = subscribers.begin(); 538 | while (it != subscribers.end()) { 539 | if (it->client == client) { 540 | it = subscribers.erase(it); 541 | } else { 542 | it++; 543 | } 544 | } 545 | } 546 | 547 | static inline bool check_method(std::string &method) { 548 | return method == "EVENT" || method == "REQ" || method == "COUNT" || 549 | method == "CLOSE" || method == "AUTH"; 550 | } 551 | 552 | static void data_callback(ws28::Client *client, char *data, size_t len, 553 | int opcode) { 554 | assert(client); 555 | assert(data); 556 | 557 | if (opcode != 1) { 558 | return; 559 | } 560 | 561 | std::string s(data, len); 562 | spdlog::debug("{} >> {}", realIP(client), s); 563 | try { 564 | const auto payload = nlohmann::json::parse(s); 565 | 566 | if (!payload.is_array() || payload.size() < 2 || !payload[0].is_string()) { 567 | relay_notice(client, "error: invalid request"); 568 | return; 569 | } 570 | 571 | std::string method = payload[0]; 572 | if (!check_method(method)) { 573 | std::string id = payload[1]; 574 | relay_notice(client, id, "error: invalid request"); 575 | return; 576 | } 577 | 578 | if (method == "REQ") { 579 | if (payload.size() < 3) { 580 | relay_notice(client, payload[1], "error: invalid request"); 581 | return; 582 | } 583 | do_relay_req(client, payload); 584 | return; 585 | } 586 | if (method == "COUNT") { 587 | if (payload.size() < 3) { 588 | relay_notice(client, payload[1], "error: invalid request"); 589 | return; 590 | } 591 | do_relay_count(client, payload); 592 | return; 593 | } 594 | if (method == "CLOSE") { 595 | do_relay_close(client, payload); 596 | return; 597 | } 598 | if (method == "EVENT") { 599 | do_relay_event(client, payload); 600 | return; 601 | } 602 | if (method == "AUTH") { 603 | do_relay_auth(client, payload); 604 | return; 605 | } 606 | relay_notice(client, payload[1], "error: invalid request"); 607 | } catch (std::exception &e) { 608 | spdlog::warn("!! {}", e.what()); 609 | relay_notice(client, std::string("error: ") + e.what()); 610 | } 611 | 612 | #if defined(__GLIBC__) 613 | // FIXME https://github.com/nlohmann/json/issues/1924 614 | malloc_trim(0); 615 | #endif 616 | } 617 | 618 | static void signal_handler(uv_signal_t *req, int /*signum*/) { 619 | assert(req); 620 | uv_signal_stop(req); 621 | spdlog::warn("!! SIGINT"); 622 | for (auto &s : subscribers) { 623 | if (s.client == nullptr) { 624 | continue; 625 | } 626 | relay_notice(s.client, s.sub, "shutdown..."); 627 | s.client->Close(0); 628 | s.client = nullptr; 629 | } 630 | uv_stop(req->loop); 631 | } 632 | 633 | static std::string env(const char *name, const char *default_value) { 634 | assert(name); 635 | assert(default_value); 636 | const char *value = std::getenv(name); 637 | if (value == nullptr) { 638 | value = default_value; 639 | } 640 | return value; 641 | } 642 | 643 | static void server(short port) { 644 | uv_loop_t *loop = uv_default_loop(); 645 | auto server = ws28::Server{loop, nullptr}; 646 | server.SetClientDataCallback(data_callback); 647 | server.SetClientConnectedCallback(connect_callback); 648 | server.SetClientDisconnectedCallback(disconnect_callback); 649 | server.SetCheckTCPConnectionCallback(tcpcheck_callback); 650 | server.SetMaxMessageSize(65535); 651 | server.SetCheckConnectionCallback(check_callback); 652 | server.SetHTTPCallback(http_request_callback); 653 | server.Listen(port); 654 | spdlog::info("server started :{}", port); 655 | 656 | uv_signal_t sig; 657 | uv_signal_init(loop, &sig); 658 | uv_signal_start(&sig, signal_handler, SIGINT); 659 | 660 | uv_run(loop, UV_RUN_DEFAULT); 661 | } 662 | 663 | int main(int argc, char *argv[]) { 664 | argparse::ArgumentParser program("cagliostr", VERSION); 665 | try { 666 | program.add_argument("-database") 667 | .default_value(env("DATABASE_URL", "./cagliostr.sqlite")) 668 | .help("connection string") 669 | .metavar("DATABASE") 670 | .nargs(1); 671 | program.add_argument("-service-url") 672 | .default_value(env("SERVICE_URL", "")) 673 | .help("service URL") 674 | .metavar("SERVICE_URL") 675 | .nargs(1); 676 | program.add_argument("-loglevel") 677 | .default_value(env("SPDLOG_LEVEL", "info")) 678 | .help("log level") 679 | .metavar("LEVEL") 680 | .nargs(1); 681 | program.add_argument("-port") 682 | .default_value((short)7447) 683 | .help("port number") 684 | .metavar("PORT") 685 | .scan<'i', short>() 686 | .nargs(1); 687 | program.parse_args(argc, argv); 688 | } catch (const std::exception &err) { 689 | std::cerr << err.what() << std::endl; 690 | std::cerr << program; 691 | return 1; 692 | } 693 | 694 | nip11["version"] = VERSION; 695 | 696 | spdlog::cfg::load_env_levels(); 697 | spdlog::set_level( 698 | spdlog::level::from_str(program.get("-loglevel"))); 699 | storage_init(program.get("-database")); 700 | service_url = program.get("-service-url"); 701 | 702 | server(program.get("-port")); 703 | storage_deinit(); 704 | return 0; 705 | } 706 | -------------------------------------------------------------------------------- /records.cxx: -------------------------------------------------------------------------------- 1 | #include "cagliostr.hxx" 2 | #include 3 | #include 4 | 5 | #include 6 | 7 | #include 8 | #include 9 | 10 | // global variables 11 | sqlite3 *conn = nullptr; 12 | 13 | #define PARAM_TYPE_NUMBER (0) 14 | #define PARAM_TYPE_STRING (1) 15 | 16 | typedef struct param_t { 17 | int t{}; 18 | int n{}; 19 | std::string s{}; 20 | } param_t; 21 | 22 | static std::string join(const std::vector &v, 23 | const char *delim = 0) { 24 | std::string s; 25 | if (!v.empty()) { 26 | s += v[0]; 27 | for (decltype(v.size()) i = 1, c = v.size(); i < c; ++i) { 28 | if (delim) { 29 | s += delim; 30 | } 31 | s += v[i]; 32 | } 33 | } 34 | return s; 35 | } 36 | 37 | bool insert_record(const event_t &ev) { 38 | const auto sql = 39 | R"(INSERT INTO event (id, pubkey, created_at, kind, tags, content, sig) VALUES ($1, $2, $3, $4, $5, $6, $7))"; 40 | sqlite3_stmt *stmt = nullptr; 41 | auto ret = sqlite3_prepare_v2(conn, sql, -1, &stmt, nullptr); 42 | if (ret != SQLITE_OK) { 43 | spdlog::error("{}", sqlite3_errmsg(conn)); 44 | return false; 45 | } 46 | nlohmann::json tags = ev.tags; 47 | auto s = tags.dump(); 48 | sqlite3_bind_text(stmt, 1, ev.id.data(), (int)ev.id.size(), nullptr); 49 | sqlite3_bind_text(stmt, 2, ev.pubkey.data(), (int)ev.pubkey.size(), nullptr); 50 | sqlite3_bind_int(stmt, 3, (int)ev.created_at); 51 | sqlite3_bind_int(stmt, 4, ev.kind); 52 | sqlite3_bind_text(stmt, 5, s.data(), (int)s.size(), nullptr); 53 | sqlite3_bind_text(stmt, 6, ev.content.data(), (int)ev.content.size(), 54 | nullptr); 55 | sqlite3_bind_text(stmt, 7, ev.sig.data(), (int)ev.sig.size(), nullptr); 56 | 57 | ret = sqlite3_step(stmt); 58 | if (ret != SQLITE_DONE) { 59 | spdlog::error("{}", sqlite3_errmsg(conn)); 60 | sqlite3_finalize(stmt); 61 | return false; 62 | } 63 | sqlite3_finalize(stmt); 64 | 65 | return true; 66 | } 67 | 68 | static std::string escape(const std::string &data) { 69 | std::string result; 70 | for (const auto c : data) { 71 | if (c == '%') { 72 | result.push_back('%'); 73 | } 74 | result.push_back(c); 75 | } 76 | return result; 77 | } 78 | 79 | static bool is_expired(std::vector> &tags) { 80 | time_t now = time(nullptr), expiration; 81 | for (const auto &tag : tags) { 82 | if (tag.size() == 2 && tag[0] == "expiration") { 83 | std::stringstream ss; 84 | ss << tag[1]; 85 | ss >> expiration; 86 | if (expiration <= now) { 87 | return true; 88 | } 89 | } 90 | } 91 | return false; 92 | } 93 | 94 | bool send_records(std::function sender, 95 | const std::string &sub, const std::vector &filters, 96 | bool do_count) { 97 | auto count = 0; 98 | for (const auto &filter : filters) { 99 | std::string sql; 100 | if (do_count) { 101 | sql = R"(SELECT COUNT(id) FROM event)"; 102 | } else { 103 | sql = 104 | R"(SELECT id, pubkey, created_at, kind, tags, content, sig FROM event)"; 105 | } 106 | 107 | auto limit = 500; 108 | std::vector params; 109 | std::vector conditions; 110 | if (!filter.ids.empty()) { 111 | if (filter.ids.size() == 1) { 112 | conditions.push_back("id = ?"); 113 | params.push_back({.t = PARAM_TYPE_STRING, .s = filter.ids.front()}); 114 | } else { 115 | std::string condition; 116 | for (const auto &id : filter.ids) { 117 | condition += "?,"; 118 | params.push_back({.t = PARAM_TYPE_STRING, .s = id}); 119 | } 120 | condition.pop_back(); 121 | conditions.push_back("id in (" + condition + ")"); 122 | } 123 | } 124 | if (!filter.authors.empty()) { 125 | if (filter.authors.size() == 1) { 126 | conditions.push_back("pubkey = ?"); 127 | params.push_back({.t = PARAM_TYPE_STRING, .s = filter.authors.front()}); 128 | } else { 129 | std::string condition; 130 | for (const auto &author : filter.authors) { 131 | condition += "?,"; 132 | params.push_back({.t = PARAM_TYPE_STRING, .s = author}); 133 | } 134 | condition.pop_back(); 135 | conditions.push_back("pubkey in (" + condition + ")"); 136 | } 137 | } 138 | if (!filter.kinds.empty()) { 139 | if (filter.kinds.size() == 1) { 140 | conditions.push_back("kind = ?"); 141 | params.push_back({.t = PARAM_TYPE_NUMBER, .n = filter.kinds.front()}); 142 | } else { 143 | std::string condition; 144 | for (const auto &kind : filter.kinds) { 145 | condition += "?,"; 146 | params.push_back({.t = PARAM_TYPE_NUMBER, .n = kind}); 147 | } 148 | condition.pop_back(); 149 | conditions.push_back("kind in (" + condition + ")"); 150 | } 151 | } 152 | if (!filter.tags.empty()) { 153 | std::vector match; 154 | for (const auto &tag : filter.tags) { 155 | if (tag.size() < 2) { 156 | continue; 157 | } 158 | auto first = tag[0]; 159 | for (decltype(tag.size()) i = 1; i < tag.size(); i++) { 160 | nlohmann::json data = {first, tag[i]}; 161 | params.push_back( 162 | {.t = PARAM_TYPE_STRING, .s = "%" + escape(data.dump()) + "%"}); 163 | match.push_back(R"(tags LIKE ? ESCAPE '\')"); 164 | } 165 | } 166 | if (match.size() == 1) { 167 | conditions.push_back(match.front()); 168 | } else { 169 | conditions.push_back("(" + join(match, " OR ") + ")"); 170 | } 171 | } 172 | if (filter.since != 0) { 173 | std::ostringstream os; 174 | os << filter.since; 175 | conditions.push_back("created_at >= " + os.str()); 176 | } 177 | if (filter.until != 0) { 178 | std::ostringstream os; 179 | os << filter.until; 180 | conditions.push_back("created_at <= " + os.str()); 181 | } 182 | if (filter.limit > 0 && filter.limit < limit) { 183 | limit = filter.limit; 184 | } 185 | if (!filter.search.empty()) { 186 | params.push_back( 187 | {.t = PARAM_TYPE_STRING, .s = "%" + escape(filter.search) + "%"}); 188 | conditions.push_back(R"(content LIKE ? ESCAPE '\')"); 189 | } 190 | if (!conditions.empty()) { 191 | sql += " WHERE " + join(conditions, " AND "); 192 | } 193 | sql += " ORDER BY created_at DESC LIMIT ?"; 194 | 195 | sqlite3_stmt *stmt = nullptr; 196 | auto ret = 197 | sqlite3_prepare_v2(conn, sql.data(), (int)sql.size(), &stmt, nullptr); 198 | if (ret != SQLITE_OK) { 199 | spdlog::error("{}", sqlite3_errmsg(conn)); 200 | return false; 201 | } 202 | 203 | for (decltype(params.size()) i = 0; i < params.size(); i++) { 204 | switch (params.at(i).t) { 205 | case PARAM_TYPE_NUMBER: 206 | sqlite3_bind_int(stmt, i + 1, params.at(i).n); 207 | break; 208 | case PARAM_TYPE_STRING: 209 | sqlite3_bind_text(stmt, i + 1, params.at(i).s.data(), 210 | (int)params.at(i).s.size(), nullptr); 211 | break; 212 | } 213 | } 214 | 215 | sqlite3_bind_int(stmt, params.size() + 1, limit); 216 | if (do_count) { 217 | ret = sqlite3_step(stmt); 218 | if (ret == SQLITE_DONE) { 219 | spdlog::error("{}", sqlite3_errmsg(conn)); 220 | sqlite3_finalize(stmt); 221 | return false; 222 | } 223 | count += sqlite3_column_int(stmt, 0); 224 | sqlite3_finalize(stmt); 225 | } else { 226 | while (true) { 227 | ret = sqlite3_step(stmt); 228 | if (ret == SQLITE_DONE) { 229 | break; 230 | } 231 | nlohmann::json ej; 232 | ej["id"] = (char *)sqlite3_column_text(stmt, 0); 233 | ej["pubkey"] = (char *)sqlite3_column_text(stmt, 1); 234 | ej["created_at"] = sqlite3_column_int(stmt, 2); 235 | ej["kind"] = sqlite3_column_int(stmt, 3); 236 | const unsigned char *j = sqlite3_column_text(stmt, 4); 237 | ej["tags"] = nlohmann::json::parse(j); 238 | ej["content"] = (char *)sqlite3_column_text(stmt, 5); 239 | ej["sig"] = (char *)sqlite3_column_text(stmt, 6); 240 | 241 | if (ej["tags"].is_array() && ej["tags"].size() > 0) { 242 | std::vector> tags; 243 | ej["tags"].get_to(tags); 244 | if (is_expired(tags)) { 245 | continue; 246 | } 247 | } 248 | 249 | nlohmann::json reply = {"EVENT", sub, ej}; 250 | sender(reply); 251 | } 252 | sqlite3_finalize(stmt); 253 | } 254 | } 255 | 256 | if (do_count) { 257 | nlohmann::json cc; 258 | cc["count"] = count; 259 | nlohmann::json reply = {"COUNT", sub, cc}; 260 | sender(reply); 261 | } 262 | return true; 263 | } 264 | 265 | int delete_record_by_id(const std::string &id) { 266 | const auto sql = R"(DELETE FROM event WHERE id = ?)"; 267 | sqlite3_stmt *stmt = nullptr; 268 | auto ret = sqlite3_prepare_v2(conn, sql, (int)strlen(sql), &stmt, nullptr); 269 | if (ret != SQLITE_OK) { 270 | spdlog::error("{}", sqlite3_errmsg(conn)); 271 | return -1; 272 | } 273 | sqlite3_bind_text(stmt, 1, id.data(), (int)id.size(), nullptr); 274 | 275 | ret = sqlite3_step(stmt); 276 | if (ret != SQLITE_DONE) { 277 | spdlog::error("{}", sqlite3_errmsg(conn)); 278 | sqlite3_finalize(stmt); 279 | return -1; 280 | } 281 | sqlite3_finalize(stmt); 282 | 283 | return sqlite3_changes(conn); 284 | } 285 | 286 | int delete_record_by_kind_and_pubkey(int kind, const std::string &pubkey, std::time_t created_at) { 287 | const auto sql = R"(DELETE FROM event WHERE kind = ? AND pubkey = ? AND created_at < ?)"; 288 | sqlite3_stmt *stmt = nullptr; 289 | auto ret = sqlite3_prepare_v2(conn, sql, (int)strlen(sql), &stmt, nullptr); 290 | if (ret != SQLITE_OK) { 291 | spdlog::error("{}", sqlite3_errmsg(conn)); 292 | return -1; 293 | } 294 | sqlite3_bind_int(stmt, 1, kind); 295 | sqlite3_bind_text(stmt, 2, pubkey.data(), (int)pubkey.size(), nullptr); 296 | sqlite3_bind_int(stmt, 3, created_at); 297 | 298 | ret = sqlite3_step(stmt); 299 | if (ret != SQLITE_DONE) { 300 | spdlog::error("{}", sqlite3_errmsg(conn)); 301 | sqlite3_finalize(stmt); 302 | return -1; 303 | } 304 | sqlite3_finalize(stmt); 305 | 306 | return sqlite3_changes(conn); 307 | } 308 | 309 | int delete_record_by_kind_and_pubkey_and_dtag( 310 | int kind, const std::string &pubkey, const std::vector &tag, std::time_t created_at) { 311 | std::string sql = 312 | R"(SELECT id FROM event WHERE kind = ? AND pubkey = ? AND tags LIKE ? AND created_at < ?)"; 313 | 314 | sqlite3_stmt *stmt = nullptr; 315 | auto ret = 316 | sqlite3_prepare_v2(conn, sql.data(), (int)sql.size(), &stmt, nullptr); 317 | if (ret != SQLITE_OK) { 318 | spdlog::error("{}", sqlite3_errmsg(conn)); 319 | return -1; 320 | } 321 | 322 | nlohmann::json data = tag; 323 | auto s = "%" + escape(data.dump()) + "%"; 324 | data.clear(); 325 | sqlite3_bind_int(stmt, 1, kind); 326 | sqlite3_bind_text(stmt, 2, pubkey.data(), (int)pubkey.size(), nullptr); 327 | sqlite3_bind_text(stmt, 3, s.data(), (int)s.size(), nullptr); 328 | sqlite3_bind_int(stmt, 4, created_at); 329 | 330 | std::vector ids; 331 | while (true) { 332 | ret = sqlite3_step(stmt); 333 | if (ret == SQLITE_DONE) { 334 | break; 335 | } 336 | ids.push_back((char *)sqlite3_column_text(stmt, 0)); 337 | } 338 | sqlite3_finalize(stmt); 339 | 340 | if (ids.empty()) { 341 | return 0; 342 | } 343 | 344 | std::ostringstream os; 345 | std::string condition; 346 | for (decltype(ids.size()) i = 0; i < ids.size(); i++) { 347 | condition += "?,"; 348 | } 349 | condition.pop_back(); 350 | sql = "DELETE FROM event WHERE id in (" + condition + ")"; 351 | 352 | stmt = nullptr; 353 | ret = sqlite3_prepare_v2(conn, sql.data(), (int)sql.size(), &stmt, nullptr); 354 | if (ret != SQLITE_OK) { 355 | spdlog::error("{}", sqlite3_errmsg(conn)); 356 | return -1; 357 | } 358 | for (decltype(ids.size()) i = 0; i < ids.size(); i++) { 359 | sqlite3_bind_text(stmt, i + 1, ids[i].data(), (int)ids[i].size(), nullptr); 360 | } 361 | 362 | ret = sqlite3_step(stmt); 363 | if (ret != SQLITE_DONE) { 364 | spdlog::error("{}", sqlite3_errmsg(conn)); 365 | sqlite3_finalize(stmt); 366 | return -1; 367 | } 368 | sqlite3_finalize(stmt); 369 | 370 | return sqlite3_changes(conn); 371 | } 372 | 373 | static void sqlite3_trace_callback(void * /*user_data*/, 374 | const char *statement) { 375 | assert(statement); 376 | spdlog::debug("{}", statement); 377 | } 378 | 379 | void storage_init(const std::string &dsn) { 380 | spdlog::debug("initialize storage"); 381 | 382 | auto ret = sqlite3_open_v2(dsn.c_str(), &conn, 383 | SQLITE_OPEN_URI | SQLITE_OPEN_READWRITE | 384 | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX, 385 | nullptr); 386 | if (ret != SQLITE_OK) { 387 | spdlog::error("{}", sqlite3_errmsg(conn)); 388 | exit(-1); 389 | } 390 | sqlite3_trace(conn, sqlite3_trace_callback, nullptr); 391 | 392 | const auto sql = R"( 393 | CREATE TABLE IF NOT EXISTS event ( 394 | id text NOT NULL, 395 | pubkey text NOT NULL, 396 | created_at integer NOT NULL, 397 | kind integer NOT NULL, 398 | tags jsonb NOT NULL, 399 | content text NOT NULL, 400 | sig text NOT NULL); 401 | CREATE UNIQUE INDEX IF NOT EXISTS ididx ON event(id); 402 | CREATE INDEX IF NOT EXISTS pubkeyprefix ON event(pubkey); 403 | CREATE INDEX IF NOT EXISTS timeidx ON event(created_at DESC); 404 | CREATE INDEX IF NOT EXISTS kindidx ON event(kind); 405 | CREATE INDEX IF NOT EXISTS kindtimeidx ON event(kind,created_at DESC); 406 | PRAGMA journal_mode = WAL; 407 | PRAGMA busy_timeout = 5000; 408 | PRAGMA synchronous = NORMAL; 409 | PRAGMA cache_size = 1000000000; 410 | PRAGMA foreign_keys = true; 411 | PRAGMA temp_store = memory; 412 | )"; 413 | ret = sqlite3_exec(conn, sql, nullptr, nullptr, nullptr); 414 | if (ret != SQLITE_OK) { 415 | spdlog::error("{}", sqlite3_errmsg(conn)); 416 | exit(-1); 417 | } 418 | } 419 | 420 | void storage_deinit() { sqlite3_close_v2(conn); } 421 | -------------------------------------------------------------------------------- /sign.cxx: -------------------------------------------------------------------------------- 1 | #include "cagliostr.hxx" 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | static inline std::vector hex2bytes(const std::string &hex) { 9 | std::vector bytes; 10 | for (decltype(hex.length()) i = 0; i < hex.length(); i += 2) { 11 | std::string s = hex.substr(i, 2); 12 | auto byte = (uint8_t)strtol(s.c_str(), nullptr, 16); 13 | bytes.push_back(byte); 14 | } 15 | return bytes; 16 | } 17 | 18 | static inline std::string digest2hex(const uint8_t data[32]) { 19 | std::stringstream ss; 20 | ss << std::hex; 21 | for (size_t i = 0; i < 32; ++i) { 22 | ss << std::setw(2) << std::setfill('0') << (int)data[i]; 23 | } 24 | return ss.str(); 25 | } 26 | 27 | static bool signature_verify(const std::vector &bytes_sig, 28 | const std::vector &bytes_pub, 29 | const uint8_t digest[32]) { 30 | #define secp256k1_context_flags \ 31 | (SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY) 32 | secp256k1_context *ctx = secp256k1_context_create(secp256k1_context_flags); 33 | secp256k1_xonly_pubkey pub; 34 | if (!secp256k1_xonly_pubkey_parse(ctx, &pub, bytes_pub.data())) { 35 | secp256k1_context_destroy(ctx); 36 | return false; 37 | } 38 | 39 | auto result = secp256k1_schnorrsig_verify(ctx, bytes_sig.data(), digest, 40 | #ifdef SECP256K1_SCHNORRSIG_EXTRAPARAMS_INIT 41 | 32, 42 | #endif 43 | &pub); 44 | secp256k1_context_destroy(ctx); 45 | return result; 46 | } 47 | 48 | bool check_event(const event_t &ev) { 49 | nlohmann::json check = {0, ev.pubkey, ev.created_at, 50 | ev.kind, ev.tags, ev.content}; 51 | auto dump = check.dump(); 52 | check.clear(); 53 | 54 | uint8_t digest[32] = {0}; 55 | EVP_Digest(dump.data(), dump.size(), digest, nullptr, EVP_sha256(), nullptr); 56 | 57 | auto id = digest2hex(digest); 58 | if (id != ev.id) { 59 | return false; 60 | } 61 | 62 | auto bytes_sig = hex2bytes(ev.sig); 63 | auto bytes_pub = hex2bytes(ev.pubkey); 64 | if (!signature_verify(bytes_sig, bytes_pub, digest)) { 65 | return false; 66 | } 67 | return true; 68 | } 69 | -------------------------------------------------------------------------------- /test.cxx: -------------------------------------------------------------------------------- 1 | // clang-format off 2 | 3 | #include 4 | #undef ok 5 | #define CAGLIOSTR_TEST 6 | #include "cagliostr.hxx" 7 | 8 | #include 9 | #include 10 | 11 | #include 12 | 13 | static event_t string2event(auto &string) { 14 | auto ej = nlohmann::json::parse(string); 15 | event_t ev; 16 | ev.id = ej["id"]; 17 | ev.pubkey = ej["pubkey"]; 18 | ev.content = ej["content"]; 19 | ev.created_at = ej["created_at"]; 20 | ev.kind = ej["kind"]; 21 | ev.tags = ej["tags"]; 22 | ev.sig = ej["sig"]; 23 | return ev; 24 | } 25 | 26 | static void test_cagliostr_records() { 27 | std::filesystem::remove("test.sqlite"); 28 | 29 | storage_init("test.sqlite"); 30 | event_t ev; 31 | 32 | ev = string2event( 33 | R"({"id":"bb97556f36930838b8593b9e3dd130182e77f34ddf6c8e351b41b1753dc2580a","pubkey":"2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc","created_at":1706278266,"kind":1,"tags":[["r","https://image.nostr.build/9b882abc8183d79fdda4c5278228a5f1641b78fa457e643532c5e1c2d89ae6f9.jpg#m=image%2Fjpeg\u0026dim=1067x1920\u0026blurhash=%5DN9%25MXON%5EnotWSf5jcWAo4WAk9t8kBogofM_WFR%25WBjvR%24s%3Bjrj%3FogRiahRjWBa%23WTj%5DWUa%7DfRRjWERjWBWVR%25ahWBWBjb\u0026x=fdde40d498de759392222679f0a1166c9d4b4012bc815be385aa3e9bd1a225ed"]],"content":"mattn いすぎじゃない?\nhttps://image.nostr.build/9b882abc8183d79fdda4c5278228a5f1641b78fa457e643532c5e1c2d89ae6f9.jpg#m=image%2Fjpeg\u0026dim=1067x1920\u0026blurhash=%5DN9%25MXON%5EnotWSf5jcWAo4WAk9t8kBogofM_WFR%25WBjvR%24s%3Bjrj%3FogRiahRjWBa%23WTj%5DWUa%7DfRRjWERjWBWVR%25ahWBWBjb\u0026x=fdde40d498de759392222679f0a1166c9d4b4012bc815be385aa3e9bd1a225ed","sig":"757a1864233031b013eef28b4e47e16bfe15055e5488735f869270f4488875aad56399fc2b28468617470698b586ddeff5261e7dc386178817d2ce0d6ea36301"})"); 34 | 35 | // tests for insert_record 36 | _ok(insert_record(ev), "insert_records should be succeeded"); 37 | 38 | _ok(!insert_record(ev), "duplicated event must be rejected"); 39 | 40 | // tests for delete_record_by_id 41 | _ok(delete_record_by_id("bb97556f36930838b8593b9e3dd130182e77f34ddf6c8e351b41b1753dc2580b") == 0, "delete_record_by_id should be failed for invalid id"); 42 | 43 | _ok(delete_record_by_id("bb97556f36930838b8593b9e3dd130182e77f34ddf6c8e351b41b1753dc2580a") == 1, "delete_record_by_id should be succeeded for valid id"); 44 | 45 | // tests for delete_record_by_kind_and_pubkey 46 | _ok(insert_record(ev), "insert_records should be succeeded"); 47 | 48 | _ok(delete_record_by_kind_and_pubkey(0, "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc", 1721057587) == 0, "delete_record_by_kind_and_pubkey should be failed for invalid kind"); 49 | 50 | _ok(delete_record_by_kind_and_pubkey(1, "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdd", 1721057587) == 0, "delete_record_by_kind_and_pubkey should be failed for invalid pubkey"); 51 | 52 | _ok(delete_record_by_kind_and_pubkey(1, "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc", 1721057587) == 1, "delete_record_by_kind_and_pubkey should be succeeded for valid kind and pubkey"); 53 | 54 | // tests for delete_record_by_kind_and_pubkey_and_dtag 55 | ev = string2event(R"({"id":"60a2ad094a92a2fc6619ef7b0e489a316868974647dd4853a3732029b95c93f0","pubkey":"2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc","created_at":1705420612,"kind":30023,"tags":[["r","https://nwc.getalby.com/apps/new?c=Algia"],["r","http://myproxy.example.com:8080"],["d","algia-article-test"],["title","Algia Article Test"],["summary","This is a test"],["published_at","1705420612"],["a","30023:2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc:algia-article-test","wss://yabu.me"]],"content":"# algia\n\nnostr CLI client written in Go\n\n## Usage\n\n```\nNAME:\n algia - A cli application for nostr\n\nUSAGE:\n algia [global options] command [command options] [arguments...]\n\nDESCRIPTION:\n A cli application for nostr\n\nCOMMANDS:\n timeline, tl show timeline\n stream show stream\n post, n post new note\n reply, r reply to the note\n repost, b repost the note\n unrepost, B unrepost the note\n like, l like the note\n unlike, L unlike the note\n delete, d delete the note\n search, s search notes\n dm-list show DM list\n dm-timeline show DM timeline\n dm-post post new note\n profile show profile\n powa post ぽわ〜\n puru post ぷる\n zap zap note1\n version show version\n help, h Shows a list of commands or help for one command\n\nGLOBAL OPTIONS:\n -a value profile name\n --relays value relays\n -V verbose (default: false)\n --help, -h show help\n```\n\n## Installation\n\nDownload binary from Release page.\n\nOr install with go install command.\n```\ngo install github.com/mattn/algia@latest\n```\n\n## Configuration\n\nMinimal configuration. Need to be at ~/.config/algia/config.json\n\n```json\n{\n \"relays\": {\n \"wss://relay-jp.nostr.wirednet.jp\": {\n \"read\": true,\n \"write\": true,\n \"search\": false\n }\n },\n \"privatekey\": \"nsecXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\"\n}\n```\n\nIf you want to zap via Nostr Wallet Connect, please add `nwc-pub` and `nwc-uri` which are provided from \u003chttps://nwc.getalby.com/apps/new?c=Algia\u003e\n\n```json\n{\n \"relays\": {\n ...\n },\n \"privatekey\": \"nsecXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\",\n \"nwc-uri\": \"nostr+walletconnect://xxxxx\",\n \"nwc-pub\": \"xxxxxxxxxxxxxxxxxxxxxxx\"\n}\n```\n\n## TODO\n\n* [x] like\n* [x] repost\n* [x] zap\n* [x] upload images\n\n## FAQ\n\nDo you use proxy? then set environment variable `HTTP_PROXY` like below.\n\n HTTP_PROXY=http://myproxy.example.com:8080\n\n## License\n\nMIT\n\n## Author\n\nYasuhiro Matsumoto (a.k.a. mattn)\n","sig":"802600cdd86e3d21435832307a0f01da8e031060880c0aa6d7f6338e17202b34e2eba6bab2c8acf316ff78c1b2489d38f02eaea6da892de31448af4875e503f6"})"); 56 | 57 | _ok(insert_record(ev), "insert_records should be succeeded"); 58 | 59 | std::vector valid_dtag = {"d", "algia-article-test"}; 60 | std::vector invalid_dtag = {"d", "algia-article-test_"}; 61 | 62 | _ok(delete_record_by_kind_and_pubkey_and_dtag(30022, "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc", valid_dtag, 1721057587) == 0, "delete_record_by_kind_and_pubkey_and_dtag should be failed for invalid kind"); 63 | 64 | _ok(delete_record_by_kind_and_pubkey_and_dtag(30023, "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdd", valid_dtag, 1721057587) == 0, "delete_record_by_kind_and_pubkey_and_dtag should be failed for invalid pubkey"); 65 | 66 | _ok(delete_record_by_kind_and_pubkey_and_dtag(30023, "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc", invalid_dtag, 1721057587) == 0, "delete_record_by_kind_and_pubkey_and_dtag should be failed for invalid dtag"); 67 | 68 | _ok(delete_record_by_kind_and_pubkey_and_dtag(30023, "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc", valid_dtag, 1721057587) == 1, "delete_record_by_kind_and_pubkey_and_dtag should be succeeded for valid kind and pubkey and dtag"); 69 | 70 | storage_deinit(); 71 | } 72 | 73 | static void test_cagliostr_sign() { 74 | event_t ev; 75 | 76 | ev = string2event( 77 | R"({"id":"bb97556f36930838b8593b9e3dd130182e77f34ddf6c8e351b41b1753dc2580a","pubkey":"2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc","created_at":1706278266,"kind":1,"tags":[["r","https://image.nostr.build/9b882abc8183d79fdda4c5278228a5f1641b78fa457e643532c5e1c2d89ae6f9.jpg#m=image%2Fjpeg\u0026dim=1067x1920\u0026blurhash=%5DN9%25MXON%5EnotWSf5jcWAo4WAk9t8kBogofM_WFR%25WBjvR%24s%3Bjrj%3FogRiahRjWBa%23WTj%5DWUa%7DfRRjWERjWBWVR%25ahWBWBjb\u0026x=fdde40d498de759392222679f0a1166c9d4b4012bc815be385aa3e9bd1a225ed"]],"content":"mattn いすぎじゃない?\nhttps://image.nostr.build/9b882abc8183d79fdda4c5278228a5f1641b78fa457e643532c5e1c2d89ae6f9.jpg#m=image%2Fjpeg\u0026dim=1067x1920\u0026blurhash=%5DN9%25MXON%5EnotWSf5jcWAo4WAk9t8kBogofM_WFR%25WBjvR%24s%3Bjrj%3FogRiahRjWBa%23WTj%5DWUa%7DfRRjWERjWBWVR%25ahWBWBjb\u0026x=fdde40d498de759392222679f0a1166c9d4b4012bc815be385aa3e9bd1a225ed","sig":"757a1864233031b013eef28b4e47e16bfe15055e5488735f869270f4488875aad56399fc2b28468617470698b586ddeff5261e7dc386178817d2ce0d6ea36301"})"); 78 | 79 | _ok(check_event(ev), "check_event should be succeeded for valid sig"); 80 | 81 | ev.sig = "757a1864233031b013eef28b4e47e16bfe15055e5488735f869270f4488875aad56399fc2b28468617470698b586ddeff5261e7dc386178817d2ce0d6ea36302"; 82 | _ok(!check_event(ev), "check_event should be failed for invalid sig"); 83 | } 84 | 85 | int main() { 86 | spdlog::set_level(spdlog::level::off); 87 | 88 | subtest("test_cagliostr_records", test_cagliostr_records); 89 | subtest("test_cagliostr_sign", test_cagliostr_sign); 90 | return done_testing(); 91 | } 92 | -------------------------------------------------------------------------------- /version.h: -------------------------------------------------------------------------------- 1 | #define VERSION "devel" 2 | --------------------------------------------------------------------------------